File: /home/parskavirpaya/public_html/wp-content/plugins/wp-parsidate/inc/Helper/NumberConverter.php
<?php
/**
* Numbers converter for English numbers in HTML content
* This code generated by ChatGPT! thanks, AI.
*/
declare( strict_types=1 );
namespace WPParsidate\Helper;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMText;
use DOMXPath;
final class NumberConverter {
/**
* Tags to fully protect before DOM parsing.
* Their content is restored byte-for-byte.
*/
private const PROTECTED_TAGS = [
'script',
'style',
'pre',
'code',
'textarea',
'noscript',
'svg',
];
/**
* Tags whose text descendants should never be converted.
* Secondary safety layer after protected-block stripping.
*/
private const SKIP_TEXT_INSIDE_TAGS = [
'script',
'style',
'pre',
'code',
'textarea',
'noscript',
'kbd',
'samp',
'svg',
];
/**
* Common classes used by code/highlight/editor blocks.
*/
private const SKIP_CLASSES = [
'wp-block-code',
'wp-block-preformatted',
'code',
'hljs',
'prism',
'highlight',
'syntaxhighlighter',
'crayon-syntax',
];
private const ROOT_ID = '__wp_parsidate_persian_number_root__';
/**
* Public API.
*
* Converts ASCII digits to Persian digits in eligible text nodes only.
* Returns original content unchanged if conversion is unnecessary or parsing fails.
*/
public static function convertContent( string $content ): string {
if ( $content === '' ) {
return $content;
}
// Fast bailouts.
if ( ! self::containsAsciiDigit( $content ) ) {
return $content;
}
// If your site is Persian-only and you want ALL visible numbers converted,
// you can remove this check. Keeping it improves performance and avoids
// converting English-only text blocks.
if ( ! self::containsPersianOrArabic( $content ) ) {
return $content;
}
$protected = [];
$working = self::protectFragileBlocks( $content, $protected );
// After protection, maybe no visible digits remain.
if ( ! self::containsAsciiDigit( $working ) ) {
return self::restoreFragileBlocks( $working, $protected );
}
$converted = self::convertHtmlTextNodes( $working );
if ( $converted === null ) {
// Fail-safe: return original content, not partially processed content.
return $content;
}
return self::restoreFragileBlocks( $converted, $protected );
}
/**
* Quick ASCII digit test.
*/
private static function containsAsciiDigit( string $content ): bool {
return (bool) preg_match( '/[0-9]/', $content );
}
/**
* Check whether the content contains Persian/Arabic script characters.
*
* This reduces unnecessary work on English-only content and avoids converting
* English sections in multilingual pages unless they contain Persian/Arabic text nearby.
*/
private static function containsPersianOrArabic( string $content ): bool {
return (bool) preg_match( '/[\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{08A0}-\x{08FF}]/u', $content );
}
/**
* Replace fragile blocks with placeholders so DOM parsing never touches them.
*
* @param string $html
* @param array<string,string> $protected
*
* @return string
*/
private static function protectFragileBlocks( string $html, array &$protected ): string {
$tags_pattern = implode( '|', array_map( 'preg_quote', self::PROTECTED_TAGS ) );
$pattern = '~<(' . $tags_pattern . ')\b[^>]*>.*?</\1>~isu';
return (string) preg_replace_callback(
$pattern,
static function ( array $matches ) use ( &$protected ): string {
$token = self::makePlaceholder( count( $protected ) );
$protected[ $token ] = $matches[0];
return $token;
},
$html
);
}
/**
* Restore protected placeholders.
*
* @param array<string,string> $protected
*/
private static function restoreFragileBlocks( string $html, array $protected ): string {
if ( empty( $protected ) ) {
return $html;
}
return strtr( $html, $protected );
}
private static function makePlaceholder( int $index ): string {
try {
$suffix = bin2hex( random_bytes( 8 ) );
} catch ( \Exception $e ) {
$suffix = md5( uniqid( (string) $index, true ) );
}
return '%%YPPNC_' . $index . '_' . $suffix . '%%';
}
/**
* Parse fragment HTML and convert eligible text nodes.
*
* Returns null on failure.
*/
private static function convertHtmlTextNodes( string $html ): ?string {
$previous_errors = libxml_use_internal_errors( true );
$dom = new DOMDocument( '1.0', 'UTF-8' );
$wrapped = '<?xml encoding="utf-8" ?><div id="' . self::ROOT_ID . '">' . $html . '</div>';
$flags = 0;
if ( defined( 'LIBXML_HTML_NOIMPLIED' ) ) {
$flags |= LIBXML_HTML_NOIMPLIED;
}
if ( defined( 'LIBXML_HTML_NODEFDTD' ) ) {
$flags |= LIBXML_HTML_NODEFDTD;
}
$loaded = $dom->loadHTML( $wrapped, $flags );
if ( ! $loaded ) {
libxml_clear_errors();
libxml_use_internal_errors( $previous_errors );
return null;
}
$xpath = new DOMXPath( $dom );
$text_nodes = $xpath->query( '//*[@id="' . self::ROOT_ID . '"]//text()' );
if ( $text_nodes === false ) {
libxml_clear_errors();
libxml_use_internal_errors( $previous_errors );
return null;
}
/** @var DOMNode $node */
foreach ( $text_nodes as $node ) {
if ( ! $node instanceof DOMText ) {
continue;
}
$value = $node->nodeValue;
if ( $value === null || $value === '' ) {
continue;
}
// Very cheap skip before expensive checks.
if ( ! self::containsAsciiDigit( $value ) ) {
continue;
}
// Skip text nodes that do not contain Persian/Arabic text.
// Remove this if you want all visible numbers converted everywhere.
if ( ! self::containsPersianOrArabic( $value ) ) {
continue;
}
if ( self::shouldSkipTextNode( $node ) ) {
continue;
}
$node->nodeValue = self::convertNumbersInText( $value );
}
$root = $dom->getElementById( self::ROOT_ID );
if ( ! $root instanceof DOMElement ) {
libxml_clear_errors();
libxml_use_internal_errors( $previous_errors );
return null;
}
$result = '';
foreach ( $root->childNodes as $child ) {
$result .= $dom->saveHTML( $child );
}
libxml_clear_errors();
libxml_use_internal_errors( $previous_errors );
// Defensive cleanup in case XML declaration leaks.
$result = (string) preg_replace( '/^<\?xml.+?\?>/u', '', $result );
return $result;
}
private static function shouldSkipTextNode( DOMText $node ): bool {
$parent = $node->parentNode;
while ( $parent instanceof DOMNode ) {
if ( $parent instanceof DOMElement ) {
if ( ! is_string( $parent->tagName ) ) {
return true;
}
$tag = strtolower( $parent->tagName );
if ( in_array( $tag, self::SKIP_TEXT_INSIDE_TAGS, true ) ) {
return true;
}
if ( self::elementHasSkipClass( $parent ) ) {
return true;
}
if ( $parent->hasAttribute( 'contenteditable' ) ) {
return true;
}
}
$parent = $parent->parentNode;
}
return false;
}
private static function elementHasSkipClass( DOMElement $element ): bool {
if ( ! $element->hasAttribute( 'class' ) ) {
return false;
}
$class_attr = $element->getAttribute( 'class' );
if ( $class_attr === '' ) {
return false;
}
$classes = preg_split( '/\s+/u', trim( $class_attr ) );
if ( ! is_array( $classes ) || empty( $classes ) ) {
return false;
}
foreach ( $classes as $class ) {
if ( in_array( $class, self::SKIP_CLASSES, true ) ) {
return true;
}
}
return false;
}
/**
* Convert only standalone numeric tokens, not digits inside identifiers.
*
* Converts:
* 875254
* 12.5
* +15
* -7.2
*
* Does not convert:
* abc123
* SR7_1_1
* item_25
* v2ray
*/
private static function convertNumbersInText( string $text ): string {
return (string) preg_replace_callback(
'/(?<![\pL\pN_])([+-]?\d+(?:\.\d+)?)(?![\pL\pN_])/u',
static function ( array $matches ): string {
return self::toPersianNumber( $matches[1] );
},
$text
);
}
private static function toPersianNumber( string $value ): string {
return strtr(
$value,
[
'0' => '۰',
'1' => '۱',
'2' => '۲',
'3' => '۳',
'4' => '۴',
'5' => '۵',
'6' => '۶',
'7' => '۷',
'8' => '۸',
'9' => '۹',
// If you want Persian decimal separator instead of ".", uncomment:
// '.' => '٫',
]
);
}
}