PluginProbe ʕ •ᴥ•ʔ
پارسی دیت – Parsi Date / trunk
پارسی دیت – Parsi Date vtrunk
6.2 6.1 5.1.6 5.1.7 5.1.8 5.1.8.2 6.0 trunk 1.0 1.1 1.2 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 2.0.0-alpha 2.1 2.1.1 2.1.2 2.1.3 2.1.5 2.1.6 2.1.7 2.2.0 2.2.1 2.2.2 2.2.3 2.3.0.1 2.3.0.2 2.3.1 2.3.2 2.3.3 2.3.4 3.0.1 3.0.2 3.0.3 4.0.0 4.0.1 4.0.2 5.1.0 5.1.1 5.1.2 5.1.3 5.1.4 5.1.5
wp-parsidate / inc / Helper / NumberConverter.php
wp-parsidate / inc / Helper Last commit date
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