PluginProbe ʕ •ᴥ•ʔ
GPTranslate – Multilingual AI Translation for WordPress: Automatically Translate Websites / 2.33.7
GPTranslate – Multilingual AI Translation for WordPress: Automatically Translate Websites v2.33.7
2.33.7 2.33.6 2.33.5 2.33.2 2.32.10 2.33 2.33.1 2.32.6 2.32.7 2.32.8 trunk 2.10.3 2.10.4 2.10.5 2.10.6 2.11 2.12 2.13 2.14 2.14.1 2.15 2.15.1 2.16.1 2.16.2 2.17 2.18 2.18.1 2.18.2 2.19 2.20 2.21 2.22 2.23 2.24 2.25 2.25.1 2.25.2 2.26 2.27 2.27.10 2.27.5 2.28 2.28.1 2.29 2.30 2.31 2.32 2.32.5
gptranslate / serverside-translations.php
gptranslate Last commit date
assets 5 days ago flags 5 days ago includes 5 days ago language 5 days ago ajax-handler.php 5 days ago gptranslate.php 5 days ago multilang-routing.php 5 days ago readme.txt 5 days ago serverside-translations.php 5 days ago settings.php 5 days ago simplehtmldom.php 5 days ago uninstall.php 5 days ago
serverside-translations.php
2379 lines
1 <?php
2 if (!defined('ABSPATH')) exit;
3
4 // Hook to apply server-side translations before output is sent to the browser
5 add_action ( 'template_redirect', function () {
6 if (is_admin ()) {
7 return;
8 }
9
10 $raw_request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
11 $uri = esc_url_raw( $raw_request_uri );
12
13 $settings = get_option ( 'gptranslate_options', [ ] );
14
15 if (empty ( $settings ['serverside_translations'] ) || $settings ['serverside_translations'] != 1)
16 return;
17
18 // Skip static assets and admin/API routes
19 if (preg_match ( '#\.(ico|png|jpe?g|gif|svg|css|js|woff2?|ttf|eot|mp4|webm)$#i', $uri ) || strpos ( $uri, '/wp-' ) === 0 || strpos ( $uri, '/wp-json/' ) === 0) {
20 return;
21 }
22
23 function normalizeText($text) {
24 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
25 $text = str_replace(["\xC2\xA0", "\n", "\r", "\t"], ' ', $text); // NBSP e spazi strani
26 $text = preg_replace('/\s+/u', ' ', $text); // spazi multipli → singolo spazio
27 $text = trim($text);
28 return $text;
29 }
30
31 // Normalizza senza trim per preservare spazi iniziali/finali (utile per SimpleHTMLDOM).
32 function normalizeTextNoTrim($text) {
33 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
34 $text = str_replace(["\xC2\xA0", "\n", "\r", "\t"], ' ', $text); // NBSP e spazi strani
35 $text = preg_replace('/\s+/u', ' ', $text); // spazi multipli -> singolo spazio
36 return $text;
37 }
38
39 // Normalizza il testo per l'incremental translation detection con dictionary replacement
40 // Applica le stesse trasformazioni del client-side JS per far corrispondere i testi nel DB
41 function normalizeTextForIncremental($text, $settings, $originalLang, $translatedLang) {
42 // Normalizzazione base
43 $text = normalizeText($text);
44
45 // Carica il dictionary come array (è una stringa JSON)
46 $dictionary = isset($settings['words_leafnodes_excluded_bylanguage_repeatable'])
47 ? $settings['words_leafnodes_excluded_bylanguage_repeatable']
48 : [];
49
50 // Decodifica la stringa JSON se necessario
51 if (is_string($dictionary)) {
52 $dictionary = json_decode($dictionary, true);
53 }
54
55 if (!is_array($dictionary)) {
56 return $text;
57 }
58
59 // Iterare su tutte le entries del dictionary
60 foreach ($dictionary as $entry) {
61 // Filtrare per lingua (fallback a "*" per all languages)
62 $langOriginal = isset($entry['langOriginal']) ? $entry['langOriginal'] : '*';
63 $langTranslated = isset($entry['langTranslated']) ? $entry['langTranslated'] : '*';
64
65 // Verifica che le lingue corrispondano (considerando "*" come wildcard)
66 if (($langOriginal !== '*' && $langOriginal !== $originalLang) || ($langTranslated !== '*' && $langTranslated !== $translatedLang)) {
67 continue;
68 }
69
70 // Parse words (CSV format: "word1,word2,word3")
71 $words = isset($entry['word']) ? array_map('trim', explode(',', $entry['word'])) : [];
72
73 // Parse translations (CSV format: "trans1,trans2,trans3")
74 $translations = isset($entry['optionalTranslation']) ? array_map('trim', explode(',', $entry['optionalTranslation'])) : [];
75
76 // Applica sostituzioni per indice (word[0] → translation[0], ecc.)
77 foreach ($words as $i => $word) {
78 if (empty($word)) continue;
79
80 $replacement = isset($translations[$i]) ? $translations[$i] : '';
81 if (empty($replacement)) continue;
82
83 // Word-boundary matching: sostituisce solo le parole intere, non substring
84 // Esempio: 'di' non sostituisce 'di' dentro 'modify' o 'indifferenziato'
85 $pattern = '/\b' . preg_quote($word, '/') . '\b/ui';
86 $text = preg_replace($pattern, $replacement, $text);
87 }
88 }
89
90 return $text;
91 }
92
93 ob_start ( function ($html) use ($uri, $settings) {
94 global $wpdb;
95
96 $originalLang = $settings ['language'] ?? '';
97
98 if (! defined ( 'GPTRANSLATE_CURRENT_LANG' )) {
99 return $html;
100 }
101
102 $translatedLang = GPTRANSLATE_CURRENT_LANG;
103
104 if (empty ( $originalLang ) || $originalLang === $translatedLang)
105 return $html;
106
107 if ($settings['subfolder_installation'] && ! $settings ['rewrite_language_alias']) {
108 $uri_parts = explode('/', ltrim($uri, '/'));
109
110 // Remove the first part (subfolder name)
111 $subfolder_prefix = '/' . array_shift($uri_parts);
112
113 // Rebuild URI without subfolder
114 $uri = '/' . implode('/', $uri_parts);
115 }
116
117 $pageLink = rtrim ( get_site_url (), '/' ) . '/' . ltrim ( GPTRANSLATE_CURRENT_LANG, '/' ) . $uri;
118
119 if ( $settings ['serverside_translations_urldecode'] ) {
120 $pageLink = urldecode ( $pageLink );
121 }
122
123 if ($settings ['serverside_translations_ignore_querystring'] == 1) {
124 // Remove query string
125 $pageLink = strtok ( $pageLink, '?' );
126 } elseif (! empty ( $_SERVER ['QUERY_STRING'] )) {
127 // Sanitize and rebuild query string
128 $raw_qs = isset($_SERVER['QUERY_STRING']) ? wp_unslash($_SERVER['QUERY_STRING']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
129
130 parse_str ( $raw_qs, $qs_args );
131
132 $clean_args = [ ];
133 foreach ( $qs_args as $key => $value ) {
134 $clean_key = sanitize_key ( $key );
135 $clean_value = is_array ( $value ) ? array_map ( 'sanitize_text_field', $value ) : sanitize_text_field ( $value );
136 $clean_args [$clean_key] = $clean_value;
137 }
138
139 $clean_qs = http_build_query ( $clean_args, '', '&', PHP_QUERY_RFC3986 );
140
141 if ($clean_qs !== '') {
142 $pageLink = strtok ( $pageLink, '?' ) . '?' . $clean_qs;
143 }
144 }
145
146 // Strip entire query string if any server-injected parameter is detected (e.g. q, fbclid)
147 // Acts as a conditional ignore_querystring=1 triggered by specific parameter names
148 if (!empty($settings['serverside_translations_strip_querystring_params']) && strpos($pageLink, '?') !== false) {
149 $stripParams = array_map('trim', explode(',', strtolower($settings['serverside_translations_strip_querystring_params'])));
150 $pageLinkQs = parse_url($pageLink, PHP_URL_QUERY);
151 if ($pageLinkQs) {
152 parse_str($pageLinkQs, $qsArgs);
153 foreach ($stripParams as $param) {
154 if (isset($qsArgs[$param])) {
155 $pageLink = strtok($pageLink, '?');
156 break;
157 }
158 }
159 }
160 }
161
162 if ( $settings ['serverside_translations_urlencode_space'] ) {
163 $pageLink = str_ireplace ( ' ', '%20', $pageLink );
164 }
165
166 $table = $wpdb->prefix . 'gptranslate';
167
168 if( $settings ['rewrite_language_url'] && $settings ['rewrite_language_alias'] ) {
169 $row = $wpdb->get_row ( $wpdb->prepare ( "SELECT pagelink, translations, alt_translations FROM {$wpdb->prefix}gptranslate" . // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
170 "\n WHERE ( pagelink = %s OR pagelink = %s OR translated_alias = %s OR translated_alias = %s) AND languageoriginal = %s AND languagetranslated = %s AND published = 1",
171 rtrim($pageLink, '/'), rtrim($pageLink, '/') . '/', rtrim($pageLink, '/'), rtrim($pageLink, '/') . '/', $originalLang, $translatedLang ), ARRAY_A );
172 } else {
173 $row = $wpdb->get_row ( $wpdb->prepare ( "SELECT pagelink, translations, alt_translations FROM {$wpdb->prefix}gptranslate" . // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
174 "\n WHERE ( pagelink = %s OR pagelink = %s ) AND languageoriginal = %s AND languagetranslated = %s AND published = 1",
175 rtrim($pageLink, '/'), rtrim($pageLink, '/') . '/', $originalLang, $translatedLang ), ARRAY_A );
176 }
177
178 if (! $row) {
179 return $html;
180 }
181
182 // Load translated aliases to replace on page links
183 $translatedAliasesMap = []; // Map absolute URLs
184 $translatedAliasesRelativeMap = []; // Map relative URL (pathname)
185
186 // Store settings for keep context flatten tags
187 $flattenInnerFormattingTags = isset($settings['flatten_inner_formatting_tags']) ? $settings['flatten_inner_formatting_tags'] : 0;
188 $flattenInnerFormattingTagsToRemove = isset($settings['flatten_inner_formatting_tags_to_remove']) ? explode(',', $settings['flatten_inner_formatting_tags_to_remove']) : explode(',', 'strong,em,u,b,i');
189 $wrapExcludedWords = isset($settings['wrap_excluded_words']) ? $settings['wrap_excluded_words'] : 0;
190
191 if (!empty($settings['rewrite_page_links']) && !empty($settings['rewrite_language_url']) && !empty($settings['rewrite_language_alias'])) {
192 try {
193 $aliasesResult = $wpdb->get_results( $wpdb->prepare(
194 "SELECT pagelink, translated_alias" .
195 "\n FROM {$table}" .
196 "\n WHERE languagetranslated = %s" .
197 "\n AND published = 1",
198 $translatedLang
199 ), ARRAY_A );
200
201 if ($aliasesResult) {
202 $parsedRoot = trailingslashit(get_site_url());
203
204 foreach ($aliasesResult as $rowAliasResult) {
205 // NORMALIZE, WORDPRESS STYLE (KEEP TRAILING SLASH)
206 $pagelink = gpt_trailingslashit_url($rowAliasResult['pagelink']);
207 $translatedAlias = $rowAliasResult['translated_alias'] ?? '';
208
209 if (empty($translatedAlias)) continue;
210
211 $translatedAlias = gpt_trailingslashit_url($translatedAlias);
212
213 // First pass: use decoded pagelink and alias as key (DB stores decoded UTF-8)
214 $translatedAliasesMap[$pagelink] = $translatedAlias;
215
216 // Second pass: use decoded relative path as key
217 $relativePath = str_replace($parsedRoot, '/', $pagelink);
218 $relativePath = parse_url($relativePath, PHP_URL_PATH);
219 if ($relativePath) {
220 $translatedAliasesRelativeMap[gpt_trailingslashit_url($relativePath)] = $translatedAlias;
221 }
222 }
223 }
224 } catch (Exception $e) {
225 // Silently fail
226 }
227 }
228
229 // Check if incremental mode is enabled and preserve new texts option is active
230 $incrementalEnabledPreserveNewTexts = !empty($settings['incremental_enabled']) && !empty($settings['incremental_preserve_new_texts']);
231
232 // Load excluded words list for incremental preserve mode
233 // When incremental_preserve_new_texts is ON, we use excluded words to do a "partial greedy match":
234 // - strip excluded words from the segment, then compare remainder with DB keys
235 // - this lets "Test new post" (Test=excluded) match DB key "new post" → "nuovo post"
236 // - without accidentally applying partial translations like "now"→"ora" to new untranslated text
237 $excludedWordsList = [];
238 $excludedWordsReplacementMap = [];
239 if ($incrementalEnabledPreserveNewTexts) {
240 // 1. Simple excluded words (words_leafnodes_excluded)
241 $rawExcluded = $settings['words_leafnodes_excluded'] ?? '';
242 $rawExcluded = str_ireplace(["\r", "\n"], ",", $rawExcluded);
243 $rawExcluded = preg_replace('/,+/', ',', $rawExcluded);
244 $excludedWordsList = array_filter(array_map('trim', explode(',', $rawExcluded)));
245 foreach ($excludedWordsList as $word) {
246 $key = mb_strtolower(trim($word), 'UTF-8');
247 if ($key !== '' && !isset($excludedWordsReplacementMap[$key])) {
248 $excludedWordsReplacementMap[$key] = '';
249 }
250 }
251
252 // 2. Dictionary (words_leafnodes_excluded_bylanguage_repeatable)
253 // Each entry has: word (CSV), langOriginal, langTranslated, optionalTranslation
254 // Only include entries that match the current language pair (* = any language)
255 $dictionaryRaw = $settings['words_leafnodes_excluded_bylanguage_repeatable'] ?? '[]';
256 if (is_string($dictionaryRaw)) {
257 $dictionary = json_decode($dictionaryRaw, true) ?? [];
258 } else {
259 $dictionary = (array) $dictionaryRaw;
260 }
261 foreach ($dictionary as $entry) {
262 $entryLangOrig = $entry['langOriginal'] ?? '*';
263 $entryLangTarget = $entry['langTranslated'] ?? '*';
264 $matchesOrig = ($entryLangOrig === '*' || $entryLangOrig === $originalLang);
265 $matchesTarget = ($entryLangTarget === '*' || $entryLangTarget === $translatedLang);
266 if ($matchesOrig && $matchesTarget) {
267 $words = array_filter(array_map('trim', explode(',', $entry['word'] ?? '')));
268 $translations = array_map('trim', explode(',', $entry['optionalTranslation'] ?? ''));
269 foreach ($words as $i => $word) {
270 if (!empty($word)) {
271 $excludedWordsList[] = $word;
272 $key = mb_strtolower(trim($word), 'UTF-8');
273 $replacement = isset($translations[$i]) ? $translations[$i] : '';
274 if ($key !== '' && !isset($excludedWordsReplacementMap[$key])) {
275 $excludedWordsReplacementMap[$key] = $replacement;
276 } elseif ($key !== '' && $excludedWordsReplacementMap[$key] === '' && $replacement !== '') {
277 $excludedWordsReplacementMap[$key] = $replacement;
278 }
279 }
280 }
281 }
282 }
283
284 $excludedWordsList = array_unique($excludedWordsList);
285 }
286
287 // Process replacement method
288 if ($settings ['serverside_translations_method'] == 'regex') {
289 $translations = json_decode ( $row ['translations'], true ) ?? [ ];
290 $altTranslations = json_decode ( $row ['alt_translations'], true ) ?? [ ];
291 uksort ( $translations, fn ($a, $b) => strlen ( $b ) - strlen ( $a ) );
292 uksort ( $altTranslations, fn ($a, $b) => strlen ( $b ) - strlen ( $a ) );
293
294 // Flatten formatting tags in the body to improve matching if enabled
295 if ($flattenInnerFormattingTags) {
296 $regexTagsToRemove = implode('|', array_map('preg_quote', $flattenInnerFormattingTagsToRemove));
297 function gp_flatten_formatting_tags_regex($html, $regexTagsToRemove) {
298 $pattern = '~<(' . $regexTagsToRemove . ')(\s[^>]*)?>(.*?)</\1>~is';
299
300 // Ripeti finché non ci sono più match (gestisce annidamenti semplici)
301 $prev = null;
302 while ($prev !== $html) {
303 $prev = $html;
304 $html = preg_replace_callback($pattern, function ($m) {
305 // Preserva elementi che contengono form elements o media (select, input, textarea, button, img, ecc.)
306 if (preg_match('/<(select|input|textarea|button|option|optgroup|fieldset|datalist|output|label|form|img|video|audio|canvas|svg|iframe)[\s>\/]/i', $m[3])) {
307 return $m[0]; // restituisci il tag originale intatto
308 }
309 // Preserva elementi vuoti (es. <i> FontAwesome/icon fonts)
310 if (trim(str_replace(['&nbsp;', "\xc2\xa0", "\xa0"], '', strip_tags($m[3]))) === '') {
311 return $m[0];
312 }
313 // tieni SOLO il testo interno (se dentro c’è altro HTML lo butta via)
314 $inner = strip_tags($m[3]);
315 return $inner;
316 }, $html);
317 }
318
319 return $html;
320 }
321 $html = gp_flatten_formatting_tags_regex($html, $regexTagsToRemove);
322 }
323
324 $caseInsensitive = ! empty ( $settings ['serverside_translations_caseinsensitive'] );
325 $matchQuotes = ! empty ( $settings ['serverside_translations_matchquotes'] );
326 $excludedPatterns = [ ];
327
328 $excludedCss = preg_replace ( '/,+/', ',', str_ireplace ( [
329 "\r",
330 "\n",
331 '"'
332 ], [
333 ',',
334 ',',
335 ''
336 ], $settings ['css_selector_serverside_leafnodes_excluded'] ?? '') );
337 $excludedCss = array_filter ( array_map ( 'trim', explode ( ',', $excludedCss ) ) );
338
339 foreach ( $excludedCss as $selector ) {
340 if (preg_match ( '/^([a-z0-9]+)\.(.+)$/i', $selector, $m )) {
341 $excludedPatterns [] = '/<' . preg_quote ( $m [1], '/' ) . '(?=[^>]*\sclass\s*=\s*["\'][^"\']*\b' . preg_quote ( $m [2], '/' ) . '\b)[^>]*>/i';
342 } elseif (preg_match ( '/^\.(.+)$/', $selector, $m )) {
343 $excludedPatterns [] = '/<([a-z0-9]+)(?=[^>]*\sclass\s*=\s*["\'][^"\']*\b' . preg_quote ( $m [1], '/' ) . '\b)[^>]*>/i';
344 }
345 }
346
347 // PROTECT script/style content before splitting (if option enabled)
348 $protectedScriptsStyles = [];
349
350 if (!empty($settings['protect_script_style'])) {
351 $protectCounter = 0;
352
353 // Extract <script>...</script> tags
354 $html = preg_replace_callback(
355 '/<script\b[^>]*>.*?<\/script>/is',
356 function ($matches) use (&$protectedScriptsStyles, &$protectCounter) {
357 $placeholder = "___GPTRANSLATE_SCRIPT_" . ($protectCounter++) . "___";
358 $protectedScriptsStyles[$placeholder] = $matches[0];
359 return $placeholder;
360 },
361 $html
362 );
363
364 // Extract <style>...</style> tags
365 $html = preg_replace_callback(
366 '/<style\b[^>]*>.*?<\/style>/is',
367 function ($matches) use (&$protectedScriptsStyles, &$protectCounter) {
368 $placeholder = "___GPTRANSLATE_STYLE_" . ($protectCounter++) . "___";
369 $protectedScriptsStyles[$placeholder] = $matches[0];
370 return $placeholder;
371 },
372 $html
373 );
374 }
375
376 $segments = preg_split ( '/(<[^>]+>)/i', $html, - 1, PREG_SPLIT_DELIM_CAPTURE );
377
378 $skipStack = [ ];
379 foreach ( $segments as $index => $segment ) {
380 if (preg_match ( '/^<\s*(script|style)(\s|>)/i', $segment, $matches )) {
381 $skipStack [] = strtolower ( $matches [1] );
382 } elseif (preg_match ( '/<\/\s*(script|style)[^>]*>/i', $segment, $matches )) { // <--- FIX QUI
383 $tag = strtolower ( $matches [1] );
384 if (! empty ( $skipStack ) && end ( $skipStack ) === $tag) {
385 array_pop ( $skipStack );
386 }
387 } elseif (preg_match ( '/^<\s*([a-zA-Z0-9]+)/', $segment, $tagMatch )) {
388 $tagName = strtolower ( $tagMatch [1] );
389 foreach ( $excludedPatterns as $pattern ) {
390 if (preg_match ( $pattern, $segment )) {
391 $skipStack [] = $tagName;
392 break;
393 }
394 }
395 } elseif (preg_match ( '/^<\/\s*([a-zA-Z0-9]+)/', $segment, $tagMatch )) {
396 $tagName = strtolower ( $tagMatch [1] );
397 if (! empty ( $skipStack ) && end ( $skipStack ) === $tagName) {
398 array_pop ( $skipStack );
399 }
400 } elseif (! preg_match ( '/^<[^>]+>$/', $segment )) {
401 if (empty ( $skipStack )) {
402 if ($matchQuotes) {
403 $segment = str_ireplace ( '"', "'", $segment );
404 }
405 // Normalize once without trimming to preserve spaces between nodes
406 $segment = normalizeTextNoTrim($segment);
407
408 // INCREMENTAL FIX: Use exact match only if incremental preserve new texts is enabled
409 if ($incrementalEnabledPreserveNewTexts) {
410 // Extract leading/trailing whitespace from original segment
411 preg_match('/^(\s*)(.*?)(\s*)$/s', $segment, $wsMatches);
412 $leadingWhitespace = $wsMatches[1] ?? '';
413 $trailingWhitespace = $wsMatches[3] ?? '';
414
415 // Apply dictionary replacement for incremental matching (matches client-side behavior)
416 $normalizedSegment = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($segment, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($segment, $settings, $originalLang, $translatedLang);
417 $found = false;
418
419 // Step 1: Exact match
420 foreach ($translations as $originalText => $translatedText) {
421 $normalizedOrig = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang);
422 if (trim($normalizedSegment) === trim($normalizedOrig)) {
423 $segment = $leadingWhitespace . $translatedText . $trailingWhitespace;
424 $found = true;
425 break;
426 }
427 }
428
429 // Step 2: Excluded-words-aware fragment matching
430 // Mirrors client-side wrap_excluded_words behavior: client creates SEPARATE text nodes
431 // around excluded words, so DB stores fragments separately, NOT the full text.
432 // Example: "Now i add a new text and modify the page" with 'text' excluded
433 // Client creates 3 nodes: "Now i add a new ", "text" (preserved), " and modify the page"
434 // DB stores: "Now i add a new " → "Ora aggiungo un nuovo ", " and modify the page" → " e modificare la pagina"
435 // We split the segment by excluded words, translate each fragment, preserve excluded words.
436 if (!$found && !empty($excludedWordsList)) {
437 $excludedPattern = '';
438 foreach ($excludedWordsList as $excludedWord) {
439 $excludedWord = trim($excludedWord);
440 if (!empty($excludedWord)) {
441 $excludedPattern .= ($excludedPattern ? '|' : '') . preg_quote($excludedWord, '/');
442 }
443 }
444
445 if (!empty($excludedPattern)) {
446 // Split keeping excluded words as delimiters (PREG_SPLIT_DELIM_CAPTURE)
447 $parts = preg_split(
448 '/((?<!\w)(?:' . $excludedPattern . ')(?!\w))/' . ($caseInsensitive ? 'ui' : 'u'),
449 $segment,
450 -1,
451 PREG_SPLIT_DELIM_CAPTURE
452 );
453
454 if (is_array($parts) && count($parts) > 1) {
455 $rebuiltSegment = '';
456 $anyTranslated = false;
457
458 foreach ($parts as $part) {
459 if ($part === '') {
460 continue;
461 }
462 // Check if this part is an excluded word - preserve as-is
463 $isExcluded = false;
464 $matchedExcludedWord = '';
465 foreach ($excludedWordsList as $excludedWord) {
466 if (strcasecmp(trim($part), trim($excludedWord)) === 0) {
467 $isExcluded = true;
468 $matchedExcludedWord = mb_strtolower(trim($excludedWord), 'UTF-8');
469 break;
470 }
471 }
472
473 if ($isExcluded) {
474 $replacement = $excludedWordsReplacementMap[$matchedExcludedWord] ?? '';
475 if ($replacement !== '') {
476 $rebuiltSegment .= $replacement;
477 $anyTranslated = true;
478 } else {
479 $rebuiltSegment .= $part;
480 }
481 } else {
482 // Try to find an exact match for this fragment in DB
483 $normalizedPart = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($part, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($part, $settings, $originalLang, $translatedLang);
484 $fragTranslated = false;
485 foreach ($translations as $originalText => $translatedText) {
486 $normalizedOrig = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang);
487 if (trim($normalizedPart) !== '' && trim($normalizedPart) === trim($normalizedOrig)) {
488 $rebuiltSegment .= $translatedText;
489 $fragTranslated = true;
490 $anyTranslated = true;
491 break;
492 }
493 }
494 if (!$fragTranslated) {
495 // Fragment not found in DB - keep original
496 $rebuiltSegment .= $part;
497 }
498 }
499 }
500
501 if ($anyTranslated) {
502 $segment = $rebuiltSegment;
503 $found = true;
504 }
505 }
506 }
507 }
508 } else {
509 // Normal pattern matching (standard behavior)
510 foreach ( $translations as $originalText => $translatedText ) {
511 $originalText = normalizeText($originalText);
512
513 $prevSegment = $segment;
514 if ($wrapExcludedWords) {
515 // Use word boundaries to avoid sub-replacing inside already translated words
516 $pattern = '/' . (preg_match('/^\w/u', trim($originalText)) ? '(?<!\w)' : '') . preg_quote(trim($originalText), '/') . '(?!\w)/' . ($caseInsensitive ? 'ui' : 'u');
517 $segment = preg_replace($pattern, $translatedText, $segment);
518 } else {
519 // Use word boundaries to avoid sub-replacing inside already translated words
520 $pattern = '/' . (preg_match('/^\w/u', trim($originalText)) ? '(?<!\w)' : '') . preg_quote(trim($originalText), '/') . '(?!\w)/' . ($caseInsensitive ? 'ui' : 'u');
521 $segment = preg_replace($pattern, $translatedText, $segment);
522 if ($segment !== $prevSegment) {
523 break;
524 }
525 }
526 }
527 }
528 }
529 }
530
531 $segments [$index] = $segment;
532 }
533
534 $html = implode ( '', $segments );
535
536 // RESTORE protected script/style content (if protection was enabled)
537 if (!empty($settings['protect_script_style'])) {
538 foreach ($protectedScriptsStyles as $placeholder => $originalContent) {
539 $html = str_replace($placeholder, $originalContent, $html);
540 }
541 }
542
543 // Replace alt and title in images
544 if (! empty ( $settings ['translate_altimages'] )) {
545 $html = preg_replace_callback ( '/<img[^>]*\b(alt|title)\s*=\s*([\"\'])(.*?)\2[^>]*>/i', function ($matches) use ($altTranslations, $caseInsensitive, $matchQuotes) {
546 $attr = $matches [1];
547 $quote = $matches [2];
548 $value = $matches [3];
549 if ($matchQuotes)
550 $value = str_ireplace ( '"', "'", $value );
551 foreach ( $altTranslations as $original => $translated ) {
552 $original = normalizeText($original);
553 $value = normalizeText($value);
554
555 $value = $caseInsensitive ? str_ireplace ( trim ( $original ), $translated, $value ) : str_replace ( trim ( $original ), $translated, $value );
556 }
557 return preg_replace ( '/\b' . $attr . '\s*=\s*["\'].*?["\']/', "$attr=$quote$value$quote", $matches [0] );
558 }, $html );
559
560 // Check if also images 'src' are enabled to be translated
561 if (! empty ( $settings ['translate_srcimages'] )) {
562 $html = preg_replace_callback('/<img\b[^>]*\bsrc\s*=\s*(["\'])(.*?)\1[^>]*>/i', function ($matches) use ($altTranslations, $caseInsensitive, $matchQuotes) {
563 $originalTag = $matches[0];
564 $quote = $matches[1];
565 $srcValue = ltrim($matches[2], '/');
566
567 if ($matchQuotes) {
568 $srcValue = str_ireplace('"', "'", $srcValue);
569 }
570
571 $translatedSrcValue = null;
572 foreach ($altTranslations as $originalText => $translatedText) {
573 $originalText = ltrim($originalText, '/');
574 if ($caseInsensitive) {
575 if (strcasecmp(trim($originalText), $srcValue) === 0) {
576 $translatedSrcValue = $translatedText;
577 break;
578 }
579 } else {
580 if (trim($originalText) === $srcValue) {
581 $translatedSrcValue = $translatedText;
582 break;
583 }
584 }
585 }
586
587 // If a translation was found, replace src and srcset
588 if ($translatedSrcValue !== null) {
589 // Replace the src attribute
590 $modifiedTag = preg_replace('/\bsrc\s*=\s*["\'].*?["\']/i', 'src=' . $quote . $translatedSrcValue . $quote, $originalTag);
591
592 // Check if srcset exists and replace it with the translated src value
593 if (preg_match('/\bsrcset\s*=\s*(["\'])(.*?)\1/i', $modifiedTag, $srcsetMatch)) {
594 $srcsetQuote = $srcsetMatch[1];
595 $modifiedTag = preg_replace('/\bsrcset\s*=\s*["\'].*?["\']/i', 'srcset=' . $srcsetQuote . $translatedSrcValue . $srcsetQuote, $modifiedTag);
596 }
597
598 return $modifiedTag;
599 }
600
601 return $originalTag;
602 }, $html);
603 }
604
605 // Check if also iframes 'src' are enabled to be translated
606 if (! empty ( $settings ['translate_srciframes'] )) {
607 $html = preg_replace_callback('/<iframe\b[^>]*\bsrc\s*=\s*(["\'])(.*?)\1[^>]*>/i', function ($matches) use ($altTranslations, $caseInsensitive, $matchQuotes) {
608 $originalTag = $matches[0];
609 $quote = $matches[1];
610 $srcValue = ltrim($matches[2], '/');
611
612 if ($matchQuotes) {
613 $srcValue = str_ireplace('"', "'", $srcValue);
614 }
615
616 $translatedSrcValue = null;
617 foreach ($altTranslations as $originalText => $translatedText) {
618 $originalText = ltrim($originalText, '/');
619 if ($caseInsensitive) {
620 if (strcasecmp(trim($originalText), $srcValue) === 0) {
621 $translatedSrcValue = $translatedText;
622 break;
623 }
624 } else {
625 if (trim($originalText) === $srcValue) {
626 $translatedSrcValue = $translatedText;
627 break;
628 }
629 }
630 }
631
632 if ($translatedSrcValue !== null) {
633 return preg_replace('/\bsrc\s*=\s*["\'].*?["\']/i', 'src=' . $quote . $translatedSrcValue . $quote, $originalTag);
634 }
635
636 return $originalTag;
637 }, $html);
638 }
639
640 // Check if also videos 'src' are enabled to be translated
641 if (! empty ( $settings ['translate_srcvideos'] )) {
642 $html = preg_replace_callback('/<(video|source)\b[^>]*\bsrc\s*=\s*(["\'])(.*?)\2[^>]*>/i', function ($matches) use ($altTranslations, $caseInsensitive, $matchQuotes) {
643 $originalTag = $matches[0];
644 $quote = $matches[2];
645 $srcValue = ltrim($matches[3], '/');
646
647 if ($matchQuotes) {
648 $srcValue = str_ireplace('"', "'", $srcValue);
649 }
650
651 $translatedSrcValue = null;
652 foreach ($altTranslations as $originalText => $translatedText) {
653 $originalText = ltrim($originalText, '/');
654 if ($caseInsensitive) {
655 if (strcasecmp(trim($originalText), $srcValue) === 0) {
656 $translatedSrcValue = $translatedText;
657 break;
658 }
659 } else {
660 if (trim($originalText) === $srcValue) {
661 $translatedSrcValue = $translatedText;
662 break;
663 }
664 }
665 }
666
667 if ($translatedSrcValue !== null) {
668 return preg_replace('/\bsrc\s*=\s*["\'].*?["\']/i', 'src=' . $quote . $translatedSrcValue . $quote, $originalTag);
669 }
670
671 return $originalTag;
672 }, $html);
673 }
674 }
675
676 // Translate iframe locale parameter (e.g. Stripe payment forms)
677 if (! empty ( $settings ['translate_iframe_locale'] )) {
678 $currentLangCode = strtolower(explode('-', $currentLanguage)[0]);
679 $html = preg_replace('/(<iframe[^>]*\bsrc=["\'"])([^"\']*?locale=)[a-z]{2}([^"\']*["\'])/i', '$1${2}' . $currentLangCode . '$3', $html);
680 }
681
682 // Translate <meta name="description" content="...">
683 $html = preg_replace_callback(
684 '~<meta\s+(?:name|property)=["\'](?:description|og:description|twitter:description|dc\.description)["\']\s+content=["\'](.*?)["\'][^>]*>~i',
685 function ($matches) use ($altTranslations, $caseInsensitive, $matchQuotes) {
686 $originalTag = $matches[0];
687 $contentValue = $matches[1];
688
689 // DECODIFICA HTML entities PRIMA del confronto
690 $contentValue = html_entity_decode($contentValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
691
692 if ($matchQuotes) {
693 $contentValue = str_ireplace('"', "'", $contentValue);
694 }
695
696 foreach ($altTranslations as $originalText => $translatedText) {
697 $prev = $contentValue;
698 if ($caseInsensitive) {
699 $contentValue = str_ireplace(trim($originalText), $translatedText, $contentValue);
700 } else {
701 $contentValue = str_replace(trim($originalText), $translatedText, $contentValue);
702 }
703 if ($contentValue !== $prev) break;
704 }
705
706 // RI-ENCODIFICA per l'HTML output (sicurezza)
707 $contentValue = htmlspecialchars($contentValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
708
709 return preg_replace(
710 '/content=["\'].*?["\']/i',
711 'content="' . $contentValue . '"',
712 $originalTag
713 );
714 },
715 $html
716 );
717
718 // Translate <meta property="og:title"> with the same value as <title> tag
719 // Step 1: Extract the translated title content
720 if (preg_match('~<title[^>]*>(.*?)</title>~is', $html, $titleMatch)) {
721 $translatedTitle = trim(strip_tags($titleMatch[1]));
722
723 // Step 2: Replace og:title content directly
724 $html = preg_replace(
725 '~(<meta\s+property=["\']og:title["\']\s+content=["\']).*?(["\'][^>]*>)~i',
726 '$1' . htmlspecialchars($translatedTitle, ENT_QUOTES, 'UTF-8') . '$2',
727 $html
728 );
729
730 // Step 3: Replace twitter:title content with the same translated title
731 $html = preg_replace(
732 '~(<meta\s+name=["\']twitter:title["\']\s+content=["\']).*?(["\'][^>]*>)~i',
733 '$1' . htmlspecialchars($translatedTitle, ENT_QUOTES, 'UTF-8') . '$2',
734 $html
735 );
736
737 // Step 4: Replace Dublin Core (SEOPress) dc.title content with the same translated title
738 $html = preg_replace(
739 '~(<meta\s+name=["\']dc\.title["\']\s+content=["\']).*?(["\'][^>]*>)~i',
740 '$1' . htmlspecialchars($translatedTitle, ENT_QUOTES, 'UTF-8') . '$2',
741 $html
742 );
743 }
744
745 // Add skip marker
746 $html = preg_replace ( '/<body/i', '<body data-gptranslateskip="1" data-gptranslateoriginalalias="' . $row['pagelink'] . '"', $html, 1 );
747
748 // Replace page links in <a href="..."> tags based on translated aliases
749 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
750 $exclusions = $settings['rewrite_page_links_exclusions'] ?? '';
751 $html = preg_replace_callback(
752 '/<a\s+([^>]*\s)?href\s*=\s*(["\'])(.*?)\2([^>]*)>/i',
753 function($matches) use ($translatedAliasesMap, $translatedAliasesRelativeMap, $settings, $exclusions) {
754 $fullTag = $matches[0];
755 $quote = $matches[2];
756 $href = $matches[3];
757
758 // Check if link should be excluded from rewriting
759 if (gptranslate_should_exclude_link($href, $exclusions)) {
760 return $fullTag;
761 }
762
763 // Decode href attribute to match database entries
764 $decodedHref = rawurldecode(html_entity_decode($href, ENT_QUOTES, 'UTF-8'));
765
766 // Extract hash/fragment if present
767 $hashFragment = '';
768 if (strpos($decodedHref, '#') !== false) {
769 $parts = explode('#', $decodedHref, 2);
770 $decodedHref = $parts[0]; // URL without hash
771 $hashFragment = '#' . $parts[1]; // Save the hash
772 }
773
774 $queryString = '';
775 if ($settings ['ignore_querystring'] == 1 && strpos($decodedHref, '?') !== false) {
776 $parts = explode('?', $decodedHref, 2);
777 $decodedHref = $parts[0];
778 $queryString = '?' . $parts[1];
779 }
780 $originalPathBeforeNormalization = $decodedHref;
781
782 $decodedHref = gpt_trailingslashit_url($decodedHref);
783
784 $translatedAlias = null;
785
786 // First pass, check in absolute URLs
787 if (isset($translatedAliasesMap[$decodedHref])) {
788 $translatedAlias = $translatedAliasesMap[$decodedHref];
789 }
790 // Second pass, check in relative URLs (pathname)
791 elseif (isset($translatedAliasesRelativeMap[$decodedHref])) {
792 $translatedAlias = $translatedAliasesRelativeMap[$decodedHref];
793 }
794
795 // If found a translated alias, replace href and add data-originalhref if not present
796 if ($translatedAlias) {
797 $finalHref = $translatedAlias . $queryString . $hashFragment;
798
799 $originalHrefFull = $originalPathBeforeNormalization . $queryString . $hashFragment;
800
801 // Check if data-originalhref already exists in the tag
802 if (strpos($fullTag, 'data-originalhref') === false) {
803 $dataAttr = ' data-originalhref=' . $quote . htmlspecialchars($originalHrefFull, ENT_QUOTES, 'UTF-8') . $quote;
804 $newTag = str_ireplace('<a ', '<a' . $dataAttr . ' ', $fullTag);
805 $newTag = str_ireplace('href=' . $quote . $href . $quote, 'href=' . $quote . htmlspecialchars($finalHref, ENT_QUOTES, 'UTF-8') . $quote, $newTag);
806 return $newTag;
807 } else {
808 return str_ireplace('href=' . $quote . $href . $quote, 'href=' . $quote . htmlspecialchars($finalHref, ENT_QUOTES, 'UTF-8') . $quote, $fullTag);
809 }
810 }
811
812 return $fullTag;
813 },
814 $html
815 );
816
817 // Replace form action URLs: add language prefix and apply translated aliases
818 if (!empty($settings['rewrite_form_actions'])) {
819 $siteUrl = trailingslashit(get_site_url());
820 $siteHost = parse_url($siteUrl, PHP_URL_HOST);
821 $knownLangs = (isset($settings['languages']) && is_array($settings['languages'])) ? array_map('strtolower', $settings['languages']) : [$originalLang, $translatedLang];
822 $html = preg_replace_callback(
823 '/<form\s+([^>]*\s)?action\s*=\s*(["\'])(.*?)\2([^>]*)>/i',
824 function($matches) use ($translatedAliasesMap, $translatedAliasesRelativeMap, $settings, $translatedLang, $siteUrl, $siteHost, $knownLangs) {
825 $fullTag = $matches[0];
826 $quote = $matches[2];
827 $action = $matches[3];
828
829 $decodedAction = rawurldecode(html_entity_decode($action, ENT_QUOTES, 'UTF-8'));
830
831 // Skip anchors, mailto, tel, javascript, .php endpoints
832 if (preg_match('/^(mailto:|tel:|javascript:|#)/i', $decodedAction)) return $fullTag;
833 if (preg_match('/\.php($|\?|\#)/i', $decodedAction)) return $fullTag;
834
835 // Skip external URLs
836 if (preg_match('/^https?:\/\//i', $decodedAction)) {
837 $actionHost = parse_url($decodedAction, PHP_URL_HOST);
838 if ($actionHost !== $siteHost) return $fullTag;
839 }
840
841 // Extract query string
842 $queryString = '';
843 if ($settings['ignore_querystring'] == 1 && strpos($decodedAction, '?') !== false) {
844 $parts = explode('?', $decodedAction, 2);
845 $decodedAction = $parts[0];
846 $queryString = '?' . $parts[1];
847 }
848 $originalActionFull = $decodedAction . $queryString;
849
850 // Extract relative path
851 $actionPath = $decodedAction;
852 $isAbsolute = false;
853 if (preg_match('/^https?:\/\//i', $actionPath)) {
854 $isAbsolute = true;
855 $actionPath = parse_url($actionPath, PHP_URL_PATH) ?: '/';
856 }
857
858 // Split path and determine language index
859 $pathParts = explode('/', $actionPath);
860 $langIndex = 1;
861 if (isset($pathParts[$langIndex]) && $pathParts[$langIndex] === 'index.php') {
862 $langIndex = 2;
863 }
864 if (!empty($settings['subfolder_installation'])) {
865 $langIndex = 2;
866 }
867
868 // Check if already has a language prefix, replace or insert
869 if (isset($pathParts[$langIndex]) && in_array(strtolower($pathParts[$langIndex]), $knownLangs)) {
870 $pathParts[$langIndex] = $translatedLang;
871 } else {
872 array_splice($pathParts, $langIndex, 0, [$translatedLang]);
873 }
874
875 // Rebuild path
876 $rebuiltPath = '/' . implode('/', array_filter($pathParts, function($p) { return $p !== ''; }));
877 $rebuiltPath = gpt_trailingslashit_url($rebuiltPath);
878
879 // Rebuild full URL if original was absolute
880 $computedAction = $isAbsolute ? (rtrim($siteUrl, '/') . $rebuiltPath) : $rebuiltPath;
881 $computedAction = gpt_trailingslashit_url($computedAction);
882
883 // Try alias match
884 $translatedAlias = null;
885 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
886 if (isset($translatedAliasesMap[$computedAction])) {
887 $translatedAlias = $translatedAliasesMap[$computedAction];
888 } elseif (isset($translatedAliasesRelativeMap[$rebuiltPath])) {
889 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPath];
890 } elseif (!$isAbsolute && !empty($settings['subfolder_installation'])) {
891 // Fallback: form action is relative and includes subfolder path
892 // Map keys strip the subfolder, so retry after stripping it
893 $siteBasePath = rtrim(parse_url(rtrim($siteUrl, '/'), PHP_URL_PATH) ?: '', '/');
894 if ($siteBasePath !== '' && strpos($rebuiltPath, $siteBasePath . '/') === 0) {
895 $rebuiltPathNoSubfolder = substr($rebuiltPath, strlen($siteBasePath));
896 if (isset($translatedAliasesRelativeMap[$rebuiltPathNoSubfolder])) {
897 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPathNoSubfolder];
898 }
899 }
900 }
901 }
902
903 $finalAction = $translatedAlias ? ($translatedAlias . $queryString) : ($computedAction . $queryString);
904
905 if (strpos($fullTag, 'data-originalaction') === false) {
906 $dataAttr = ' data-originalaction=' . $quote . htmlspecialchars($originalActionFull, ENT_QUOTES, 'UTF-8') . $quote;
907 $newTag = str_ireplace('<form ', '<form' . $dataAttr . ' ', $fullTag);
908 $newTag = str_ireplace('action=' . $quote . $action . $quote, 'action=' . $quote . htmlspecialchars($finalAction, ENT_QUOTES, 'UTF-8') . $quote, $newTag);
909 return $newTag;
910 } else {
911 return str_ireplace('action=' . $quote . $action . $quote, 'action=' . $quote . htmlspecialchars($finalAction, ENT_QUOTES, 'UTF-8') . $quote, $fullTag);
912 }
913 },
914 $html
915 );
916 } // end rewrite_form_actions
917 }
918 } elseif ($settings ['serverside_translations_method'] == 'domdocument') {
919 // Solution 2: classic DOMDocument approach, effective but could cause closing tags and encoding issues
920 try {
921 $translationsArray = json_decode ( $row['translations'], true ) ?? [ ];
922 $altTranslationsArray = json_decode ( $row['alt_translations'] ?? '', true ) ?: [ ];
923
924 // Sort the translation keys in descending order by length.
925 uksort ( $translationsArray, function ( $a, $b ) {
926 return strlen ( $b ) - strlen ( $a );
927 } );
928
929 // Sort the alt translation keys in descending order by length.
930 uksort ( $altTranslationsArray, function ( $a, $b ) {
931 return strlen ( $b ) - strlen ( $a );
932 } );
933
934 // Flatten formatting tags in the body to improve matching if enabled
935 if($flattenInnerFormattingTags) {
936 function gp_flatten_inner_formatting_tags($html, $tagsToRemove) {
937 $dom = new DOMDocument();
938 libxml_use_internal_errors(true);
939
940 // Usa lo stesso metodo del codice principale con l'hack XML encoding
941 $dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
942
943 libxml_clear_errors();
944
945 $xpath = new DOMXPath($dom);
946
947 // Seleziona tutti i tag da rimuovere, ovunque
948 $query = '//' . implode(' | //', array_map(function($t) { return strtolower($t); }, $tagsToRemove));
949
950 $nodes = $xpath->query($query);
951
952 // Importante: sostituire dal "basso verso l'alto" per evitare problemi mentre modifichi il DOM
953 // Convertiamo NodeList in array e invertiamo
954 $toReplace = [];
955 foreach ($nodes as $n)
956 $toReplace[] = $n;
957 $toReplace = array_reverse($toReplace);
958
959 foreach ($toReplace as $node) {
960 // Preserva elementi vuoti (es. <i> FontAwesome/icon fonts)
961 if (trim(str_replace(['&nbsp;', "\xc2\xa0", "\xa0"], '', $node->textContent)) === '') {
962 continue;
963 }
964 // Preserva elementi che contengono form elements (select, input, textarea, button, ecc.)
965 $interactiveTags = ['select', 'input', 'textarea', 'button', 'option', 'optgroup', 'fieldset', 'datalist', 'output', 'label', 'form', 'img', 'video', 'audio', 'canvas', 'svg', 'iframe'];
966 $hasInteractive = false;
967 foreach ($interactiveTags as $iTag) {
968 if ($node->getElementsByTagName($iTag)->length > 0) {
969 $hasInteractive = true;
970 break;
971 }
972 }
973 if ($hasInteractive) {
974 continue;
975 }
976
977 $text = $node->textContent; // prende il testo "visibile" (quello che vuoi)
978 $textNode = $dom->createTextNode($text);
979
980 if ($node->parentNode) {
981 $node->parentNode->replaceChild($textNode, $node);
982 }
983 }
984
985 // Salva l'HTML modificato
986 $out = $dom->saveHTML();
987
988 return $out;
989 }
990 $html = gp_flatten_inner_formatting_tags($html, $flattenInnerFormattingTagsToRemove);
991 }
992
993 // Initialize DOMDocument without converting the body content via mb_convert_encoding.
994 $doc = new DOMDocument ();
995 libxml_use_internal_errors ( true );
996 // Use an XML encoding hack to inform DOMDocument about UTF-8 while preserving original characters.
997
998 // PROTECT script/style content before DOMDocument loading (if option enabled)
999 $protectedScriptsStyles = [];
1000 if (!empty($settings['protect_script_style'])) {
1001 $protectCounter = 0;
1002
1003 // Extract <script>...</script> tags and replace with marker meta tags
1004 $html = preg_replace_callback(
1005 '/<script\b[^>]*>.*?<\/script>/is',
1006 function ($matches) use (&$protectedScriptsStyles, &$protectCounter) {
1007 $placeholder = "___GPTRANSLATE_SCRIPT_" . ($protectCounter++) . "___";
1008 $protectedScriptsStyles[$placeholder] = $matches[0];
1009 // Use a data attribute to preserve position - DOMDocument won't move these
1010 return '<meta data-gptranslate-script="' . htmlspecialchars($placeholder, ENT_QUOTES) . '">';
1011 },
1012 $html
1013 );
1014
1015 // Extract <style>...</style> tags and replace with marker meta tags
1016 $html = preg_replace_callback(
1017 '/<style\b[^>]*>.*?<\/style>/is',
1018 function ($matches) use (&$protectedScriptsStyles, &$protectCounter) {
1019 $placeholder = "___GPTRANSLATE_STYLE_" . ($protectCounter++) . "___";
1020 $protectedScriptsStyles[$placeholder] = $matches[0];
1021 // Use a data attribute to preserve position
1022 return '<meta data-gptranslate-style="' . htmlspecialchars($placeholder, ENT_QUOTES) . '">';
1023 },
1024 $html
1025 );
1026 }
1027 $doc->loadHTML ( '<?xml encoding="UTF-8">' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
1028 libxml_clear_errors ();
1029
1030 // Use XPath to locate text nodes, skipping those in <script> or <style> tags.
1031 $xpath = new DOMXPath ( $doc );
1032 $cssSelectorLeafnodesExcluded = str_ireplace ( '"', '', trim ( trim ( preg_replace ( '/,+/', ',', str_ireplace ( [ "\r", "\n" ], ",", $settings['css_selector_serverside_leafnodes_excluded'] ?? '' ) ) ), ',' ) );
1033 $excludedNodes = [ ];
1034
1035 if (! empty ( $cssSelectorLeafnodesExcluded )) {
1036 $selectors = explode ( ',', $cssSelectorLeafnodesExcluded );
1037 $xpathQueries = [ ];
1038
1039 foreach ( $selectors as $selector ) {
1040 $selector = trim ( $selector );
1041 if (empty ( $selector ))
1042 continue;
1043
1044 // Convert CSS selector to XPath
1045 $selector = preg_replace ( '/\s+/', ' ', $selector ); // Normalize spaces
1046 $selectorParts = explode ( ' ', $selector );
1047
1048 $xpathQuery = '';
1049 foreach ( $selectorParts as $part ) {
1050 if (preg_match ( '/^([a-zA-Z0-9_-]+)?(\.[a-zA-Z0-9_-]+)?(#[a-zA-Z0-9_-]+)?$/', $part, $matches )) {
1051 $tag = ! empty ( $matches[1] ) ? $matches[1] : '*'; // If no tag, use '*'
1052 $class = ! empty ( $matches[2] ) ? substr ( $matches[2], 1 ) : ''; // Remove leading '.'
1053 $id = ! empty ( $matches[3] ) ? substr ( $matches[3], 1 ) : ''; // Remove leading '#'
1054
1055 $conditions = [ ];
1056 if ($class) {
1057 $conditions[] = "contains(concat(' ', normalize-space(@class), ' '), ' $class ')";
1058 }
1059 if ($id) {
1060 $conditions[] = "@id='$id'";
1061 }
1062
1063 $xpathQuery .= "//{$tag}" . (! empty ( $conditions ) ? "[" . implode ( " and ", $conditions ) . "]" : "");
1064 }
1065 }
1066
1067 if (! empty ( $xpathQuery )) {
1068 $xpathQueries[] = $xpathQuery;
1069 }
1070 }
1071
1072 // Execute all XPath queries and collect excluded nodes
1073 foreach ( $xpathQueries as $query ) {
1074 foreach ( $xpath->query ( $query ) as $excludedNode ) {
1075 $excludedNodes[] = $excludedNode;
1076 }
1077 }
1078 }
1079
1080 $textNodes = $xpath->query ( '//text()[not(ancestor::script) and not(ancestor::style) and normalize-space()]' );
1081 $caseInsensitive = ! empty ( $settings['serverside_translations_caseinsensitive'] );
1082 $matchQuotes = ! empty ( $settings['serverside_translations_matchquotes'] );
1083
1084 // Process each text node with translation replacements.
1085 foreach ( $textNodes as $textNode ) {
1086 if ($cssSelectorLeafnodesExcluded) {
1087 $skipNode = false;
1088 foreach ( $excludedNodes as $excludedNode ) {
1089 if ($excludedNode->isSameNode ( $textNode->parentNode )) {
1090 $skipNode = true;
1091 break;
1092 }
1093 }
1094 if ($skipNode)
1095 continue;
1096 }
1097
1098 $originalTextContent = $textNode->nodeValue;
1099 $textContent = $textNode->nodeValue;
1100 if ($matchQuotes) {
1101 $textContent = str_ireplace ( '"', "'", $textContent );
1102 }
1103 // Normalize once without trimming to preserve spaces between nodes
1104 $textContent = normalizeTextNoTrim($textContent);
1105
1106 // INCREMENTAL FIX: Use exact match only if incremental preserve new texts is enabled
1107 if ($incrementalEnabledPreserveNewTexts) {
1108 // Extract leading/trailing whitespace from original text
1109 preg_match('/^(\s*)(.*?)(\s*)$/s', $textContent, $wsMatches);
1110 $leadingWhitespace = $wsMatches[1] ?? '';
1111 $trailingWhitespace = $wsMatches[3] ?? '';
1112
1113 // Apply dictionary replacement for incremental matching (matches client-side behavior)
1114 $normalizedNodeText = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($textContent, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($textContent, $settings, $originalLang, $translatedLang);
1115 $found = false;
1116
1117 // Step 1: Exact match
1118 foreach ($translationsArray as $originalText => $translatedText) {
1119 $normalizedOrig = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang);
1120 if (trim($normalizedNodeText) === trim($normalizedOrig)) {
1121 $textContent = $leadingWhitespace . $translatedText . $trailingWhitespace;
1122 $found = true;
1123 break;
1124 }
1125 }
1126
1127 // Step 2: Excluded-words-aware fragment matching
1128 // Mirrors client-side wrap_excluded_words behavior: client creates SEPARATE text nodes
1129 // around excluded words, so DB stores fragments separately, NOT the full text.
1130 // Example: "Now i add a new text and modify the page" with 'text' excluded
1131 // Client creates 3 nodes: "Now i add a new ", "text" (preserved), " and modify the page"
1132 // DB stores: "Now i add a new " → "Ora aggiungo un nuovo ", " and modify the page" → " e modificare la pagina"
1133 // We split the text by excluded words, translate each fragment, preserve excluded words.
1134 if (!$found && !empty($excludedWordsList)) {
1135 $excludedPattern = '';
1136 foreach ($excludedWordsList as $excludedWord) {
1137 $excludedWord = trim($excludedWord);
1138 if (!empty($excludedWord)) {
1139 $excludedPattern .= ($excludedPattern ? '|' : '') . preg_quote($excludedWord, '/');
1140 }
1141 }
1142
1143 if (!empty($excludedPattern)) {
1144 // Split keeping excluded words as delimiters (PREG_SPLIT_DELIM_CAPTURE)
1145 $parts = preg_split(
1146 '/((?<!\w)(?:' . $excludedPattern . ')(?!\w))/' . ($caseInsensitive ? 'ui' : 'u'),
1147 $textContent,
1148 -1,
1149 PREG_SPLIT_DELIM_CAPTURE
1150 );
1151
1152 if (is_array($parts) && count($parts) > 1) {
1153 $rebuiltText = '';
1154 $anyTranslated = false;
1155
1156 foreach ($parts as $part) {
1157 if ($part === '') {
1158 continue;
1159 }
1160 // Check if this part is an excluded word - preserve as-is
1161 $isExcluded = false;
1162 $matchedExcludedWord = '';
1163 foreach ($excludedWordsList as $excludedWord) {
1164 if (strcasecmp(trim($part), trim($excludedWord)) === 0) {
1165 $isExcluded = true;
1166 $matchedExcludedWord = mb_strtolower(trim($excludedWord), 'UTF-8');
1167 break;
1168 }
1169 }
1170
1171 if ($isExcluded) {
1172 $replacement = $excludedWordsReplacementMap[$matchedExcludedWord] ?? '';
1173 if ($replacement !== '') {
1174 $rebuiltText .= $replacement;
1175 $anyTranslated = true;
1176 } else {
1177 $rebuiltText .= $part;
1178 }
1179 } else {
1180 // Try to find an exact match for this fragment in DB
1181 $normalizedPart = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($part, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($part, $settings, $originalLang, $translatedLang);
1182 $fragTranslated = false;
1183 foreach ($translationsArray as $originalText => $translatedText) {
1184 $normalizedOrig = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang);
1185 if (trim($normalizedPart) !== '' && trim($normalizedPart) === trim($normalizedOrig)) {
1186 $rebuiltText .= $translatedText;
1187 $fragTranslated = true;
1188 $anyTranslated = true;
1189 break;
1190 }
1191 }
1192 if (!$fragTranslated) {
1193 // Fragment not found in DB - keep original
1194 $rebuiltText .= $part;
1195 }
1196 }
1197 }
1198
1199 if ($anyTranslated) {
1200 $textContent = $rebuiltText;
1201 $found = true;
1202 }
1203 }
1204 }
1205 }
1206 } else {
1207 // Normal pattern matching (standard behavior)
1208 foreach ( $translationsArray as $originalText => $translatedText ) {
1209 $originalText = normalizeText($originalText);
1210
1211 $prevTextContent = $textContent; // Save the current state
1212 if ($wrapExcludedWords) {
1213 // Use word boundaries to avoid sub-replacing inside already translated words
1214 $pattern = '/' . (preg_match('/^\w/u', trim($originalText)) ? '(?<!\w)' : '') . preg_quote(trim($originalText), '/') . '(?!\w)/' . ($caseInsensitive ? 'ui' : 'u');
1215 $textContent = preg_replace($pattern, $translatedText, $textContent);
1216 } else {
1217 // Use word boundaries to avoid sub-replacing inside already translated words
1218 $pattern = '/' . (preg_match('/^\w/u', trim($originalText)) ? '(?<!\w)' : '') . preg_quote(trim($originalText), '/') . '(?!\w)/' . ($caseInsensitive ? 'ui' : 'u');
1219 $textContent = preg_replace($pattern, $translatedText, $textContent);
1220 if ($textContent !== $prevTextContent) {
1221 break;
1222 }
1223 }
1224 }
1225 }
1226 $textNode->nodeValue = $textContent;
1227 }
1228
1229 // Replace page links in <a href="..."> tags based on translated aliases
1230 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
1231 $exclusions = $settings['rewrite_page_links_exclusions'] ?? '';
1232 $linkNodes = $xpath->query('//a[@href]');
1233 foreach ($linkNodes as $linkNode) {
1234 $href = $linkNode->getAttribute('href');
1235
1236 // Check if link should be excluded from rewriting
1237 if (gptranslate_should_exclude_link($href, $exclusions)) {
1238 continue;
1239 }
1240
1241 // Decode href attribute (WordPress-style, KEEP trailing slash)
1242 $decodedHref = rawurldecode(html_entity_decode($href, ENT_QUOTES, 'UTF-8'));
1243
1244 // Extract hash/fragment if present
1245 $hashFragment = '';
1246 if (strpos($decodedHref, '#') !== false) {
1247 $parts = explode('#', $decodedHref, 2);
1248 $decodedHref = $parts[0]; // URL without hash
1249 $hashFragment = '#' . $parts[1]; // Save the hash
1250 }
1251
1252 $queryString = '';
1253 if ($settings['ignore_querystring'] == 1 && strpos($decodedHref, '?') !== false) {
1254 $parts = explode('?', $decodedHref, 2);
1255 $decodedHref = $parts[0];
1256 $queryString = '?' . $parts[1];
1257 }
1258 $originalPathBeforeNormalization = $decodedHref;
1259
1260 $decodedHref = gpt_trailingslashit_url($decodedHref);
1261
1262 $translatedAlias = null;
1263
1264 // Cerca prima negli URL assoluti
1265 if (isset($translatedAliasesMap[$decodedHref])) {
1266 $translatedAlias = $translatedAliasesMap[$decodedHref];
1267 }
1268 // Poi cerca negli URL relativi
1269 elseif (isset($translatedAliasesRelativeMap[$decodedHref])) {
1270 $translatedAlias = $translatedAliasesRelativeMap[$decodedHref];
1271 }
1272
1273 // Se trovato un alias tradotto, sostituisci
1274 if ($translatedAlias) {
1275 // Aggiungi data-originalhref se non presente
1276 if (!$linkNode->hasAttribute('data-originalhref')) {
1277 $linkNode->setAttribute('data-originalhref', $originalPathBeforeNormalization . $queryString . $hashFragment);
1278 }
1279 $linkNode->setAttribute('href', $translatedAlias . $queryString . $hashFragment);
1280 }
1281 }
1282 }
1283
1284 // Replace form action URLs: add language prefix and apply translated aliases
1285 if (!empty($settings['rewrite_form_actions'])) {
1286 $siteUrl = trailingslashit(get_site_url());
1287 $siteHost = parse_url($siteUrl, PHP_URL_HOST);
1288 $knownLangs = (isset($settings['languages']) && is_array($settings['languages'])) ? array_map('strtolower', $settings['languages']) : [$originalLang, $translatedLang];
1289 $formNodes = $xpath->query('//form[@action]');
1290 foreach ($formNodes as $formNode) {
1291 $action = $formNode->getAttribute('action');
1292 $decodedAction = rawurldecode(html_entity_decode($action, ENT_QUOTES, 'UTF-8'));
1293
1294 // Skip non-rewritable actions
1295 if (preg_match('/^(mailto:|tel:|javascript:|#)/i', $decodedAction)) continue;
1296 if (preg_match('/\.php($|\?|\#)/i', $decodedAction)) continue;
1297 if (preg_match('/^https?:\/\//i', $decodedAction) && parse_url($decodedAction, PHP_URL_HOST) !== $siteHost) continue;
1298
1299 $queryString = '';
1300 if ($settings['ignore_querystring'] == 1 && strpos($decodedAction, '?') !== false) {
1301 $parts = explode('?', $decodedAction, 2);
1302 $decodedAction = $parts[0];
1303 $queryString = '?' . $parts[1];
1304 }
1305 $originalActionFull = $decodedAction . $queryString;
1306
1307 // Extract relative path
1308 $actionPath = $decodedAction;
1309 $isAbsolute = false;
1310 if (preg_match('/^https?:\/\//i', $actionPath)) {
1311 $isAbsolute = true;
1312 $actionPath = parse_url($actionPath, PHP_URL_PATH) ?: '/';
1313 }
1314
1315 // Add/replace language prefix
1316 $pathParts = explode('/', $actionPath);
1317 $langIndex = 1;
1318 if (isset($pathParts[$langIndex]) && $pathParts[$langIndex] === 'index.php') $langIndex = 2;
1319 if (!empty($settings['subfolder_installation'])) $langIndex = 2;
1320
1321 if (isset($pathParts[$langIndex]) && in_array(strtolower($pathParts[$langIndex]), $knownLangs)) {
1322 $pathParts[$langIndex] = $translatedLang;
1323 } else {
1324 array_splice($pathParts, $langIndex, 0, [$translatedLang]);
1325 }
1326
1327 $rebuiltPath = '/' . implode('/', array_filter($pathParts, function($p) { return $p !== ''; }));
1328 $rebuiltPath = gpt_trailingslashit_url($rebuiltPath);
1329 $computedAction = $isAbsolute ? (rtrim($siteUrl, '/') . $rebuiltPath) : $rebuiltPath;
1330 $computedAction = gpt_trailingslashit_url($computedAction);
1331
1332 // Try alias match
1333 $translatedAlias = null;
1334 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
1335 if (isset($translatedAliasesMap[$computedAction])) {
1336 $translatedAlias = $translatedAliasesMap[$computedAction];
1337 } elseif (isset($translatedAliasesRelativeMap[$rebuiltPath])) {
1338 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPath];
1339 } elseif (!$isAbsolute && !empty($settings['subfolder_installation'])) {
1340 // Fallback: form action is relative and includes subfolder path
1341 // Map keys strip the subfolder, so retry after stripping it
1342 $siteBasePath = rtrim(parse_url(rtrim($siteUrl, '/'), PHP_URL_PATH) ?: '', '/');
1343 if ($siteBasePath !== '' && strpos($rebuiltPath, $siteBasePath . '/') === 0) {
1344 $rebuiltPathNoSubfolder = substr($rebuiltPath, strlen($siteBasePath));
1345 if (isset($translatedAliasesRelativeMap[$rebuiltPathNoSubfolder])) {
1346 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPathNoSubfolder];
1347 }
1348 }
1349 }
1350 }
1351
1352 $finalAction = $translatedAlias ? ($translatedAlias . $queryString) : ($computedAction . $queryString);
1353
1354 if (!$formNode->hasAttribute('data-originalaction')) {
1355 $formNode->setAttribute('data-originalaction', $originalActionFull);
1356 }
1357 $formNode->setAttribute('action', $finalAction);
1358 }
1359 }
1360
1361 // Check if also images 'alt' are enabled to be translated
1362 if (! empty ( $settings['translate_altimages'] )) {
1363 $imgNodesAlt = $xpath->query ( '//img[@alt]' );
1364 foreach ( $imgNodesAlt as $imgNode ) {
1365 $altText = $imgNode->getAttribute ( 'alt' );
1366 if ($matchQuotes) {
1367 $altText = str_ireplace ( '"', "'", $altText );
1368 }
1369 foreach ( $altTranslationsArray as $originalText => $translatedText ) {
1370 $originalText = normalizeText($originalText);
1371 $altText = normalizeText($altText);
1372
1373 $prevAltText = $altText;
1374 if ($caseInsensitive) {
1375 $altText = str_ireplace ( trim ( $originalText ), $translatedText, $altText );
1376 } else {
1377 $altText = str_replace ( trim ( $originalText ), $translatedText, $altText );
1378 }
1379 if ($altText !== $prevAltText) {
1380 break;
1381 }
1382 }
1383 $imgNode->setAttribute ( 'alt', $altText );
1384 }
1385
1386 $imgNodesTitle = $xpath->query ( '//img[@title]' );
1387 foreach ( $imgNodesTitle as $imgNode ) {
1388 $titleText = $imgNode->getAttribute ( 'title' );
1389 if ($matchQuotes) {
1390 $titleText = str_ireplace ( '"', "'", $titleText );
1391 }
1392 foreach ( $altTranslationsArray as $originalText => $translatedText ) {
1393 $originalText = normalizeText($originalText);
1394 $titleText = normalizeText($titleText);
1395
1396 $prevTitleText = $titleText;
1397 if ($caseInsensitive) {
1398 $titleText = str_ireplace ( trim ( $originalText ), $translatedText, $titleText );
1399 } else {
1400 $titleText = str_replace ( trim ( $originalText ), $translatedText, $titleText );
1401 }
1402 if ($titleText !== $prevTitleText) {
1403 break;
1404 }
1405 }
1406 $imgNode->setAttribute ( 'title', $titleText );
1407 }
1408
1409 // Check if also images 'src' are enabled to be translated
1410 if (! empty ( $settings ['translate_srcimages'] )) {
1411 $imgNodesSrc = $xpath->query ( '//img[@src]' );
1412 foreach ( $imgNodesSrc as $imgNode ) {
1413 $srcValue = ltrim ( $imgNode->getAttribute ( 'src' ), '/' );
1414
1415 if ($matchQuotes) {
1416 $srcValue = str_ireplace ( '"', "'", $srcValue );
1417 }
1418
1419 $translatedSrcValue = null;
1420 foreach ( $altTranslationsArray as $originalText => $translatedText ) {
1421 $originalText = normalizeText($originalText);
1422 $srcValue = normalizeText($srcValue);
1423
1424 $originalText = ltrim ( $originalText, '/' );
1425 if ($caseInsensitive) {
1426 if (strcasecmp ( trim ( $originalText ), $srcValue ) === 0) {
1427 $translatedSrcValue = $translatedText;
1428 break;
1429 }
1430 } else {
1431 if (trim ( $originalText ) === $srcValue) {
1432 $translatedSrcValue = $translatedText;
1433 break;
1434 }
1435 }
1436 }
1437
1438 // If a translation was found, replace src and srcset
1439 if ($translatedSrcValue !== null) {
1440 // Replace the src attribute
1441 $imgNode->setAttribute ( 'src', $translatedSrcValue );
1442
1443 // Check if srcset exists and replace it with the translated src value
1444 if ($imgNode->hasAttribute ( 'srcset' )) {
1445 $imgNode->setAttribute ( 'srcset', $translatedSrcValue );
1446 }
1447 }
1448 }
1449 }
1450
1451 // Check if also iframes 'src' are enabled to be translated
1452 if (! empty ( $settings ['translate_srciframes'] )) {
1453 $iframeNodesSrc = $xpath->query ( '//iframe[@src]' );
1454 foreach ( $iframeNodesSrc as $iframeNode ) {
1455 $srcValue = ltrim ( $iframeNode->getAttribute ( 'src' ), '/' );
1456
1457 if ($matchQuotes) {
1458 $srcValue = str_ireplace ( '"', "'", $srcValue );
1459 }
1460
1461 $translatedSrcValue = null;
1462 foreach ( $altTranslationsArray as $originalText => $translatedText ) {
1463 $originalText = normalizeText($originalText);
1464 $srcValue = normalizeText($srcValue);
1465
1466 $originalText = ltrim ( $originalText, '/' );
1467 if ($caseInsensitive) {
1468 if (strcasecmp ( trim ( $originalText ), $srcValue ) === 0) {
1469 $translatedSrcValue = $translatedText;
1470 break;
1471 }
1472 } else {
1473 if (trim ( $originalText ) === $srcValue) {
1474 $translatedSrcValue = $translatedText;
1475 break;
1476 }
1477 }
1478 }
1479
1480 if ($translatedSrcValue !== null) {
1481 $iframeNode->setAttribute ( 'src', $translatedSrcValue );
1482 }
1483 }
1484 }
1485
1486 // Check if also videos 'src' are enabled to be translated
1487 if (! empty ( $settings ['translate_srcvideos'] )) {
1488 $videoNodesSrc = $xpath->query ( '//video[@src] | //video//source[@src]' );
1489 foreach ( $videoNodesSrc as $videoNode ) {
1490 $srcValue = ltrim ( $videoNode->getAttribute ( 'src' ), '/' );
1491
1492 if ($matchQuotes) {
1493 $srcValue = str_ireplace ( '"', "'", $srcValue );
1494 }
1495
1496 $translatedSrcValue = null;
1497 foreach ( $altTranslationsArray as $originalText => $translatedText ) {
1498 $originalText = normalizeText($originalText);
1499 $srcValue = normalizeText($srcValue);
1500
1501 $originalText = ltrim ( $originalText, '/' );
1502 if ($caseInsensitive) {
1503 if (strcasecmp ( trim ( $originalText ), $srcValue ) === 0) {
1504 $translatedSrcValue = $translatedText;
1505 break;
1506 }
1507 } else {
1508 if (trim ( $originalText ) === $srcValue) {
1509 $translatedSrcValue = $translatedText;
1510 break;
1511 }
1512 }
1513 }
1514
1515 if ($translatedSrcValue !== null) {
1516 $videoNode->setAttribute ( 'src', $translatedSrcValue );
1517 }
1518 }
1519 }
1520 }
1521
1522 // Translate iframe locale parameter (e.g. Stripe payment forms)
1523 if (! empty ( $settings ['translate_iframe_locale'] )) {
1524 $currentLangCode = strtolower(explode('-', $currentLanguage)[0]);
1525 $iframeNodes = $xpath->query ( '//iframe[@src]' );
1526 foreach ( $iframeNodes as $iframeNode ) {
1527 $src = $iframeNode->getAttribute ( 'src' );
1528 if (strpos ( $src, 'locale=' ) !== false) {
1529 $translatedSrc = preg_replace ( '/locale=[a-z]{2}/i', 'locale=' . $currentLangCode, $src );
1530 $iframeNode->setAttribute ( 'src', $translatedSrc );
1531 }
1532 }
1533 }
1534
1535 // Translate meta descriptions (standard, Open Graph, Twitter, Dublin Core)
1536 $metaDescriptions = $xpath->query(
1537 '//meta[
1538 (@name="description")
1539 or (@name="twitter:description")
1540 or (@property="og:description")
1541 or (@name="dc.description")
1542 ]'
1543 );
1544 foreach ($metaDescriptions as $meta) {
1545 $contentValue = $meta->getAttribute('content');
1546
1547 if ($matchQuotes) {
1548 $contentValue = str_ireplace('"', "'", $contentValue);
1549 }
1550
1551 foreach ($altTranslationsArray as $originalText => $translatedText) {
1552 $prev = $contentValue;
1553 if ($caseInsensitive) {
1554 $contentValue = str_ireplace(trim($originalText), $translatedText, $contentValue);
1555 } else {
1556 $contentValue = str_replace(trim($originalText), $translatedText, $contentValue);
1557 }
1558 if ($contentValue !== $prev) break;
1559 }
1560
1561 $meta->setAttribute('content', $contentValue);
1562 }
1563
1564 $ogTitleNodes = $xpath->query('//meta[@property="og:title"]');
1565 if ($ogTitleNodes->length > 0) {
1566 // Find the <title> tag to get the translated title
1567 $titleNodes = $xpath->query('//title');
1568
1569 if ($titleNodes->length > 0) {
1570 $titleNode = $titleNodes->item(0);
1571 $translatedTitle = $titleNode->textContent;
1572
1573 // Set og:title with the same value as <title>
1574 foreach ($ogTitleNodes as $ogTitleNode) {
1575 $ogTitleNode->setAttribute('content', trim($translatedTitle));
1576 }
1577
1578 // Set twitter:title with the same value as <title>
1579 $twitterTitleNodes = $xpath->query('//meta[@name="twitter:title"]');
1580 foreach ($twitterTitleNodes as $twitterTitleNode) {
1581 $twitterTitleNode->setAttribute('content', trim($translatedTitle));
1582 }
1583
1584 // Set Dublin Core (SEOPress) dc:title with the same value as <title>
1585 $dcTitleNodes = $xpath->query('//meta[@name="dc.title"]');
1586 foreach ($dcTitleNodes as $dcTitleNode) {
1587 $dcTitleNode->setAttribute('content', trim($translatedTitle));
1588 }
1589 }
1590 }
1591
1592 // Optionally, add an attribute to the <body> tag to signal that translations were applied.
1593 $bodyTag = $doc->getElementsByTagName ( 'body' )->item ( 0 );
1594 if ($bodyTag) {
1595 $bodyTag->setAttribute ( 'data-gptranslateskip', '1' );
1596 $bodyTag->setAttribute ( 'data-gptranslateoriginalalias', htmlspecialchars($row['pagelink'], ENT_QUOTES, 'UTF-8') );
1597 }
1598
1599 // Save the modified HTML and strip the XML processing instruction
1600 // that was injected as a UTF-8 encoding hint for DOMDocument.
1601 $html = $doc->saveHTML ();
1602 $html = preg_replace ( '/<\?xml[^>]*>\s*/i', '', $html );
1603
1604 // RESTORE protected script/style content (if protection was enabled)
1605 if (!empty($settings['protect_script_style'])) {
1606 // Restore scripts from meta markers
1607 $html = preg_replace_callback(
1608 '/<meta\s+data-gptranslate-script="([^"]+)"[^>]*>/i',
1609 function ($matches) use ($protectedScriptsStyles) {
1610 $placeholder = $matches[1];
1611 return isset($protectedScriptsStyles[$placeholder]) ? $protectedScriptsStyles[$placeholder] : $matches[0];
1612 },
1613 $html
1614 );
1615
1616 // Restore styles from meta markers
1617 $html = preg_replace_callback(
1618 '/<meta\s+data-gptranslate-style="([^"]+)"[^>]*>/i',
1619 function ($matches) use ($protectedScriptsStyles) {
1620 $placeholder = $matches[1];
1621 return isset($protectedScriptsStyles[$placeholder]) ? $protectedScriptsStyles[$placeholder] : $matches[0];
1622 },
1623 $html
1624 );
1625 }
1626
1627 // Decode HTML entities back into UTF-8 characters.
1628 $html = html_entity_decode ( $html, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
1629
1630 } catch ( Exception $e ) {
1631 // Handle exception if needed
1632 }
1633 } elseif ($settings['serverside_translations_method'] == 'simplehtmldom') {
1634 require_once plugin_dir_path(__FILE__) . 'simplehtmldom.php';
1635
1636 // Helper: checks if $child is inside (or is) $parent.
1637 function nodeIsInsideExcluded($child, $excludedNodes) {
1638 while ($child !== null) {
1639 foreach ($excludedNodes as $ex) {
1640 if ($child === $ex) {
1641 return true;
1642 }
1643 }
1644 $child = $child->parent;
1645 }
1646 return false;
1647 }
1648
1649 // Helper: checks if $node is inside (or is) any of the specified tags.
1650 function nodeIsInsideTags($node, $tagNames) {
1651 $tagNames = array_map('strtolower', $tagNames);
1652 while ($node !== null) {
1653 $tag = strtolower($node->tag ?? '');
1654 if (in_array($tag, $tagNames, true)) {
1655 return true;
1656 }
1657 $node = $node->parent;
1658 }
1659 return false;
1660 }
1661
1662 // Recursive function to process all text nodes.
1663 function processTextNodes($node, $excludedNodes, $translationsArray, $caseInsensitive, $matchQuotes, $incrementalEnabledPreserveNewTexts, $excludedWordsList, $excludedWordsReplacementMap, $settings, $originalLang, $translatedLang) {
1664 if ($node->tag === 'text') {
1665 if (! nodeIsInsideExcluded($node, $excludedNodes) && ! nodeIsInsideTags($node, ['script', 'style', 'noscript']) && trim($node->innertext) && $node->innertext != "\t") {
1666 $text = $node->innertext;
1667 if ($matchQuotes) {
1668 $text = str_ireplace('"', "'", $text);
1669 }
1670
1671 // Normalize once to avoid stripping spaces added by replacements
1672 $text = normalizeTextNoTrim($text);
1673
1674 // INCREMENTAL FIX: Use exact match only if incremental preserve new texts is enabled
1675 if ($incrementalEnabledPreserveNewTexts) {
1676 // Extract leading/trailing whitespace from original text
1677 preg_match('/^(\s*)(.*?)(\s*)$/s', $text, $wsMatches);
1678 $leadingWhitespace = $wsMatches[1] ?? '';
1679 $trailingWhitespace = $wsMatches[3] ?? '';
1680
1681 // Apply dictionary replacement for incremental matching (matches client-side behavior)
1682 $normalizedNodeText = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($text, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($text, $settings, $originalLang, $translatedLang);
1683 $found = false;
1684
1685 // Step 1: Exact match
1686 foreach ($translationsArray as $originalText => $translatedText) {
1687 $normalizedOrig = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang);
1688 if (trim($normalizedNodeText) === trim($normalizedOrig)) {
1689 $text = $leadingWhitespace . $translatedText . $trailingWhitespace;
1690 $found = true;
1691 break;
1692 }
1693 }
1694
1695 // Step 2: Excluded-words-aware fragment matching
1696 // Mirrors client-side wrap_excluded_words behavior: client creates SEPARATE text nodes
1697 // around excluded words, so DB stores fragments separately, NOT the full text.
1698 // Example: "Now i add a new text and modify the page" with 'text' excluded
1699 // Client creates 3 nodes: "Now i add a new ", "text" (preserved), " and modify the page"
1700 // DB stores: "Now i add a new " → "Ora aggiungo un nuovo ", " and modify the page" → " e modificare la pagina"
1701 // We split the text by excluded words, translate each fragment, preserve excluded words.
1702 if (!$found && !empty($excludedWordsList)) {
1703 $excludedPattern = '';
1704 foreach ($excludedWordsList as $excludedWord) {
1705 $excludedWord = trim($excludedWord);
1706 if (!empty($excludedWord)) {
1707 $excludedPattern .= ($excludedPattern ? '|' : '') . preg_quote($excludedWord, '/');
1708 }
1709 }
1710
1711 if (!empty($excludedPattern)) {
1712 // Split keeping excluded words as delimiters (PREG_SPLIT_DELIM_CAPTURE)
1713 $parts = preg_split(
1714 '/((?<!\w)(?:' . $excludedPattern . ')(?!\w))/' . ($caseInsensitive ? 'ui' : 'u'),
1715 $text,
1716 -1,
1717 PREG_SPLIT_DELIM_CAPTURE
1718 );
1719
1720 if (is_array($parts) && count($parts) > 1) {
1721 $rebuiltText = '';
1722 $anyTranslated = false;
1723
1724 foreach ($parts as $part) {
1725 if ($part === '') {
1726 continue;
1727 }
1728 // Check if this part is an excluded word - preserve as-is
1729 $isExcluded = false;
1730 $matchedExcludedWord = '';
1731 foreach ($excludedWordsList as $excludedWord) {
1732 if (strcasecmp(trim($part), trim($excludedWord)) === 0) {
1733 $isExcluded = true;
1734 $matchedExcludedWord = mb_strtolower(trim($excludedWord), 'UTF-8');
1735 break;
1736 }
1737 }
1738
1739 if ($isExcluded) {
1740 $replacement = $excludedWordsReplacementMap[$matchedExcludedWord] ?? '';
1741 if ($replacement !== '') {
1742 $rebuiltText .= $replacement;
1743 $anyTranslated = true;
1744 } else {
1745 $rebuiltText .= $part;
1746 }
1747 } else {
1748 // Try to find an exact match for this fragment in DB
1749 $normalizedPart = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($part, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($part, $settings, $originalLang, $translatedLang);
1750 $fragTranslated = false;
1751 foreach ($translationsArray as $originalText => $translatedText) {
1752 $normalizedOrig = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang);
1753 if (trim($normalizedPart) !== '' && trim($normalizedPart) === trim($normalizedOrig)) {
1754 $rebuiltText .= $translatedText;
1755 $fragTranslated = true;
1756 $anyTranslated = true;
1757 break;
1758 }
1759 }
1760 if (!$fragTranslated) {
1761 // Fragment not found in DB - keep original
1762 $rebuiltText .= $part;
1763 }
1764 }
1765 }
1766
1767 if ($anyTranslated) {
1768 $text = $rebuiltText;
1769 $found = true;
1770 }
1771 }
1772 }
1773 }
1774 } else {
1775 // Normal pattern matching (standard behavior)
1776 // Apply translations without overriding longer translations
1777 $processedParts = [];
1778 foreach ($translationsArray as $originalText => $translatedText) {
1779 $originalText = normalizeText($originalText);
1780
1781 if ($caseInsensitive) {
1782 $text = preg_replace_callback(
1783 '/' . (preg_match('/^\w/u', trim($originalText)) ? '(?<!\w)' : '') . preg_quote(trim($originalText), '/') . '(?!\w)/ui',
1784 function ($matches) use ($translatedText, &$processedParts) {
1785 if (in_array($matches[0], $processedParts, true)) {
1786 return $matches[0];
1787 }
1788 $processedParts[] = $translatedText;
1789 return $translatedText;
1790 },
1791 $text
1792 );
1793 } else {
1794 $text = preg_replace_callback(
1795 '/' . (preg_match('/^\w/u', trim($originalText)) ? '(?<!\w)' : '') . preg_quote(trim($originalText), '/') . '(?!\w)/',
1796 function ($matches) use ($translatedText, &$processedParts) {
1797 if (in_array($matches[0], $processedParts, true)) {
1798 return $matches[0];
1799 }
1800 $processedParts[] = $translatedText;
1801 return $translatedText;
1802 },
1803 $text
1804 );
1805 }
1806 }
1807 }
1808 $node->innertext = $text;
1809 }
1810 } else {
1811 $tagLower = strtolower($node->tag ?? '');
1812 if (in_array($tagLower, ['script', 'style'])) {
1813 return;
1814 }
1815 if (isset($node->nodes) && is_array($node->nodes)) {
1816 foreach ($node->nodes as $child) {
1817 processTextNodes($child, $excludedNodes, $translationsArray, $caseInsensitive, $matchQuotes, $incrementalEnabledPreserveNewTexts, $excludedWordsList, $excludedWordsReplacementMap, $settings, $originalLang, $translatedLang);
1818 }
1819 }
1820 }
1821 }
1822
1823 // Main processing code.
1824 try {
1825 $translationsArray = json_decode($row['translations'], true) ?? [];
1826 $altTranslationsArray = json_decode($row['alt_translations'] ?? '', true) ?: [];
1827
1828 // Sort translations by descending key length
1829 uksort($translationsArray, function ($a, $b) {
1830 return strlen($b) - strlen($a);
1831 });
1832
1833 // Sort alt translations by descending key length
1834 uksort($altTranslationsArray, function ($a, $b) {
1835 return strlen($b) - strlen($a);
1836 });
1837
1838 // Flatten formatting tags regex
1839 if($flattenInnerFormattingTags) {
1840 // Flatten formatting tags in the body to improve matching if enabled
1841 function gp_flatten_inner_formatting_tags_simplehtmldom($htmlString, $tagsToRemove ) {
1842 // Complete body parsing
1843 $doc = gptranslate_simplehtmldom_str_get_html ( $htmlString );
1844 if (! $doc)
1845 return $htmlString;
1846
1847 $selector = implode ( ',', $tagsToRemove );
1848
1849 $found = $doc->find($selector);
1850 if (!empty($found)) {
1851 for ($i = count($found) - 1; $i >= 0; $i--) {
1852 $n = $found[$i];
1853 // Preserva elementi vuoti (es. <i> FontAwesome/icon fonts)
1854 if (trim(str_replace(['&nbsp;', "\xc2\xa0", "\xa0"], '', $n->plaintext)) === '') {
1855 continue;
1856 }
1857 // Preserva elementi che contengono form elements (select, input, textarea, button, ecc.)
1858 $interactiveTags = ['select', 'input', 'textarea', 'button', 'option', 'optgroup', 'fieldset', 'datalist', 'output', 'label', 'form', 'img', 'video', 'audio', 'canvas', 'svg', 'iframe'];
1859 $hasInteractive = false;
1860 foreach ($interactiveTags as $iTag) {
1861 if (!empty($n->find($iTag))) {
1862 $hasInteractive = true;
1863 break;
1864 }
1865 }
1866 if ($hasInteractive) {
1867 continue;
1868 }
1869 $n->outertext = $n->plaintext;
1870 }
1871 }
1872
1873 $out = $doc->save();
1874
1875 if (method_exists($doc, 'clear')) $doc->clear();
1876 unset($doc);
1877
1878 return $out;
1879 }
1880 $html = gp_flatten_inner_formatting_tags_simplehtmldom($html, $flattenInnerFormattingTagsToRemove);
1881 }
1882
1883 // PROTECT script/style content before SimpleHtmlDom parsing (if option enabled)
1884 $protectedScriptsStyles = [];
1885 if (!empty($settings['protect_script_style'])) {
1886 $protectCounter = 0;
1887 $html = preg_replace_callback(
1888 '/<script[^>]*>.*?<\/script>/is',
1889 function ($matches) use (&$protectedScriptsStyles, &$protectCounter) {
1890 $placeholder = "___GPTRANSLATE_SCRIPT_" . ($protectCounter++) . "___";
1891 $protectedScriptsStyles[$placeholder] = $matches[0];
1892 return $placeholder;
1893 },
1894 $html
1895 );
1896 $html = preg_replace_callback(
1897 '/<style[^>]*>.*?<\/style>/is',
1898 function ($matches) use (&$protectedScriptsStyles, &$protectCounter) {
1899 $placeholder = "___GPTRANSLATE_STYLE_" . ($protectCounter++) . "___";
1900 $protectedScriptsStyles[$placeholder] = $matches[0];
1901 return $placeholder;
1902 },
1903 $html
1904 );
1905 }
1906 $htmlObj = gptranslate_simplehtmldom_str_get_html($html);
1907
1908 $cssSelectorLeafnodesExcluded = str_ireplace(
1909 '"',
1910 '',
1911 trim(trim(preg_replace('/,+/', ',', str_ireplace(["\r", "\n"], ",", $settings['css_selector_serverside_leafnodes_excluded'] ?? ''))), ',')
1912 );
1913
1914 $excludedNodes = [];
1915 if (! empty($cssSelectorLeafnodesExcluded)) {
1916 $selectors = explode(',', $cssSelectorLeafnodesExcluded);
1917 foreach ($selectors as $selector) {
1918 $selector = trim($selector);
1919 if (! empty($selector)) {
1920 $foundNodes = $htmlObj->find($selector);
1921 foreach ($foundNodes as $node) {
1922 $excludedNodes[] = $node;
1923 }
1924 }
1925 }
1926 }
1927
1928 $caseInsensitive = ! empty($settings['serverside_translations_caseinsensitive']);
1929 $matchQuotes = ! empty($settings['serverside_translations_matchquotes']);
1930
1931 processTextNodes($htmlObj, $excludedNodes, $translationsArray, $caseInsensitive, $matchQuotes, $incrementalEnabledPreserveNewTexts, $excludedWordsList, $excludedWordsReplacementMap, $settings, $originalLang, $translatedLang);
1932
1933 // Replace page links in <a href="..."> tags based on translated aliases
1934 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
1935 $exclusions = $settings['rewrite_page_links_exclusions'] ?? '';
1936 foreach ($htmlObj->find('a[href]') as $linkNode) {
1937 $href = $linkNode->href;
1938
1939 // Check if link should be excluded from rewriting
1940 if (gptranslate_should_exclude_link($href, $exclusions)) {
1941 continue;
1942 }
1943
1944 // Decode href attribute (WordPress-style, KEEP trailing slash)
1945 $decodedHref = rawurldecode(html_entity_decode($href, ENT_QUOTES, 'UTF-8'));
1946
1947 // Extract hash/fragment if present
1948 $hashFragment = '';
1949 if (strpos($decodedHref, '#') !== false) {
1950 $parts = explode('#', $decodedHref, 2);
1951 $decodedHref = $parts[0]; // URL without hash
1952 $hashFragment = '#' . $parts[1]; // Save the hash
1953 }
1954
1955 // Extract query string if ignore_querystring is enabled
1956 $queryString = '';
1957 if ($settings['ignore_querystring'] == 1 && strpos($decodedHref, '?') !== false) {
1958 $parts = explode('?', $decodedHref, 2);
1959 $decodedHref = $parts[0];
1960 $queryString = '?' . $parts[1];
1961 }
1962 $originalPathBeforeNormalization = $decodedHref;
1963
1964 $decodedHref = gpt_trailingslashit_url($decodedHref);
1965
1966 $translatedAlias = null;
1967
1968 // Cerca prima negli URL assoluti
1969 if (isset($translatedAliasesMap[$decodedHref])) {
1970 $translatedAlias = $translatedAliasesMap[$decodedHref];
1971 }
1972 // Poi cerca negli URL relativi
1973 elseif (isset($translatedAliasesRelativeMap[$decodedHref])) {
1974 $translatedAlias = $translatedAliasesRelativeMap[$decodedHref];
1975 }
1976
1977 // Se trovato un alias tradotto, sostituisci
1978 if ($translatedAlias) {
1979 // Aggiungi data-originalhref se non presente
1980 if (!isset($linkNode->{'data-originalhref'})) {
1981 $linkNode->{'data-originalhref'} = $originalPathBeforeNormalization . $queryString . $hashFragment;
1982 }
1983 $linkNode->href = $translatedAlias . $queryString . $hashFragment;
1984 }
1985 }
1986 }
1987
1988 // Replace form action URLs: add language prefix and apply translated aliases
1989 if (!empty($settings['rewrite_form_actions'])) {
1990 $siteUrl = trailingslashit(get_site_url());
1991 $siteHost = parse_url($siteUrl, PHP_URL_HOST);
1992 $knownLangs = (isset($settings['languages']) && is_array($settings['languages'])) ? array_map('strtolower', $settings['languages']) : [$originalLang, $translatedLang];
1993 foreach ($htmlObj->find('form[action]') as $formNode) {
1994 $action = $formNode->action;
1995 $decodedAction = rawurldecode(html_entity_decode($action, ENT_QUOTES, 'UTF-8'));
1996
1997 // Skip non-rewritable actions
1998 if (preg_match('/^(mailto:|tel:|javascript:|#)/i', $decodedAction)) continue;
1999 if (preg_match('/\.php($|\?|\#)/i', $decodedAction)) continue;
2000 if (preg_match('/^https?:\/\//i', $decodedAction) && parse_url($decodedAction, PHP_URL_HOST) !== $siteHost) continue;
2001
2002 $queryString = '';
2003 if ($settings['ignore_querystring'] == 1 && strpos($decodedAction, '?') !== false) {
2004 $parts = explode('?', $decodedAction, 2);
2005 $decodedAction = $parts[0];
2006 $queryString = '?' . $parts[1];
2007 }
2008 $originalActionFull = $decodedAction . $queryString;
2009
2010 // Extract relative path
2011 $actionPath = $decodedAction;
2012 $isAbsolute = false;
2013 if (preg_match('/^https?:\/\//i', $actionPath)) {
2014 $isAbsolute = true;
2015 $actionPath = parse_url($actionPath, PHP_URL_PATH) ?: '/';
2016 }
2017
2018 // Add/replace language prefix
2019 $pathParts = explode('/', $actionPath);
2020 $langIndex = 1;
2021 if (isset($pathParts[$langIndex]) && $pathParts[$langIndex] === 'index.php') $langIndex = 2;
2022 if (!empty($settings['subfolder_installation'])) $langIndex = 2;
2023
2024 if (isset($pathParts[$langIndex]) && in_array(strtolower($pathParts[$langIndex]), $knownLangs)) {
2025 $pathParts[$langIndex] = $translatedLang;
2026 } else {
2027 array_splice($pathParts, $langIndex, 0, [$translatedLang]);
2028 }
2029
2030 $rebuiltPath = '/' . implode('/', array_filter($pathParts, function($p) { return $p !== ''; }));
2031 $rebuiltPath = gpt_trailingslashit_url($rebuiltPath);
2032 $computedAction = $isAbsolute ? (rtrim($siteUrl, '/') . $rebuiltPath) : $rebuiltPath;
2033 $computedAction = gpt_trailingslashit_url($computedAction);
2034
2035 // Try alias match
2036 $translatedAlias = null;
2037 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
2038 if (isset($translatedAliasesMap[$computedAction])) {
2039 $translatedAlias = $translatedAliasesMap[$computedAction];
2040 } elseif (isset($translatedAliasesRelativeMap[$rebuiltPath])) {
2041 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPath];
2042 } elseif (!$isAbsolute && !empty($settings['subfolder_installation'])) {
2043 // Fallback: form action is relative and includes subfolder path
2044 // Map keys strip the subfolder, so retry after stripping it
2045 $siteBasePath = rtrim(parse_url(rtrim($siteUrl, '/'), PHP_URL_PATH) ?: '', '/');
2046 if ($siteBasePath !== '' && strpos($rebuiltPath, $siteBasePath . '/') === 0) {
2047 $rebuiltPathNoSubfolder = substr($rebuiltPath, strlen($siteBasePath));
2048 if (isset($translatedAliasesRelativeMap[$rebuiltPathNoSubfolder])) {
2049 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPathNoSubfolder];
2050 }
2051 }
2052 }
2053 }
2054
2055 $finalAction = $translatedAlias ? ($translatedAlias . $queryString) : ($computedAction . $queryString);
2056
2057 if (!isset($formNode->{'data-originalaction'})) {
2058 $formNode->{'data-originalaction'} = $originalActionFull;
2059 }
2060 $formNode->action = $finalAction;
2061 }
2062 }
2063
2064 // Check if also images 'alt' are enabled to be translated
2065 if (! empty($settings['translate_altimages'])) {
2066 foreach ($htmlObj->find('img[alt]') as $imgNode) {
2067 $altText = $imgNode->alt;
2068 if ($matchQuotes) {
2069 $altText = str_ireplace('"', "'", $altText);
2070 }
2071 foreach ($altTranslationsArray as $originalText => $translatedText) {
2072 $originalText = normalizeText($originalText);
2073 $altText = normalizeText($altText);
2074
2075 $prevAltText = $altText;
2076 if ($caseInsensitive) {
2077 $altText = str_ireplace(trim($originalText), $translatedText, $altText);
2078 } else {
2079 $altText = str_replace(trim($originalText), $translatedText, $altText);
2080 }
2081 if ($altText !== $prevAltText) {
2082 break;
2083 }
2084 }
2085 $imgNode->alt = $altText;
2086 }
2087
2088 foreach ($htmlObj->find('img[title]') as $imgNode) {
2089 $titleText = $imgNode->title;
2090 if ($matchQuotes) {
2091 $titleText = str_ireplace('"', "'", $titleText);
2092 }
2093 foreach ($altTranslationsArray as $originalText => $translatedText) {
2094 $originalText = normalizeText($originalText);
2095 $titleText = normalizeText($titleText);
2096
2097 $prevTitleText = $titleText;
2098 if ($caseInsensitive) {
2099 $titleText = str_ireplace(trim($originalText), $translatedText, $titleText);
2100 } else {
2101 $titleText = str_replace(trim($originalText), $translatedText, $titleText);
2102 }
2103 if ($titleText !== $prevTitleText) {
2104 break;
2105 }
2106 }
2107 $imgNode->title = $titleText;
2108 }
2109
2110 // Check if also images 'src' are enabled to be translated
2111 if (! empty($settings['translate_srcimages'])) {
2112 foreach ($htmlObj->find('img[src]') as $imgNode) {
2113 $srcValue = ltrim($imgNode->src, '/');
2114
2115 if ($matchQuotes) {
2116 $srcValue = str_ireplace('"', "'", $srcValue);
2117 }
2118
2119 $translatedSrcValue = null;
2120 foreach ($altTranslationsArray as $originalText => $translatedText) {
2121 $originalText = normalizeText($originalText);
2122 $srcValue = normalizeText($srcValue);
2123
2124 $originalText = ltrim($originalText, '/');
2125 if ($caseInsensitive) {
2126 if (strcasecmp(trim($originalText), $srcValue) === 0) {
2127 $translatedSrcValue = $translatedText;
2128 break;
2129 }
2130 } else {
2131 if (trim($originalText) === $srcValue) {
2132 $translatedSrcValue = $translatedText;
2133 break;
2134 }
2135 }
2136 }
2137
2138 // If a translation was found, replace src and srcset
2139 if ($translatedSrcValue !== null) {
2140 // Replace the src attribute
2141 $imgNode->src = $translatedSrcValue;
2142
2143 // Check if srcset exists and replace it with the translated src value
2144 if (isset($imgNode->srcset)) {
2145 $imgNode->srcset = $translatedSrcValue;
2146 }
2147 }
2148 }
2149 }
2150
2151 // Check if also iframes 'src' are enabled to be translated
2152 if (! empty($settings['translate_srciframes'])) {
2153 foreach ($htmlObj->find('iframe[src]') as $iframeNode) {
2154 $srcValue = ltrim($iframeNode->src, '/');
2155
2156 if ($matchQuotes) {
2157 $srcValue = str_ireplace('"', "'", $srcValue);
2158 }
2159
2160 $translatedSrcValue = null;
2161 foreach ($altTranslationsArray as $originalText => $translatedText) {
2162 $originalText = normalizeText($originalText);
2163 $srcValue = normalizeText($srcValue);
2164
2165 $originalText = ltrim($originalText, '/');
2166 if ($caseInsensitive) {
2167 if (strcasecmp(trim($originalText), $srcValue) === 0) {
2168 $translatedSrcValue = $translatedText;
2169 break;
2170 }
2171 } else {
2172 if (trim($originalText) === $srcValue) {
2173 $translatedSrcValue = $translatedText;
2174 break;
2175 }
2176 }
2177 }
2178
2179 if ($translatedSrcValue !== null) {
2180 $iframeNode->src = $translatedSrcValue;
2181 }
2182 }
2183 }
2184
2185 // Check if also videos 'src' are enabled to be translated
2186 if (! empty($settings['translate_srcvideos'])) {
2187 foreach ($htmlObj->find('video[src], video source[src]') as $videoNode) {
2188 $srcValue = ltrim($videoNode->src, '/');
2189
2190 if ($matchQuotes) {
2191 $srcValue = str_ireplace('"', "'", $srcValue);
2192 }
2193
2194 $translatedSrcValue = null;
2195 foreach ($altTranslationsArray as $originalText => $translatedText) {
2196 $originalText = normalizeText($originalText);
2197 $srcValue = normalizeText($srcValue);
2198
2199 $originalText = ltrim($originalText, '/');
2200 if ($caseInsensitive) {
2201 if (strcasecmp(trim($originalText), $srcValue) === 0) {
2202 $translatedSrcValue = $translatedText;
2203 break;
2204 }
2205 } else {
2206 if (trim($originalText) === $srcValue) {
2207 $translatedSrcValue = $translatedText;
2208 break;
2209 }
2210 }
2211 }
2212
2213 if ($translatedSrcValue !== null) {
2214 $videoNode->src = $translatedSrcValue;
2215 }
2216 }
2217 }
2218 }
2219
2220 // Translate iframe locale parameter (e.g. Stripe payment forms)
2221 if (! empty($settings['translate_iframe_locale'])) {
2222 $currentLangCode = strtolower(explode('-', $currentLanguage)[0]);
2223 foreach ($htmlObj->find('iframe[src]') as $iframeNode) {
2224 $src = $iframeNode->src;
2225 if (strpos($src, 'locale=') !== false) {
2226 $iframeNode->src = preg_replace('/locale=[a-z]{2}/i', 'locale=' . $currentLangCode, $src);
2227 }
2228 }
2229 }
2230
2231 // Translate <meta name="description">
2232 foreach ($htmlObj->find('meta[name=description], meta[name=twitter:description], meta[property=og:description], meta[name=dc.description]') as $metaNode) {
2233 $contentValue = $metaNode->content;
2234
2235 // DECODIFICA HTML entities PRIMA del confronto
2236 $contentValue = html_entity_decode($contentValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
2237
2238 if ($matchQuotes) {
2239 $contentValue = str_ireplace('"', "'", $contentValue);
2240 }
2241
2242 foreach ($altTranslationsArray as $originalText => $translatedText) {
2243 $prev = $contentValue;
2244 if ($caseInsensitive) {
2245 $contentValue = str_ireplace(trim($originalText), $translatedText, $contentValue);
2246 } else {
2247 $contentValue = str_replace(trim($originalText), $translatedText, $contentValue);
2248 }
2249 if ($contentValue !== $prev) break;
2250 }
2251
2252 // RI-ENCODIFICA per l'HTML output (sicurezza)
2253 $contentValue = htmlspecialchars($contentValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
2254
2255 $metaNode->content = $contentValue;
2256 }
2257
2258 foreach ($htmlObj->find('meta[property=og:title]') as $metaOgTitleNode) {
2259 // Find the <title> tag to get the translated title
2260 $titleNode = $htmlObj->find('title', 0);
2261
2262 if ($titleNode) {
2263 // Get the translated title text
2264 $translatedTitle = $titleNode->plaintext;
2265
2266 // Set og:title with the same value as <title>
2267 $metaOgTitleNode->content = trim($translatedTitle);
2268 }
2269 }
2270
2271 // Translate <meta name="twitter:title"> with the same value as <title> tag
2272 foreach ($htmlObj->find('meta[name=twitter:title]') as $metaTwitterTitleNode) {
2273 // Find the <title> tag to get the translated title
2274 $titleNode = $htmlObj->find('title', 0);
2275
2276 if ($titleNode) {
2277 // Get the translated title text
2278 $translatedTitle = $titleNode->plaintext;
2279
2280 // Set twitter:title with the same value as <title>
2281 $metaTwitterTitleNode->content = trim($translatedTitle);
2282 }
2283 }
2284
2285 // Translate <meta name="dc:title"> (Dublin Core - SEOPress) with the same value as <title> tag
2286 foreach ($htmlObj->find('meta[name=dc.title]') as $metaDcTitleNode) {
2287 // Find the <title> tag to get the translated title
2288 $titleNode = $htmlObj->find('title', 0);
2289
2290 if ($titleNode) {
2291 // Get the translated title text
2292 $translatedTitle = $titleNode->plaintext;
2293
2294 // Set dc:title with the same value as <title>
2295 $metaDcTitleNode->content = trim($translatedTitle);
2296 }
2297 }
2298
2299 if ($bodyElement = $htmlObj->find('body', 0)) {
2300 $bodyElement->setAttribute('data-gptranslateskip', '1');
2301 $bodyElement->setAttribute ( 'data-gptranslateoriginalalias', htmlspecialchars($row['pagelink'], ENT_QUOTES, 'UTF-8') );
2302 }
2303
2304 $modifiedHtml = $htmlObj->save();
2305 $modifiedHtml = html_entity_decode($modifiedHtml, ENT_QUOTES | ENT_HTML5, 'UTF-8');
2306
2307 // RESTORE protected script/style content (if protection was enabled)
2308 if (!empty($settings['protect_script_style'])) {
2309 foreach ($protectedScriptsStyles as $placeholder => $originalContent) {
2310 $modifiedHtml = str_replace($placeholder, $originalContent, $modifiedHtml);
2311 }
2312 }
2313 $html = $modifiedHtml;
2314 } catch (Exception $e) {
2315 // Handle exceptions as needed
2316 }
2317 } elseif ($settings['serverside_translations_method'] == 'strireplace') {
2318 // Solution 3: simplest approach, unconditional str_ireplace that could cause unintentional replacements
2319 try {
2320 $translationsArray = json_decode($row['translations'], true) ?? [];
2321 $altTranslationsArray = json_decode($row['alt_translations'] ?? '', true) ?: [];
2322
2323 // Do body page translations replacements
2324 uksort($translationsArray, function ($a, $b) {
2325 return strlen($b) - strlen($a);
2326 });
2327
2328 $caseInsensitive = !empty($settings['serverside_translations_caseinsensitive']);
2329 $matchQuotes = !empty($settings['serverside_translations_matchquotes']);
2330
2331 foreach ($translationsArray as $originalText => $translatedText) {
2332 $originalText = normalizeText($originalText);
2333 $html = normalizeText($html);
2334
2335 if ($caseInsensitive) {
2336 $html = str_ireplace(trim($originalText), $translatedText, $html);
2337
2338 // Check also if both single quotes or double quotes should be checked to replace
2339 if ($matchQuotes && strpos($originalText, "'") !== false) {
2340 $originalTextAlt = str_ireplace("'", '"', $originalText);
2341 $html = str_ireplace(trim($originalTextAlt), $translatedText, $html);
2342 }
2343 } else {
2344 $html = str_replace(trim($originalText), $translatedText, $html);
2345
2346 if ($matchQuotes && strpos($originalText, "'") !== false) {
2347 $originalTextAlt = str_replace("'", '"', $originalText);
2348 $html = str_replace(trim($originalTextAlt), $translatedText, $html);
2349 }
2350 }
2351 }
2352
2353 // Check if also images 'alt' are enabled to be translated
2354 if (!empty($settings['translate_altimages'])) {
2355 foreach ($altTranslationsArray as $originalAlt => $translatedAlt) {
2356 if ($matchQuotes) {
2357 $originalAlt = str_ireplace('"', "'", $originalAlt);
2358 }
2359 if ($caseInsensitive) {
2360 $html = str_ireplace('alt="' . trim($originalAlt) . '"', 'alt="' . $translatedAlt . '"', $html);
2361 $html = str_ireplace("alt='" . trim($originalAlt) . "'", "alt='" . $translatedAlt . "'", $html);
2362 } else {
2363 $html = str_replace('alt="' . trim($originalAlt) . '"', 'alt="' . $translatedAlt . '"', $html);
2364 $html = str_replace("alt='" . trim($originalAlt) . "'", "alt='" . $translatedAlt . "'", $html);
2365 }
2366 }
2367 }
2368
2369 $html = str_ireplace('<body', '<body data-gptranslateskip="1" data-gptranslateoriginalalias="' . $row['pagelink'] . '"', $html);
2370
2371 } catch (Exception $e) {
2372 // Handle exceptions as needed
2373 }
2374 }
2375
2376 return $html;
2377 } );
2378 } );
2379