Assets.php
6 hours ago
Cache.php
3 weeks ago
Date.php
6 hours ago
Debug.php
5 hours ago
FeedReader.php
6 hours ago
HTML.php
6 hours ago
Helper.php
3 weeks ago
JSON.php
3 weeks ago
Nonce.php
3 weeks ago
Notice.php
6 hours ago
Number.php
3 weeks ago
NumberConverter.php
6 hours ago
Param.php
3 weeks ago
Sanitizing.php
6 hours ago
Strip.php
3 weeks ago
Templates.php
3 weeks ago
User.php
3 weeks ago
Validating.php
3 weeks ago
WooCommerce.php
3 weeks ago
WordPress.php
6 hours ago
NumberConverter.php
361 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Numbers converter for English numbers in HTML content |
| 4 | * This code generated by ChatGPT! thanks, AI. |
| 5 | */ |
| 6 | |
| 7 | declare( strict_types=1 ); |
| 8 | |
| 9 | namespace WPParsidate\Helper; |
| 10 | |
| 11 | use DOMDocument; |
| 12 | use DOMElement; |
| 13 | use DOMNode; |
| 14 | use DOMText; |
| 15 | use DOMXPath; |
| 16 | |
| 17 | final class NumberConverter { |
| 18 | /** |
| 19 | * Tags to fully protect before DOM parsing. |
| 20 | * Their content is restored byte-for-byte. |
| 21 | */ |
| 22 | private const PROTECTED_TAGS = [ |
| 23 | 'script', |
| 24 | 'style', |
| 25 | 'pre', |
| 26 | 'code', |
| 27 | 'textarea', |
| 28 | 'noscript', |
| 29 | 'svg', |
| 30 | ]; |
| 31 | |
| 32 | /** |
| 33 | * Tags whose text descendants should never be converted. |
| 34 | * Secondary safety layer after protected-block stripping. |
| 35 | */ |
| 36 | private const SKIP_TEXT_INSIDE_TAGS = [ |
| 37 | 'script', |
| 38 | 'style', |
| 39 | 'pre', |
| 40 | 'code', |
| 41 | 'textarea', |
| 42 | 'noscript', |
| 43 | 'kbd', |
| 44 | 'samp', |
| 45 | 'svg', |
| 46 | ]; |
| 47 | |
| 48 | /** |
| 49 | * Common classes used by code/highlight/editor blocks. |
| 50 | */ |
| 51 | private const SKIP_CLASSES = [ |
| 52 | 'wp-block-code', |
| 53 | 'wp-block-preformatted', |
| 54 | 'code', |
| 55 | 'hljs', |
| 56 | 'prism', |
| 57 | 'highlight', |
| 58 | 'syntaxhighlighter', |
| 59 | 'crayon-syntax', |
| 60 | ]; |
| 61 | |
| 62 | private const ROOT_ID = '__wp_parsidate_persian_number_root__'; |
| 63 | |
| 64 | /** |
| 65 | * Public API. |
| 66 | * |
| 67 | * Converts ASCII digits to Persian digits in eligible text nodes only. |
| 68 | * Returns original content unchanged if conversion is unnecessary or parsing fails. |
| 69 | */ |
| 70 | public static function convertContent( string $content ): string { |
| 71 | if ( $content === '' ) { |
| 72 | return $content; |
| 73 | } |
| 74 | |
| 75 | // Fast bailouts. |
| 76 | if ( ! self::containsAsciiDigit( $content ) ) { |
| 77 | return $content; |
| 78 | } |
| 79 | |
| 80 | // If your site is Persian-only and you want ALL visible numbers converted, |
| 81 | // you can remove this check. Keeping it improves performance and avoids |
| 82 | // converting English-only text blocks. |
| 83 | if ( ! self::containsPersianOrArabic( $content ) ) { |
| 84 | return $content; |
| 85 | } |
| 86 | |
| 87 | $protected = []; |
| 88 | $working = self::protectFragileBlocks( $content, $protected ); |
| 89 | |
| 90 | // After protection, maybe no visible digits remain. |
| 91 | if ( ! self::containsAsciiDigit( $working ) ) { |
| 92 | return self::restoreFragileBlocks( $working, $protected ); |
| 93 | } |
| 94 | |
| 95 | $converted = self::convertHtmlTextNodes( $working ); |
| 96 | |
| 97 | if ( $converted === null ) { |
| 98 | // Fail-safe: return original content, not partially processed content. |
| 99 | return $content; |
| 100 | } |
| 101 | |
| 102 | return self::restoreFragileBlocks( $converted, $protected ); |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * Quick ASCII digit test. |
| 107 | */ |
| 108 | private static function containsAsciiDigit( string $content ): bool { |
| 109 | return (bool) preg_match( '/[0-9]/', $content ); |
| 110 | } |
| 111 | |
| 112 | /** |
| 113 | * Check whether the content contains Persian/Arabic script characters. |
| 114 | * |
| 115 | * This reduces unnecessary work on English-only content and avoids converting |
| 116 | * English sections in multilingual pages unless they contain Persian/Arabic text nearby. |
| 117 | */ |
| 118 | private static function containsPersianOrArabic( string $content ): bool { |
| 119 | return (bool) preg_match( '/[\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{08A0}-\x{08FF}]/u', $content ); |
| 120 | } |
| 121 | |
| 122 | /** |
| 123 | * Replace fragile blocks with placeholders so DOM parsing never touches them. |
| 124 | * |
| 125 | * @param string $html |
| 126 | * @param array<string,string> $protected |
| 127 | * |
| 128 | * @return string |
| 129 | */ |
| 130 | private static function protectFragileBlocks( string $html, array &$protected ): string { |
| 131 | $tags_pattern = implode( '|', array_map( 'preg_quote', self::PROTECTED_TAGS ) ); |
| 132 | |
| 133 | $pattern = '~<(' . $tags_pattern . ')\b[^>]*>.*?</\1>~isu'; |
| 134 | |
| 135 | return (string) preg_replace_callback( |
| 136 | $pattern, |
| 137 | static function ( array $matches ) use ( &$protected ): string { |
| 138 | $token = self::makePlaceholder( count( $protected ) ); |
| 139 | $protected[ $token ] = $matches[0]; |
| 140 | |
| 141 | return $token; |
| 142 | }, |
| 143 | $html |
| 144 | ); |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * Restore protected placeholders. |
| 149 | * |
| 150 | * @param array<string,string> $protected |
| 151 | */ |
| 152 | private static function restoreFragileBlocks( string $html, array $protected ): string { |
| 153 | if ( empty( $protected ) ) { |
| 154 | return $html; |
| 155 | } |
| 156 | |
| 157 | return strtr( $html, $protected ); |
| 158 | } |
| 159 | |
| 160 | private static function makePlaceholder( int $index ): string { |
| 161 | try { |
| 162 | $suffix = bin2hex( random_bytes( 8 ) ); |
| 163 | } catch ( \Exception $e ) { |
| 164 | $suffix = md5( uniqid( (string) $index, true ) ); |
| 165 | } |
| 166 | |
| 167 | return '%%YPPNC_' . $index . '_' . $suffix . '%%'; |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * Parse fragment HTML and convert eligible text nodes. |
| 172 | * |
| 173 | * Returns null on failure. |
| 174 | */ |
| 175 | private static function convertHtmlTextNodes( string $html ): ?string { |
| 176 | $previous_errors = libxml_use_internal_errors( true ); |
| 177 | |
| 178 | $dom = new DOMDocument( '1.0', 'UTF-8' ); |
| 179 | |
| 180 | $wrapped = '<?xml encoding="utf-8" ?><div id="' . self::ROOT_ID . '">' . $html . '</div>'; |
| 181 | |
| 182 | $flags = 0; |
| 183 | if ( defined( 'LIBXML_HTML_NOIMPLIED' ) ) { |
| 184 | $flags |= LIBXML_HTML_NOIMPLIED; |
| 185 | } |
| 186 | if ( defined( 'LIBXML_HTML_NODEFDTD' ) ) { |
| 187 | $flags |= LIBXML_HTML_NODEFDTD; |
| 188 | } |
| 189 | |
| 190 | $loaded = $dom->loadHTML( $wrapped, $flags ); |
| 191 | |
| 192 | if ( ! $loaded ) { |
| 193 | libxml_clear_errors(); |
| 194 | libxml_use_internal_errors( $previous_errors ); |
| 195 | |
| 196 | return null; |
| 197 | } |
| 198 | |
| 199 | $xpath = new DOMXPath( $dom ); |
| 200 | $text_nodes = $xpath->query( '//*[@id="' . self::ROOT_ID . '"]//text()' ); |
| 201 | |
| 202 | if ( $text_nodes === false ) { |
| 203 | libxml_clear_errors(); |
| 204 | libxml_use_internal_errors( $previous_errors ); |
| 205 | |
| 206 | return null; |
| 207 | } |
| 208 | |
| 209 | /** @var DOMNode $node */ |
| 210 | foreach ( $text_nodes as $node ) { |
| 211 | if ( ! $node instanceof DOMText ) { |
| 212 | continue; |
| 213 | } |
| 214 | |
| 215 | $value = $node->nodeValue; |
| 216 | |
| 217 | if ( $value === null || $value === '' ) { |
| 218 | continue; |
| 219 | } |
| 220 | |
| 221 | // Very cheap skip before expensive checks. |
| 222 | if ( ! self::containsAsciiDigit( $value ) ) { |
| 223 | continue; |
| 224 | } |
| 225 | |
| 226 | // Skip text nodes that do not contain Persian/Arabic text. |
| 227 | // Remove this if you want all visible numbers converted everywhere. |
| 228 | if ( ! self::containsPersianOrArabic( $value ) ) { |
| 229 | continue; |
| 230 | } |
| 231 | |
| 232 | if ( self::shouldSkipTextNode( $node ) ) { |
| 233 | continue; |
| 234 | } |
| 235 | |
| 236 | $node->nodeValue = self::convertNumbersInText( $value ); |
| 237 | } |
| 238 | |
| 239 | $root = $dom->getElementById( self::ROOT_ID ); |
| 240 | if ( ! $root instanceof DOMElement ) { |
| 241 | libxml_clear_errors(); |
| 242 | libxml_use_internal_errors( $previous_errors ); |
| 243 | |
| 244 | return null; |
| 245 | } |
| 246 | |
| 247 | $result = ''; |
| 248 | |
| 249 | foreach ( $root->childNodes as $child ) { |
| 250 | $result .= $dom->saveHTML( $child ); |
| 251 | } |
| 252 | |
| 253 | libxml_clear_errors(); |
| 254 | libxml_use_internal_errors( $previous_errors ); |
| 255 | |
| 256 | // Defensive cleanup in case XML declaration leaks. |
| 257 | $result = (string) preg_replace( '/^<\?xml.+?\?>/u', '', $result ); |
| 258 | |
| 259 | return $result; |
| 260 | } |
| 261 | |
| 262 | private static function shouldSkipTextNode( DOMText $node ): bool { |
| 263 | $parent = $node->parentNode; |
| 264 | |
| 265 | while ( $parent instanceof DOMNode ) { |
| 266 | if ( $parent instanceof DOMElement ) { |
| 267 | if ( ! is_string( $parent->tagName ) ) { |
| 268 | return true; |
| 269 | } |
| 270 | |
| 271 | $tag = strtolower( $parent->tagName ); |
| 272 | |
| 273 | if ( in_array( $tag, self::SKIP_TEXT_INSIDE_TAGS, true ) ) { |
| 274 | return true; |
| 275 | } |
| 276 | |
| 277 | if ( self::elementHasSkipClass( $parent ) ) { |
| 278 | return true; |
| 279 | } |
| 280 | |
| 281 | if ( $parent->hasAttribute( 'contenteditable' ) ) { |
| 282 | return true; |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | $parent = $parent->parentNode; |
| 287 | } |
| 288 | |
| 289 | return false; |
| 290 | } |
| 291 | |
| 292 | private static function elementHasSkipClass( DOMElement $element ): bool { |
| 293 | if ( ! $element->hasAttribute( 'class' ) ) { |
| 294 | return false; |
| 295 | } |
| 296 | |
| 297 | $class_attr = $element->getAttribute( 'class' ); |
| 298 | if ( $class_attr === '' ) { |
| 299 | return false; |
| 300 | } |
| 301 | |
| 302 | $classes = preg_split( '/\s+/u', trim( $class_attr ) ); |
| 303 | if ( ! is_array( $classes ) || empty( $classes ) ) { |
| 304 | return false; |
| 305 | } |
| 306 | |
| 307 | foreach ( $classes as $class ) { |
| 308 | if ( in_array( $class, self::SKIP_CLASSES, true ) ) { |
| 309 | return true; |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | return false; |
| 314 | } |
| 315 | |
| 316 | /** |
| 317 | * Convert only standalone numeric tokens, not digits inside identifiers. |
| 318 | * |
| 319 | * Converts: |
| 320 | * 875254 |
| 321 | * 12.5 |
| 322 | * +15 |
| 323 | * -7.2 |
| 324 | * |
| 325 | * Does not convert: |
| 326 | * abc123 |
| 327 | * SR7_1_1 |
| 328 | * item_25 |
| 329 | * v2ray |
| 330 | */ |
| 331 | private static function convertNumbersInText( string $text ): string { |
| 332 | return (string) preg_replace_callback( |
| 333 | '/(?<![\pL\pN_])([+-]?\d+(?:\.\d+)?)(?![\pL\pN_])/u', |
| 334 | static function ( array $matches ): string { |
| 335 | return self::toPersianNumber( $matches[1] ); |
| 336 | }, |
| 337 | $text |
| 338 | ); |
| 339 | } |
| 340 | |
| 341 | private static function toPersianNumber( string $value ): string { |
| 342 | return strtr( |
| 343 | $value, |
| 344 | [ |
| 345 | '0' => '۰', |
| 346 | '1' => '۱', |
| 347 | '2' => '۲', |
| 348 | '3' => '۳', |
| 349 | '4' => '۴', |
| 350 | '5' => '۵', |
| 351 | '6' => '۶', |
| 352 | '7' => '۷', |
| 353 | '8' => '۸', |
| 354 | '9' => '۹', |
| 355 | // If you want Persian decimal separator instead of ".", uncomment: |
| 356 | // '.' => '٫', |
| 357 | ] |
| 358 | ); |
| 359 | } |
| 360 | } |
| 361 |