PluginProbe ʕ •ᴥ•ʔ
GPTranslate – Multilingual AI Translation for WordPress: Translate Websites with AI Agents / 2.33.8
GPTranslate – Multilingual AI Translation for WordPress: Translate Websites with AI Agents v2.33.8
2.33.8 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
2382 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 $page_exclusions = $settings['page_exclusions'] ?? '';
752 $html = preg_replace_callback(
753 '/<a\s+([^>]*\s)?href\s*=\s*(["\'])(.*?)\2([^>]*)>/i',
754 function($matches) use ($translatedAliasesMap, $translatedAliasesRelativeMap, $settings, $exclusions, $page_exclusions) {
755 $fullTag = $matches[0];
756 $quote = $matches[2];
757 $href = $matches[3];
758
759 // Check if link should be excluded from rewriting
760 if (gptranslate_should_exclude_link($href, $exclusions, $page_exclusions)) {
761 return $fullTag;
762 }
763
764 // Decode href attribute to match database entries
765 $decodedHref = rawurldecode(html_entity_decode($href, ENT_QUOTES, 'UTF-8'));
766
767 // Extract hash/fragment if present
768 $hashFragment = '';
769 if (strpos($decodedHref, '#') !== false) {
770 $parts = explode('#', $decodedHref, 2);
771 $decodedHref = $parts[0]; // URL without hash
772 $hashFragment = '#' . $parts[1]; // Save the hash
773 }
774
775 $queryString = '';
776 if ($settings ['ignore_querystring'] == 1 && strpos($decodedHref, '?') !== false) {
777 $parts = explode('?', $decodedHref, 2);
778 $decodedHref = $parts[0];
779 $queryString = '?' . $parts[1];
780 }
781 $originalPathBeforeNormalization = $decodedHref;
782
783 $decodedHref = gpt_trailingslashit_url($decodedHref);
784
785 $translatedAlias = null;
786
787 // First pass, check in absolute URLs
788 if (isset($translatedAliasesMap[$decodedHref])) {
789 $translatedAlias = $translatedAliasesMap[$decodedHref];
790 }
791 // Second pass, check in relative URLs (pathname)
792 elseif (isset($translatedAliasesRelativeMap[$decodedHref])) {
793 $translatedAlias = $translatedAliasesRelativeMap[$decodedHref];
794 }
795
796 // If found a translated alias, replace href and add data-originalhref if not present
797 if ($translatedAlias) {
798 $finalHref = $translatedAlias . $queryString . $hashFragment;
799
800 $originalHrefFull = $originalPathBeforeNormalization . $queryString . $hashFragment;
801
802 // Check if data-originalhref already exists in the tag
803 if (strpos($fullTag, 'data-originalhref') === false) {
804 $dataAttr = ' data-originalhref=' . $quote . htmlspecialchars($originalHrefFull, ENT_QUOTES, 'UTF-8') . $quote;
805 $newTag = str_ireplace('<a ', '<a' . $dataAttr . ' ', $fullTag);
806 $newTag = str_ireplace('href=' . $quote . $href . $quote, 'href=' . $quote . htmlspecialchars($finalHref, ENT_QUOTES, 'UTF-8') . $quote, $newTag);
807 return $newTag;
808 } else {
809 return str_ireplace('href=' . $quote . $href . $quote, 'href=' . $quote . htmlspecialchars($finalHref, ENT_QUOTES, 'UTF-8') . $quote, $fullTag);
810 }
811 }
812
813 return $fullTag;
814 },
815 $html
816 );
817
818 // Replace form action URLs: add language prefix and apply translated aliases
819 if (!empty($settings['rewrite_form_actions'])) {
820 $siteUrl = trailingslashit(get_site_url());
821 $siteHost = parse_url($siteUrl, PHP_URL_HOST);
822 $knownLangs = (isset($settings['languages']) && is_array($settings['languages'])) ? array_map('strtolower', $settings['languages']) : [$originalLang, $translatedLang];
823 $html = preg_replace_callback(
824 '/<form\s+([^>]*\s)?action\s*=\s*(["\'])(.*?)\2([^>]*)>/i',
825 function($matches) use ($translatedAliasesMap, $translatedAliasesRelativeMap, $settings, $translatedLang, $siteUrl, $siteHost, $knownLangs) {
826 $fullTag = $matches[0];
827 $quote = $matches[2];
828 $action = $matches[3];
829
830 $decodedAction = rawurldecode(html_entity_decode($action, ENT_QUOTES, 'UTF-8'));
831
832 // Skip anchors, mailto, tel, javascript, .php endpoints
833 if (preg_match('/^(mailto:|tel:|javascript:|#)/i', $decodedAction)) return $fullTag;
834 if (preg_match('/\.php($|\?|\#)/i', $decodedAction)) return $fullTag;
835
836 // Skip external URLs
837 if (preg_match('/^https?:\/\//i', $decodedAction)) {
838 $actionHost = parse_url($decodedAction, PHP_URL_HOST);
839 if ($actionHost !== $siteHost) return $fullTag;
840 }
841
842 // Extract query string
843 $queryString = '';
844 if ($settings['ignore_querystring'] == 1 && strpos($decodedAction, '?') !== false) {
845 $parts = explode('?', $decodedAction, 2);
846 $decodedAction = $parts[0];
847 $queryString = '?' . $parts[1];
848 }
849 $originalActionFull = $decodedAction . $queryString;
850
851 // Extract relative path
852 $actionPath = $decodedAction;
853 $isAbsolute = false;
854 if (preg_match('/^https?:\/\//i', $actionPath)) {
855 $isAbsolute = true;
856 $actionPath = parse_url($actionPath, PHP_URL_PATH) ?: '/';
857 }
858
859 // Split path and determine language index
860 $pathParts = explode('/', $actionPath);
861 $langIndex = 1;
862 if (isset($pathParts[$langIndex]) && $pathParts[$langIndex] === 'index.php') {
863 $langIndex = 2;
864 }
865 if (!empty($settings['subfolder_installation'])) {
866 $langIndex = 2;
867 }
868
869 // Check if already has a language prefix, replace or insert
870 if (isset($pathParts[$langIndex]) && in_array(strtolower($pathParts[$langIndex]), $knownLangs)) {
871 $pathParts[$langIndex] = $translatedLang;
872 } else {
873 array_splice($pathParts, $langIndex, 0, [$translatedLang]);
874 }
875
876 // Rebuild path
877 $rebuiltPath = '/' . implode('/', array_filter($pathParts, function($p) { return $p !== ''; }));
878 $rebuiltPath = gpt_trailingslashit_url($rebuiltPath);
879
880 // Rebuild full URL if original was absolute
881 $computedAction = $isAbsolute ? (rtrim($siteUrl, '/') . $rebuiltPath) : $rebuiltPath;
882 $computedAction = gpt_trailingslashit_url($computedAction);
883
884 // Try alias match
885 $translatedAlias = null;
886 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
887 if (isset($translatedAliasesMap[$computedAction])) {
888 $translatedAlias = $translatedAliasesMap[$computedAction];
889 } elseif (isset($translatedAliasesRelativeMap[$rebuiltPath])) {
890 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPath];
891 } elseif (!$isAbsolute && !empty($settings['subfolder_installation'])) {
892 // Fallback: form action is relative and includes subfolder path
893 // Map keys strip the subfolder, so retry after stripping it
894 $siteBasePath = rtrim(parse_url(rtrim($siteUrl, '/'), PHP_URL_PATH) ?: '', '/');
895 if ($siteBasePath !== '' && strpos($rebuiltPath, $siteBasePath . '/') === 0) {
896 $rebuiltPathNoSubfolder = substr($rebuiltPath, strlen($siteBasePath));
897 if (isset($translatedAliasesRelativeMap[$rebuiltPathNoSubfolder])) {
898 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPathNoSubfolder];
899 }
900 }
901 }
902 }
903
904 $finalAction = $translatedAlias ? ($translatedAlias . $queryString) : ($computedAction . $queryString);
905
906 if (strpos($fullTag, 'data-originalaction') === false) {
907 $dataAttr = ' data-originalaction=' . $quote . htmlspecialchars($originalActionFull, ENT_QUOTES, 'UTF-8') . $quote;
908 $newTag = str_ireplace('<form ', '<form' . $dataAttr . ' ', $fullTag);
909 $newTag = str_ireplace('action=' . $quote . $action . $quote, 'action=' . $quote . htmlspecialchars($finalAction, ENT_QUOTES, 'UTF-8') . $quote, $newTag);
910 return $newTag;
911 } else {
912 return str_ireplace('action=' . $quote . $action . $quote, 'action=' . $quote . htmlspecialchars($finalAction, ENT_QUOTES, 'UTF-8') . $quote, $fullTag);
913 }
914 },
915 $html
916 );
917 } // end rewrite_form_actions
918 }
919 } elseif ($settings ['serverside_translations_method'] == 'domdocument') {
920 // Solution 2: classic DOMDocument approach, effective but could cause closing tags and encoding issues
921 try {
922 $translationsArray = json_decode ( $row['translations'], true ) ?? [ ];
923 $altTranslationsArray = json_decode ( $row['alt_translations'] ?? '', true ) ?: [ ];
924
925 // Sort the translation keys in descending order by length.
926 uksort ( $translationsArray, function ( $a, $b ) {
927 return strlen ( $b ) - strlen ( $a );
928 } );
929
930 // Sort the alt translation keys in descending order by length.
931 uksort ( $altTranslationsArray, function ( $a, $b ) {
932 return strlen ( $b ) - strlen ( $a );
933 } );
934
935 // Flatten formatting tags in the body to improve matching if enabled
936 if($flattenInnerFormattingTags) {
937 function gp_flatten_inner_formatting_tags($html, $tagsToRemove) {
938 $dom = new DOMDocument();
939 libxml_use_internal_errors(true);
940
941 // Usa lo stesso metodo del codice principale con l'hack XML encoding
942 $dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
943
944 libxml_clear_errors();
945
946 $xpath = new DOMXPath($dom);
947
948 // Seleziona tutti i tag da rimuovere, ovunque
949 $query = '//' . implode(' | //', array_map(function($t) { return strtolower($t); }, $tagsToRemove));
950
951 $nodes = $xpath->query($query);
952
953 // Importante: sostituire dal "basso verso l'alto" per evitare problemi mentre modifichi il DOM
954 // Convertiamo NodeList in array e invertiamo
955 $toReplace = [];
956 foreach ($nodes as $n)
957 $toReplace[] = $n;
958 $toReplace = array_reverse($toReplace);
959
960 foreach ($toReplace as $node) {
961 // Preserva elementi vuoti (es. <i> FontAwesome/icon fonts)
962 if (trim(str_replace(['&nbsp;', "\xc2\xa0", "\xa0"], '', $node->textContent)) === '') {
963 continue;
964 }
965 // Preserva elementi che contengono form elements (select, input, textarea, button, ecc.)
966 $interactiveTags = ['select', 'input', 'textarea', 'button', 'option', 'optgroup', 'fieldset', 'datalist', 'output', 'label', 'form', 'img', 'video', 'audio', 'canvas', 'svg', 'iframe'];
967 $hasInteractive = false;
968 foreach ($interactiveTags as $iTag) {
969 if ($node->getElementsByTagName($iTag)->length > 0) {
970 $hasInteractive = true;
971 break;
972 }
973 }
974 if ($hasInteractive) {
975 continue;
976 }
977
978 $text = $node->textContent; // prende il testo "visibile" (quello che vuoi)
979 $textNode = $dom->createTextNode($text);
980
981 if ($node->parentNode) {
982 $node->parentNode->replaceChild($textNode, $node);
983 }
984 }
985
986 // Salva l'HTML modificato
987 $out = $dom->saveHTML();
988
989 return $out;
990 }
991 $html = gp_flatten_inner_formatting_tags($html, $flattenInnerFormattingTagsToRemove);
992 }
993
994 // Initialize DOMDocument without converting the body content via mb_convert_encoding.
995 $doc = new DOMDocument ();
996 libxml_use_internal_errors ( true );
997 // Use an XML encoding hack to inform DOMDocument about UTF-8 while preserving original characters.
998
999 // PROTECT script/style content before DOMDocument loading (if option enabled)
1000 $protectedScriptsStyles = [];
1001 if (!empty($settings['protect_script_style'])) {
1002 $protectCounter = 0;
1003
1004 // Extract <script>...</script> tags and replace with marker meta tags
1005 $html = preg_replace_callback(
1006 '/<script\b[^>]*>.*?<\/script>/is',
1007 function ($matches) use (&$protectedScriptsStyles, &$protectCounter) {
1008 $placeholder = "___GPTRANSLATE_SCRIPT_" . ($protectCounter++) . "___";
1009 $protectedScriptsStyles[$placeholder] = $matches[0];
1010 // Use a data attribute to preserve position - DOMDocument won't move these
1011 return '<meta data-gptranslate-script="' . htmlspecialchars($placeholder, ENT_QUOTES) . '">';
1012 },
1013 $html
1014 );
1015
1016 // Extract <style>...</style> tags and replace with marker meta tags
1017 $html = preg_replace_callback(
1018 '/<style\b[^>]*>.*?<\/style>/is',
1019 function ($matches) use (&$protectedScriptsStyles, &$protectCounter) {
1020 $placeholder = "___GPTRANSLATE_STYLE_" . ($protectCounter++) . "___";
1021 $protectedScriptsStyles[$placeholder] = $matches[0];
1022 // Use a data attribute to preserve position
1023 return '<meta data-gptranslate-style="' . htmlspecialchars($placeholder, ENT_QUOTES) . '">';
1024 },
1025 $html
1026 );
1027 }
1028 $doc->loadHTML ( '<?xml encoding="UTF-8">' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
1029 libxml_clear_errors ();
1030
1031 // Use XPath to locate text nodes, skipping those in <script> or <style> tags.
1032 $xpath = new DOMXPath ( $doc );
1033 $cssSelectorLeafnodesExcluded = str_ireplace ( '"', '', trim ( trim ( preg_replace ( '/,+/', ',', str_ireplace ( [ "\r", "\n" ], ",", $settings['css_selector_serverside_leafnodes_excluded'] ?? '' ) ) ), ',' ) );
1034 $excludedNodes = [ ];
1035
1036 if (! empty ( $cssSelectorLeafnodesExcluded )) {
1037 $selectors = explode ( ',', $cssSelectorLeafnodesExcluded );
1038 $xpathQueries = [ ];
1039
1040 foreach ( $selectors as $selector ) {
1041 $selector = trim ( $selector );
1042 if (empty ( $selector ))
1043 continue;
1044
1045 // Convert CSS selector to XPath
1046 $selector = preg_replace ( '/\s+/', ' ', $selector ); // Normalize spaces
1047 $selectorParts = explode ( ' ', $selector );
1048
1049 $xpathQuery = '';
1050 foreach ( $selectorParts as $part ) {
1051 if (preg_match ( '/^([a-zA-Z0-9_-]+)?(\.[a-zA-Z0-9_-]+)?(#[a-zA-Z0-9_-]+)?$/', $part, $matches )) {
1052 $tag = ! empty ( $matches[1] ) ? $matches[1] : '*'; // If no tag, use '*'
1053 $class = ! empty ( $matches[2] ) ? substr ( $matches[2], 1 ) : ''; // Remove leading '.'
1054 $id = ! empty ( $matches[3] ) ? substr ( $matches[3], 1 ) : ''; // Remove leading '#'
1055
1056 $conditions = [ ];
1057 if ($class) {
1058 $conditions[] = "contains(concat(' ', normalize-space(@class), ' '), ' $class ')";
1059 }
1060 if ($id) {
1061 $conditions[] = "@id='$id'";
1062 }
1063
1064 $xpathQuery .= "//{$tag}" . (! empty ( $conditions ) ? "[" . implode ( " and ", $conditions ) . "]" : "");
1065 }
1066 }
1067
1068 if (! empty ( $xpathQuery )) {
1069 $xpathQueries[] = $xpathQuery;
1070 }
1071 }
1072
1073 // Execute all XPath queries and collect excluded nodes
1074 foreach ( $xpathQueries as $query ) {
1075 foreach ( $xpath->query ( $query ) as $excludedNode ) {
1076 $excludedNodes[] = $excludedNode;
1077 }
1078 }
1079 }
1080
1081 $textNodes = $xpath->query ( '//text()[not(ancestor::script) and not(ancestor::style) and normalize-space()]' );
1082 $caseInsensitive = ! empty ( $settings['serverside_translations_caseinsensitive'] );
1083 $matchQuotes = ! empty ( $settings['serverside_translations_matchquotes'] );
1084
1085 // Process each text node with translation replacements.
1086 foreach ( $textNodes as $textNode ) {
1087 if ($cssSelectorLeafnodesExcluded) {
1088 $skipNode = false;
1089 foreach ( $excludedNodes as $excludedNode ) {
1090 if ($excludedNode->isSameNode ( $textNode->parentNode )) {
1091 $skipNode = true;
1092 break;
1093 }
1094 }
1095 if ($skipNode)
1096 continue;
1097 }
1098
1099 $originalTextContent = $textNode->nodeValue;
1100 $textContent = $textNode->nodeValue;
1101 if ($matchQuotes) {
1102 $textContent = str_ireplace ( '"', "'", $textContent );
1103 }
1104 // Normalize once without trimming to preserve spaces between nodes
1105 $textContent = normalizeTextNoTrim($textContent);
1106
1107 // INCREMENTAL FIX: Use exact match only if incremental preserve new texts is enabled
1108 if ($incrementalEnabledPreserveNewTexts) {
1109 // Extract leading/trailing whitespace from original text
1110 preg_match('/^(\s*)(.*?)(\s*)$/s', $textContent, $wsMatches);
1111 $leadingWhitespace = $wsMatches[1] ?? '';
1112 $trailingWhitespace = $wsMatches[3] ?? '';
1113
1114 // Apply dictionary replacement for incremental matching (matches client-side behavior)
1115 $normalizedNodeText = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($textContent, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($textContent, $settings, $originalLang, $translatedLang);
1116 $found = false;
1117
1118 // Step 1: Exact match
1119 foreach ($translationsArray as $originalText => $translatedText) {
1120 $normalizedOrig = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang);
1121 if (trim($normalizedNodeText) === trim($normalizedOrig)) {
1122 $textContent = $leadingWhitespace . $translatedText . $trailingWhitespace;
1123 $found = true;
1124 break;
1125 }
1126 }
1127
1128 // Step 2: Excluded-words-aware fragment matching
1129 // Mirrors client-side wrap_excluded_words behavior: client creates SEPARATE text nodes
1130 // around excluded words, so DB stores fragments separately, NOT the full text.
1131 // Example: "Now i add a new text and modify the page" with 'text' excluded
1132 // Client creates 3 nodes: "Now i add a new ", "text" (preserved), " and modify the page"
1133 // DB stores: "Now i add a new " → "Ora aggiungo un nuovo ", " and modify the page" → " e modificare la pagina"
1134 // We split the text by excluded words, translate each fragment, preserve excluded words.
1135 if (!$found && !empty($excludedWordsList)) {
1136 $excludedPattern = '';
1137 foreach ($excludedWordsList as $excludedWord) {
1138 $excludedWord = trim($excludedWord);
1139 if (!empty($excludedWord)) {
1140 $excludedPattern .= ($excludedPattern ? '|' : '') . preg_quote($excludedWord, '/');
1141 }
1142 }
1143
1144 if (!empty($excludedPattern)) {
1145 // Split keeping excluded words as delimiters (PREG_SPLIT_DELIM_CAPTURE)
1146 $parts = preg_split(
1147 '/((?<!\w)(?:' . $excludedPattern . ')(?!\w))/' . ($caseInsensitive ? 'ui' : 'u'),
1148 $textContent,
1149 -1,
1150 PREG_SPLIT_DELIM_CAPTURE
1151 );
1152
1153 if (is_array($parts) && count($parts) > 1) {
1154 $rebuiltText = '';
1155 $anyTranslated = false;
1156
1157 foreach ($parts as $part) {
1158 if ($part === '') {
1159 continue;
1160 }
1161 // Check if this part is an excluded word - preserve as-is
1162 $isExcluded = false;
1163 $matchedExcludedWord = '';
1164 foreach ($excludedWordsList as $excludedWord) {
1165 if (strcasecmp(trim($part), trim($excludedWord)) === 0) {
1166 $isExcluded = true;
1167 $matchedExcludedWord = mb_strtolower(trim($excludedWord), 'UTF-8');
1168 break;
1169 }
1170 }
1171
1172 if ($isExcluded) {
1173 $replacement = $excludedWordsReplacementMap[$matchedExcludedWord] ?? '';
1174 if ($replacement !== '') {
1175 $rebuiltText .= $replacement;
1176 $anyTranslated = true;
1177 } else {
1178 $rebuiltText .= $part;
1179 }
1180 } else {
1181 // Try to find an exact match for this fragment in DB
1182 $normalizedPart = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($part, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($part, $settings, $originalLang, $translatedLang);
1183 $fragTranslated = false;
1184 foreach ($translationsArray as $originalText => $translatedText) {
1185 $normalizedOrig = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang);
1186 if (trim($normalizedPart) !== '' && trim($normalizedPart) === trim($normalizedOrig)) {
1187 $rebuiltText .= $translatedText;
1188 $fragTranslated = true;
1189 $anyTranslated = true;
1190 break;
1191 }
1192 }
1193 if (!$fragTranslated) {
1194 // Fragment not found in DB - keep original
1195 $rebuiltText .= $part;
1196 }
1197 }
1198 }
1199
1200 if ($anyTranslated) {
1201 $textContent = $rebuiltText;
1202 $found = true;
1203 }
1204 }
1205 }
1206 }
1207 } else {
1208 // Normal pattern matching (standard behavior)
1209 foreach ( $translationsArray as $originalText => $translatedText ) {
1210 $originalText = normalizeText($originalText);
1211
1212 $prevTextContent = $textContent; // Save the current state
1213 if ($wrapExcludedWords) {
1214 // Use word boundaries to avoid sub-replacing inside already translated words
1215 $pattern = '/' . (preg_match('/^\w/u', trim($originalText)) ? '(?<!\w)' : '') . preg_quote(trim($originalText), '/') . '(?!\w)/' . ($caseInsensitive ? 'ui' : 'u');
1216 $textContent = preg_replace($pattern, $translatedText, $textContent);
1217 } else {
1218 // Use word boundaries to avoid sub-replacing inside already translated words
1219 $pattern = '/' . (preg_match('/^\w/u', trim($originalText)) ? '(?<!\w)' : '') . preg_quote(trim($originalText), '/') . '(?!\w)/' . ($caseInsensitive ? 'ui' : 'u');
1220 $textContent = preg_replace($pattern, $translatedText, $textContent);
1221 if ($textContent !== $prevTextContent) {
1222 break;
1223 }
1224 }
1225 }
1226 }
1227 $textNode->nodeValue = $textContent;
1228 }
1229
1230 // Replace page links in <a href="..."> tags based on translated aliases
1231 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
1232 $exclusions = $settings['rewrite_page_links_exclusions'] ?? '';
1233 $page_exclusions = $settings['page_exclusions'] ?? '';
1234 $linkNodes = $xpath->query('//a[@href]');
1235 foreach ($linkNodes as $linkNode) {
1236 $href = $linkNode->getAttribute('href');
1237
1238 // Check if link should be excluded from rewriting
1239 if (gptranslate_should_exclude_link($href, $exclusions, $page_exclusions)) {
1240 continue;
1241 }
1242
1243 // Decode href attribute (WordPress-style, KEEP trailing slash)
1244 $decodedHref = rawurldecode(html_entity_decode($href, ENT_QUOTES, 'UTF-8'));
1245
1246 // Extract hash/fragment if present
1247 $hashFragment = '';
1248 if (strpos($decodedHref, '#') !== false) {
1249 $parts = explode('#', $decodedHref, 2);
1250 $decodedHref = $parts[0]; // URL without hash
1251 $hashFragment = '#' . $parts[1]; // Save the hash
1252 }
1253
1254 $queryString = '';
1255 if ($settings['ignore_querystring'] == 1 && strpos($decodedHref, '?') !== false) {
1256 $parts = explode('?', $decodedHref, 2);
1257 $decodedHref = $parts[0];
1258 $queryString = '?' . $parts[1];
1259 }
1260 $originalPathBeforeNormalization = $decodedHref;
1261
1262 $decodedHref = gpt_trailingslashit_url($decodedHref);
1263
1264 $translatedAlias = null;
1265
1266 // Cerca prima negli URL assoluti
1267 if (isset($translatedAliasesMap[$decodedHref])) {
1268 $translatedAlias = $translatedAliasesMap[$decodedHref];
1269 }
1270 // Poi cerca negli URL relativi
1271 elseif (isset($translatedAliasesRelativeMap[$decodedHref])) {
1272 $translatedAlias = $translatedAliasesRelativeMap[$decodedHref];
1273 }
1274
1275 // Se trovato un alias tradotto, sostituisci
1276 if ($translatedAlias) {
1277 // Aggiungi data-originalhref se non presente
1278 if (!$linkNode->hasAttribute('data-originalhref')) {
1279 $linkNode->setAttribute('data-originalhref', $originalPathBeforeNormalization . $queryString . $hashFragment);
1280 }
1281 $linkNode->setAttribute('href', $translatedAlias . $queryString . $hashFragment);
1282 }
1283 }
1284 }
1285
1286 // Replace form action URLs: add language prefix and apply translated aliases
1287 if (!empty($settings['rewrite_form_actions'])) {
1288 $siteUrl = trailingslashit(get_site_url());
1289 $siteHost = parse_url($siteUrl, PHP_URL_HOST);
1290 $knownLangs = (isset($settings['languages']) && is_array($settings['languages'])) ? array_map('strtolower', $settings['languages']) : [$originalLang, $translatedLang];
1291 $formNodes = $xpath->query('//form[@action]');
1292 foreach ($formNodes as $formNode) {
1293 $action = $formNode->getAttribute('action');
1294 $decodedAction = rawurldecode(html_entity_decode($action, ENT_QUOTES, 'UTF-8'));
1295
1296 // Skip non-rewritable actions
1297 if (preg_match('/^(mailto:|tel:|javascript:|#)/i', $decodedAction)) continue;
1298 if (preg_match('/\.php($|\?|\#)/i', $decodedAction)) continue;
1299 if (preg_match('/^https?:\/\//i', $decodedAction) && parse_url($decodedAction, PHP_URL_HOST) !== $siteHost) continue;
1300
1301 $queryString = '';
1302 if ($settings['ignore_querystring'] == 1 && strpos($decodedAction, '?') !== false) {
1303 $parts = explode('?', $decodedAction, 2);
1304 $decodedAction = $parts[0];
1305 $queryString = '?' . $parts[1];
1306 }
1307 $originalActionFull = $decodedAction . $queryString;
1308
1309 // Extract relative path
1310 $actionPath = $decodedAction;
1311 $isAbsolute = false;
1312 if (preg_match('/^https?:\/\//i', $actionPath)) {
1313 $isAbsolute = true;
1314 $actionPath = parse_url($actionPath, PHP_URL_PATH) ?: '/';
1315 }
1316
1317 // Add/replace language prefix
1318 $pathParts = explode('/', $actionPath);
1319 $langIndex = 1;
1320 if (isset($pathParts[$langIndex]) && $pathParts[$langIndex] === 'index.php') $langIndex = 2;
1321 if (!empty($settings['subfolder_installation'])) $langIndex = 2;
1322
1323 if (isset($pathParts[$langIndex]) && in_array(strtolower($pathParts[$langIndex]), $knownLangs)) {
1324 $pathParts[$langIndex] = $translatedLang;
1325 } else {
1326 array_splice($pathParts, $langIndex, 0, [$translatedLang]);
1327 }
1328
1329 $rebuiltPath = '/' . implode('/', array_filter($pathParts, function($p) { return $p !== ''; }));
1330 $rebuiltPath = gpt_trailingslashit_url($rebuiltPath);
1331 $computedAction = $isAbsolute ? (rtrim($siteUrl, '/') . $rebuiltPath) : $rebuiltPath;
1332 $computedAction = gpt_trailingslashit_url($computedAction);
1333
1334 // Try alias match
1335 $translatedAlias = null;
1336 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
1337 if (isset($translatedAliasesMap[$computedAction])) {
1338 $translatedAlias = $translatedAliasesMap[$computedAction];
1339 } elseif (isset($translatedAliasesRelativeMap[$rebuiltPath])) {
1340 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPath];
1341 } elseif (!$isAbsolute && !empty($settings['subfolder_installation'])) {
1342 // Fallback: form action is relative and includes subfolder path
1343 // Map keys strip the subfolder, so retry after stripping it
1344 $siteBasePath = rtrim(parse_url(rtrim($siteUrl, '/'), PHP_URL_PATH) ?: '', '/');
1345 if ($siteBasePath !== '' && strpos($rebuiltPath, $siteBasePath . '/') === 0) {
1346 $rebuiltPathNoSubfolder = substr($rebuiltPath, strlen($siteBasePath));
1347 if (isset($translatedAliasesRelativeMap[$rebuiltPathNoSubfolder])) {
1348 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPathNoSubfolder];
1349 }
1350 }
1351 }
1352 }
1353
1354 $finalAction = $translatedAlias ? ($translatedAlias . $queryString) : ($computedAction . $queryString);
1355
1356 if (!$formNode->hasAttribute('data-originalaction')) {
1357 $formNode->setAttribute('data-originalaction', $originalActionFull);
1358 }
1359 $formNode->setAttribute('action', $finalAction);
1360 }
1361 }
1362
1363 // Check if also images 'alt' are enabled to be translated
1364 if (! empty ( $settings['translate_altimages'] )) {
1365 $imgNodesAlt = $xpath->query ( '//img[@alt]' );
1366 foreach ( $imgNodesAlt as $imgNode ) {
1367 $altText = $imgNode->getAttribute ( 'alt' );
1368 if ($matchQuotes) {
1369 $altText = str_ireplace ( '"', "'", $altText );
1370 }
1371 foreach ( $altTranslationsArray as $originalText => $translatedText ) {
1372 $originalText = normalizeText($originalText);
1373 $altText = normalizeText($altText);
1374
1375 $prevAltText = $altText;
1376 if ($caseInsensitive) {
1377 $altText = str_ireplace ( trim ( $originalText ), $translatedText, $altText );
1378 } else {
1379 $altText = str_replace ( trim ( $originalText ), $translatedText, $altText );
1380 }
1381 if ($altText !== $prevAltText) {
1382 break;
1383 }
1384 }
1385 $imgNode->setAttribute ( 'alt', $altText );
1386 }
1387
1388 $imgNodesTitle = $xpath->query ( '//img[@title]' );
1389 foreach ( $imgNodesTitle as $imgNode ) {
1390 $titleText = $imgNode->getAttribute ( 'title' );
1391 if ($matchQuotes) {
1392 $titleText = str_ireplace ( '"', "'", $titleText );
1393 }
1394 foreach ( $altTranslationsArray as $originalText => $translatedText ) {
1395 $originalText = normalizeText($originalText);
1396 $titleText = normalizeText($titleText);
1397
1398 $prevTitleText = $titleText;
1399 if ($caseInsensitive) {
1400 $titleText = str_ireplace ( trim ( $originalText ), $translatedText, $titleText );
1401 } else {
1402 $titleText = str_replace ( trim ( $originalText ), $translatedText, $titleText );
1403 }
1404 if ($titleText !== $prevTitleText) {
1405 break;
1406 }
1407 }
1408 $imgNode->setAttribute ( 'title', $titleText );
1409 }
1410
1411 // Check if also images 'src' are enabled to be translated
1412 if (! empty ( $settings ['translate_srcimages'] )) {
1413 $imgNodesSrc = $xpath->query ( '//img[@src]' );
1414 foreach ( $imgNodesSrc as $imgNode ) {
1415 $srcValue = ltrim ( $imgNode->getAttribute ( 'src' ), '/' );
1416
1417 if ($matchQuotes) {
1418 $srcValue = str_ireplace ( '"', "'", $srcValue );
1419 }
1420
1421 $translatedSrcValue = null;
1422 foreach ( $altTranslationsArray as $originalText => $translatedText ) {
1423 $originalText = normalizeText($originalText);
1424 $srcValue = normalizeText($srcValue);
1425
1426 $originalText = ltrim ( $originalText, '/' );
1427 if ($caseInsensitive) {
1428 if (strcasecmp ( trim ( $originalText ), $srcValue ) === 0) {
1429 $translatedSrcValue = $translatedText;
1430 break;
1431 }
1432 } else {
1433 if (trim ( $originalText ) === $srcValue) {
1434 $translatedSrcValue = $translatedText;
1435 break;
1436 }
1437 }
1438 }
1439
1440 // If a translation was found, replace src and srcset
1441 if ($translatedSrcValue !== null) {
1442 // Replace the src attribute
1443 $imgNode->setAttribute ( 'src', $translatedSrcValue );
1444
1445 // Check if srcset exists and replace it with the translated src value
1446 if ($imgNode->hasAttribute ( 'srcset' )) {
1447 $imgNode->setAttribute ( 'srcset', $translatedSrcValue );
1448 }
1449 }
1450 }
1451 }
1452
1453 // Check if also iframes 'src' are enabled to be translated
1454 if (! empty ( $settings ['translate_srciframes'] )) {
1455 $iframeNodesSrc = $xpath->query ( '//iframe[@src]' );
1456 foreach ( $iframeNodesSrc as $iframeNode ) {
1457 $srcValue = ltrim ( $iframeNode->getAttribute ( 'src' ), '/' );
1458
1459 if ($matchQuotes) {
1460 $srcValue = str_ireplace ( '"', "'", $srcValue );
1461 }
1462
1463 $translatedSrcValue = null;
1464 foreach ( $altTranslationsArray as $originalText => $translatedText ) {
1465 $originalText = normalizeText($originalText);
1466 $srcValue = normalizeText($srcValue);
1467
1468 $originalText = ltrim ( $originalText, '/' );
1469 if ($caseInsensitive) {
1470 if (strcasecmp ( trim ( $originalText ), $srcValue ) === 0) {
1471 $translatedSrcValue = $translatedText;
1472 break;
1473 }
1474 } else {
1475 if (trim ( $originalText ) === $srcValue) {
1476 $translatedSrcValue = $translatedText;
1477 break;
1478 }
1479 }
1480 }
1481
1482 if ($translatedSrcValue !== null) {
1483 $iframeNode->setAttribute ( 'src', $translatedSrcValue );
1484 }
1485 }
1486 }
1487
1488 // Check if also videos 'src' are enabled to be translated
1489 if (! empty ( $settings ['translate_srcvideos'] )) {
1490 $videoNodesSrc = $xpath->query ( '//video[@src] | //video//source[@src]' );
1491 foreach ( $videoNodesSrc as $videoNode ) {
1492 $srcValue = ltrim ( $videoNode->getAttribute ( 'src' ), '/' );
1493
1494 if ($matchQuotes) {
1495 $srcValue = str_ireplace ( '"', "'", $srcValue );
1496 }
1497
1498 $translatedSrcValue = null;
1499 foreach ( $altTranslationsArray as $originalText => $translatedText ) {
1500 $originalText = normalizeText($originalText);
1501 $srcValue = normalizeText($srcValue);
1502
1503 $originalText = ltrim ( $originalText, '/' );
1504 if ($caseInsensitive) {
1505 if (strcasecmp ( trim ( $originalText ), $srcValue ) === 0) {
1506 $translatedSrcValue = $translatedText;
1507 break;
1508 }
1509 } else {
1510 if (trim ( $originalText ) === $srcValue) {
1511 $translatedSrcValue = $translatedText;
1512 break;
1513 }
1514 }
1515 }
1516
1517 if ($translatedSrcValue !== null) {
1518 $videoNode->setAttribute ( 'src', $translatedSrcValue );
1519 }
1520 }
1521 }
1522 }
1523
1524 // Translate iframe locale parameter (e.g. Stripe payment forms)
1525 if (! empty ( $settings ['translate_iframe_locale'] )) {
1526 $currentLangCode = strtolower(explode('-', $currentLanguage)[0]);
1527 $iframeNodes = $xpath->query ( '//iframe[@src]' );
1528 foreach ( $iframeNodes as $iframeNode ) {
1529 $src = $iframeNode->getAttribute ( 'src' );
1530 if (strpos ( $src, 'locale=' ) !== false) {
1531 $translatedSrc = preg_replace ( '/locale=[a-z]{2}/i', 'locale=' . $currentLangCode, $src );
1532 $iframeNode->setAttribute ( 'src', $translatedSrc );
1533 }
1534 }
1535 }
1536
1537 // Translate meta descriptions (standard, Open Graph, Twitter, Dublin Core)
1538 $metaDescriptions = $xpath->query(
1539 '//meta[
1540 (@name="description")
1541 or (@name="twitter:description")
1542 or (@property="og:description")
1543 or (@name="dc.description")
1544 ]'
1545 );
1546 foreach ($metaDescriptions as $meta) {
1547 $contentValue = $meta->getAttribute('content');
1548
1549 if ($matchQuotes) {
1550 $contentValue = str_ireplace('"', "'", $contentValue);
1551 }
1552
1553 foreach ($altTranslationsArray as $originalText => $translatedText) {
1554 $prev = $contentValue;
1555 if ($caseInsensitive) {
1556 $contentValue = str_ireplace(trim($originalText), $translatedText, $contentValue);
1557 } else {
1558 $contentValue = str_replace(trim($originalText), $translatedText, $contentValue);
1559 }
1560 if ($contentValue !== $prev) break;
1561 }
1562
1563 $meta->setAttribute('content', $contentValue);
1564 }
1565
1566 $ogTitleNodes = $xpath->query('//meta[@property="og:title"]');
1567 if ($ogTitleNodes->length > 0) {
1568 // Find the <title> tag to get the translated title
1569 $titleNodes = $xpath->query('//title');
1570
1571 if ($titleNodes->length > 0) {
1572 $titleNode = $titleNodes->item(0);
1573 $translatedTitle = $titleNode->textContent;
1574
1575 // Set og:title with the same value as <title>
1576 foreach ($ogTitleNodes as $ogTitleNode) {
1577 $ogTitleNode->setAttribute('content', trim($translatedTitle));
1578 }
1579
1580 // Set twitter:title with the same value as <title>
1581 $twitterTitleNodes = $xpath->query('//meta[@name="twitter:title"]');
1582 foreach ($twitterTitleNodes as $twitterTitleNode) {
1583 $twitterTitleNode->setAttribute('content', trim($translatedTitle));
1584 }
1585
1586 // Set Dublin Core (SEOPress) dc:title with the same value as <title>
1587 $dcTitleNodes = $xpath->query('//meta[@name="dc.title"]');
1588 foreach ($dcTitleNodes as $dcTitleNode) {
1589 $dcTitleNode->setAttribute('content', trim($translatedTitle));
1590 }
1591 }
1592 }
1593
1594 // Optionally, add an attribute to the <body> tag to signal that translations were applied.
1595 $bodyTag = $doc->getElementsByTagName ( 'body' )->item ( 0 );
1596 if ($bodyTag) {
1597 $bodyTag->setAttribute ( 'data-gptranslateskip', '1' );
1598 $bodyTag->setAttribute ( 'data-gptranslateoriginalalias', htmlspecialchars($row['pagelink'], ENT_QUOTES, 'UTF-8') );
1599 }
1600
1601 // Save the modified HTML and strip the XML processing instruction
1602 // that was injected as a UTF-8 encoding hint for DOMDocument.
1603 $html = $doc->saveHTML ();
1604 $html = preg_replace ( '/<\?xml[^>]*>\s*/i', '', $html );
1605
1606 // RESTORE protected script/style content (if protection was enabled)
1607 if (!empty($settings['protect_script_style'])) {
1608 // Restore scripts from meta markers
1609 $html = preg_replace_callback(
1610 '/<meta\s+data-gptranslate-script="([^"]+)"[^>]*>/i',
1611 function ($matches) use ($protectedScriptsStyles) {
1612 $placeholder = $matches[1];
1613 return isset($protectedScriptsStyles[$placeholder]) ? $protectedScriptsStyles[$placeholder] : $matches[0];
1614 },
1615 $html
1616 );
1617
1618 // Restore styles from meta markers
1619 $html = preg_replace_callback(
1620 '/<meta\s+data-gptranslate-style="([^"]+)"[^>]*>/i',
1621 function ($matches) use ($protectedScriptsStyles) {
1622 $placeholder = $matches[1];
1623 return isset($protectedScriptsStyles[$placeholder]) ? $protectedScriptsStyles[$placeholder] : $matches[0];
1624 },
1625 $html
1626 );
1627 }
1628
1629 // Decode HTML entities back into UTF-8 characters.
1630 $html = html_entity_decode ( $html, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
1631
1632 } catch ( Exception $e ) {
1633 // Handle exception if needed
1634 }
1635 } elseif ($settings['serverside_translations_method'] == 'simplehtmldom') {
1636 require_once plugin_dir_path(__FILE__) . 'simplehtmldom.php';
1637
1638 // Helper: checks if $child is inside (or is) $parent.
1639 function nodeIsInsideExcluded($child, $excludedNodes) {
1640 while ($child !== null) {
1641 foreach ($excludedNodes as $ex) {
1642 if ($child === $ex) {
1643 return true;
1644 }
1645 }
1646 $child = $child->parent;
1647 }
1648 return false;
1649 }
1650
1651 // Helper: checks if $node is inside (or is) any of the specified tags.
1652 function nodeIsInsideTags($node, $tagNames) {
1653 $tagNames = array_map('strtolower', $tagNames);
1654 while ($node !== null) {
1655 $tag = strtolower($node->tag ?? '');
1656 if (in_array($tag, $tagNames, true)) {
1657 return true;
1658 }
1659 $node = $node->parent;
1660 }
1661 return false;
1662 }
1663
1664 // Recursive function to process all text nodes.
1665 function processTextNodes($node, $excludedNodes, $translationsArray, $caseInsensitive, $matchQuotes, $incrementalEnabledPreserveNewTexts, $excludedWordsList, $excludedWordsReplacementMap, $settings, $originalLang, $translatedLang) {
1666 if ($node->tag === 'text') {
1667 if (! nodeIsInsideExcluded($node, $excludedNodes) && ! nodeIsInsideTags($node, ['script', 'style', 'noscript']) && trim($node->innertext) && $node->innertext != "\t") {
1668 $text = $node->innertext;
1669 if ($matchQuotes) {
1670 $text = str_ireplace('"', "'", $text);
1671 }
1672
1673 // Normalize once to avoid stripping spaces added by replacements
1674 $text = normalizeTextNoTrim($text);
1675
1676 // INCREMENTAL FIX: Use exact match only if incremental preserve new texts is enabled
1677 if ($incrementalEnabledPreserveNewTexts) {
1678 // Extract leading/trailing whitespace from original text
1679 preg_match('/^(\s*)(.*?)(\s*)$/s', $text, $wsMatches);
1680 $leadingWhitespace = $wsMatches[1] ?? '';
1681 $trailingWhitespace = $wsMatches[3] ?? '';
1682
1683 // Apply dictionary replacement for incremental matching (matches client-side behavior)
1684 $normalizedNodeText = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($text, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($text, $settings, $originalLang, $translatedLang);
1685 $found = false;
1686
1687 // Step 1: Exact match
1688 foreach ($translationsArray as $originalText => $translatedText) {
1689 $normalizedOrig = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang);
1690 if (trim($normalizedNodeText) === trim($normalizedOrig)) {
1691 $text = $leadingWhitespace . $translatedText . $trailingWhitespace;
1692 $found = true;
1693 break;
1694 }
1695 }
1696
1697 // Step 2: Excluded-words-aware fragment matching
1698 // Mirrors client-side wrap_excluded_words behavior: client creates SEPARATE text nodes
1699 // around excluded words, so DB stores fragments separately, NOT the full text.
1700 // Example: "Now i add a new text and modify the page" with 'text' excluded
1701 // Client creates 3 nodes: "Now i add a new ", "text" (preserved), " and modify the page"
1702 // DB stores: "Now i add a new " → "Ora aggiungo un nuovo ", " and modify the page" → " e modificare la pagina"
1703 // We split the text by excluded words, translate each fragment, preserve excluded words.
1704 if (!$found && !empty($excludedWordsList)) {
1705 $excludedPattern = '';
1706 foreach ($excludedWordsList as $excludedWord) {
1707 $excludedWord = trim($excludedWord);
1708 if (!empty($excludedWord)) {
1709 $excludedPattern .= ($excludedPattern ? '|' : '') . preg_quote($excludedWord, '/');
1710 }
1711 }
1712
1713 if (!empty($excludedPattern)) {
1714 // Split keeping excluded words as delimiters (PREG_SPLIT_DELIM_CAPTURE)
1715 $parts = preg_split(
1716 '/((?<!\w)(?:' . $excludedPattern . ')(?!\w))/' . ($caseInsensitive ? 'ui' : 'u'),
1717 $text,
1718 -1,
1719 PREG_SPLIT_DELIM_CAPTURE
1720 );
1721
1722 if (is_array($parts) && count($parts) > 1) {
1723 $rebuiltText = '';
1724 $anyTranslated = false;
1725
1726 foreach ($parts as $part) {
1727 if ($part === '') {
1728 continue;
1729 }
1730 // Check if this part is an excluded word - preserve as-is
1731 $isExcluded = false;
1732 $matchedExcludedWord = '';
1733 foreach ($excludedWordsList as $excludedWord) {
1734 if (strcasecmp(trim($part), trim($excludedWord)) === 0) {
1735 $isExcluded = true;
1736 $matchedExcludedWord = mb_strtolower(trim($excludedWord), 'UTF-8');
1737 break;
1738 }
1739 }
1740
1741 if ($isExcluded) {
1742 $replacement = $excludedWordsReplacementMap[$matchedExcludedWord] ?? '';
1743 if ($replacement !== '') {
1744 $rebuiltText .= $replacement;
1745 $anyTranslated = true;
1746 } else {
1747 $rebuiltText .= $part;
1748 }
1749 } else {
1750 // Try to find an exact match for this fragment in DB
1751 $normalizedPart = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($part, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($part, $settings, $originalLang, $translatedLang);
1752 $fragTranslated = false;
1753 foreach ($translationsArray as $originalText => $translatedText) {
1754 $normalizedOrig = $caseInsensitive ? mb_strtolower(normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang), 'UTF-8') : normalizeTextForIncremental($originalText, $settings, $originalLang, $translatedLang);
1755 if (trim($normalizedPart) !== '' && trim($normalizedPart) === trim($normalizedOrig)) {
1756 $rebuiltText .= $translatedText;
1757 $fragTranslated = true;
1758 $anyTranslated = true;
1759 break;
1760 }
1761 }
1762 if (!$fragTranslated) {
1763 // Fragment not found in DB - keep original
1764 $rebuiltText .= $part;
1765 }
1766 }
1767 }
1768
1769 if ($anyTranslated) {
1770 $text = $rebuiltText;
1771 $found = true;
1772 }
1773 }
1774 }
1775 }
1776 } else {
1777 // Normal pattern matching (standard behavior)
1778 // Apply translations without overriding longer translations
1779 $processedParts = [];
1780 foreach ($translationsArray as $originalText => $translatedText) {
1781 $originalText = normalizeText($originalText);
1782
1783 if ($caseInsensitive) {
1784 $text = preg_replace_callback(
1785 '/' . (preg_match('/^\w/u', trim($originalText)) ? '(?<!\w)' : '') . preg_quote(trim($originalText), '/') . '(?!\w)/ui',
1786 function ($matches) use ($translatedText, &$processedParts) {
1787 if (in_array($matches[0], $processedParts, true)) {
1788 return $matches[0];
1789 }
1790 $processedParts[] = $translatedText;
1791 return $translatedText;
1792 },
1793 $text
1794 );
1795 } else {
1796 $text = preg_replace_callback(
1797 '/' . (preg_match('/^\w/u', trim($originalText)) ? '(?<!\w)' : '') . preg_quote(trim($originalText), '/') . '(?!\w)/',
1798 function ($matches) use ($translatedText, &$processedParts) {
1799 if (in_array($matches[0], $processedParts, true)) {
1800 return $matches[0];
1801 }
1802 $processedParts[] = $translatedText;
1803 return $translatedText;
1804 },
1805 $text
1806 );
1807 }
1808 }
1809 }
1810 $node->innertext = $text;
1811 }
1812 } else {
1813 $tagLower = strtolower($node->tag ?? '');
1814 if (in_array($tagLower, ['script', 'style'])) {
1815 return;
1816 }
1817 if (isset($node->nodes) && is_array($node->nodes)) {
1818 foreach ($node->nodes as $child) {
1819 processTextNodes($child, $excludedNodes, $translationsArray, $caseInsensitive, $matchQuotes, $incrementalEnabledPreserveNewTexts, $excludedWordsList, $excludedWordsReplacementMap, $settings, $originalLang, $translatedLang);
1820 }
1821 }
1822 }
1823 }
1824
1825 // Main processing code.
1826 try {
1827 $translationsArray = json_decode($row['translations'], true) ?? [];
1828 $altTranslationsArray = json_decode($row['alt_translations'] ?? '', true) ?: [];
1829
1830 // Sort translations by descending key length
1831 uksort($translationsArray, function ($a, $b) {
1832 return strlen($b) - strlen($a);
1833 });
1834
1835 // Sort alt translations by descending key length
1836 uksort($altTranslationsArray, function ($a, $b) {
1837 return strlen($b) - strlen($a);
1838 });
1839
1840 // Flatten formatting tags regex
1841 if($flattenInnerFormattingTags) {
1842 // Flatten formatting tags in the body to improve matching if enabled
1843 function gp_flatten_inner_formatting_tags_simplehtmldom($htmlString, $tagsToRemove ) {
1844 // Complete body parsing
1845 $doc = gptranslate_simplehtmldom_str_get_html ( $htmlString );
1846 if (! $doc)
1847 return $htmlString;
1848
1849 $selector = implode ( ',', $tagsToRemove );
1850
1851 $found = $doc->find($selector);
1852 if (!empty($found)) {
1853 for ($i = count($found) - 1; $i >= 0; $i--) {
1854 $n = $found[$i];
1855 // Preserva elementi vuoti (es. <i> FontAwesome/icon fonts)
1856 if (trim(str_replace(['&nbsp;', "\xc2\xa0", "\xa0"], '', $n->plaintext)) === '') {
1857 continue;
1858 }
1859 // Preserva elementi che contengono form elements (select, input, textarea, button, ecc.)
1860 $interactiveTags = ['select', 'input', 'textarea', 'button', 'option', 'optgroup', 'fieldset', 'datalist', 'output', 'label', 'form', 'img', 'video', 'audio', 'canvas', 'svg', 'iframe'];
1861 $hasInteractive = false;
1862 foreach ($interactiveTags as $iTag) {
1863 if (!empty($n->find($iTag))) {
1864 $hasInteractive = true;
1865 break;
1866 }
1867 }
1868 if ($hasInteractive) {
1869 continue;
1870 }
1871 $n->outertext = $n->plaintext;
1872 }
1873 }
1874
1875 $out = $doc->save();
1876
1877 if (method_exists($doc, 'clear')) $doc->clear();
1878 unset($doc);
1879
1880 return $out;
1881 }
1882 $html = gp_flatten_inner_formatting_tags_simplehtmldom($html, $flattenInnerFormattingTagsToRemove);
1883 }
1884
1885 // PROTECT script/style content before SimpleHtmlDom parsing (if option enabled)
1886 $protectedScriptsStyles = [];
1887 if (!empty($settings['protect_script_style'])) {
1888 $protectCounter = 0;
1889 $html = preg_replace_callback(
1890 '/<script[^>]*>.*?<\/script>/is',
1891 function ($matches) use (&$protectedScriptsStyles, &$protectCounter) {
1892 $placeholder = "___GPTRANSLATE_SCRIPT_" . ($protectCounter++) . "___";
1893 $protectedScriptsStyles[$placeholder] = $matches[0];
1894 return $placeholder;
1895 },
1896 $html
1897 );
1898 $html = preg_replace_callback(
1899 '/<style[^>]*>.*?<\/style>/is',
1900 function ($matches) use (&$protectedScriptsStyles, &$protectCounter) {
1901 $placeholder = "___GPTRANSLATE_STYLE_" . ($protectCounter++) . "___";
1902 $protectedScriptsStyles[$placeholder] = $matches[0];
1903 return $placeholder;
1904 },
1905 $html
1906 );
1907 }
1908 $htmlObj = gptranslate_simplehtmldom_str_get_html($html);
1909
1910 $cssSelectorLeafnodesExcluded = str_ireplace(
1911 '"',
1912 '',
1913 trim(trim(preg_replace('/,+/', ',', str_ireplace(["\r", "\n"], ",", $settings['css_selector_serverside_leafnodes_excluded'] ?? ''))), ',')
1914 );
1915
1916 $excludedNodes = [];
1917 if (! empty($cssSelectorLeafnodesExcluded)) {
1918 $selectors = explode(',', $cssSelectorLeafnodesExcluded);
1919 foreach ($selectors as $selector) {
1920 $selector = trim($selector);
1921 if (! empty($selector)) {
1922 $foundNodes = $htmlObj->find($selector);
1923 foreach ($foundNodes as $node) {
1924 $excludedNodes[] = $node;
1925 }
1926 }
1927 }
1928 }
1929
1930 $caseInsensitive = ! empty($settings['serverside_translations_caseinsensitive']);
1931 $matchQuotes = ! empty($settings['serverside_translations_matchquotes']);
1932
1933 processTextNodes($htmlObj, $excludedNodes, $translationsArray, $caseInsensitive, $matchQuotes, $incrementalEnabledPreserveNewTexts, $excludedWordsList, $excludedWordsReplacementMap, $settings, $originalLang, $translatedLang);
1934
1935 // Replace page links in <a href="..."> tags based on translated aliases
1936 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
1937 $exclusions = $settings['rewrite_page_links_exclusions'] ?? '';
1938 $page_exclusions = $settings['page_exclusions'] ?? '';
1939 foreach ($htmlObj->find('a[href]') as $linkNode) {
1940 $href = $linkNode->href;
1941
1942 // Check if link should be excluded from rewriting
1943 if (gptranslate_should_exclude_link($href, $exclusions, $page_exclusions)) {
1944 continue;
1945 }
1946
1947 // Decode href attribute (WordPress-style, KEEP trailing slash)
1948 $decodedHref = rawurldecode(html_entity_decode($href, ENT_QUOTES, 'UTF-8'));
1949
1950 // Extract hash/fragment if present
1951 $hashFragment = '';
1952 if (strpos($decodedHref, '#') !== false) {
1953 $parts = explode('#', $decodedHref, 2);
1954 $decodedHref = $parts[0]; // URL without hash
1955 $hashFragment = '#' . $parts[1]; // Save the hash
1956 }
1957
1958 // Extract query string if ignore_querystring is enabled
1959 $queryString = '';
1960 if ($settings['ignore_querystring'] == 1 && strpos($decodedHref, '?') !== false) {
1961 $parts = explode('?', $decodedHref, 2);
1962 $decodedHref = $parts[0];
1963 $queryString = '?' . $parts[1];
1964 }
1965 $originalPathBeforeNormalization = $decodedHref;
1966
1967 $decodedHref = gpt_trailingslashit_url($decodedHref);
1968
1969 $translatedAlias = null;
1970
1971 // Cerca prima negli URL assoluti
1972 if (isset($translatedAliasesMap[$decodedHref])) {
1973 $translatedAlias = $translatedAliasesMap[$decodedHref];
1974 }
1975 // Poi cerca negli URL relativi
1976 elseif (isset($translatedAliasesRelativeMap[$decodedHref])) {
1977 $translatedAlias = $translatedAliasesRelativeMap[$decodedHref];
1978 }
1979
1980 // Se trovato un alias tradotto, sostituisci
1981 if ($translatedAlias) {
1982 // Aggiungi data-originalhref se non presente
1983 if (!isset($linkNode->{'data-originalhref'})) {
1984 $linkNode->{'data-originalhref'} = $originalPathBeforeNormalization . $queryString . $hashFragment;
1985 }
1986 $linkNode->href = $translatedAlias . $queryString . $hashFragment;
1987 }
1988 }
1989 }
1990
1991 // Replace form action URLs: add language prefix and apply translated aliases
1992 if (!empty($settings['rewrite_form_actions'])) {
1993 $siteUrl = trailingslashit(get_site_url());
1994 $siteHost = parse_url($siteUrl, PHP_URL_HOST);
1995 $knownLangs = (isset($settings['languages']) && is_array($settings['languages'])) ? array_map('strtolower', $settings['languages']) : [$originalLang, $translatedLang];
1996 foreach ($htmlObj->find('form[action]') as $formNode) {
1997 $action = $formNode->action;
1998 $decodedAction = rawurldecode(html_entity_decode($action, ENT_QUOTES, 'UTF-8'));
1999
2000 // Skip non-rewritable actions
2001 if (preg_match('/^(mailto:|tel:|javascript:|#)/i', $decodedAction)) continue;
2002 if (preg_match('/\.php($|\?|\#)/i', $decodedAction)) continue;
2003 if (preg_match('/^https?:\/\//i', $decodedAction) && parse_url($decodedAction, PHP_URL_HOST) !== $siteHost) continue;
2004
2005 $queryString = '';
2006 if ($settings['ignore_querystring'] == 1 && strpos($decodedAction, '?') !== false) {
2007 $parts = explode('?', $decodedAction, 2);
2008 $decodedAction = $parts[0];
2009 $queryString = '?' . $parts[1];
2010 }
2011 $originalActionFull = $decodedAction . $queryString;
2012
2013 // Extract relative path
2014 $actionPath = $decodedAction;
2015 $isAbsolute = false;
2016 if (preg_match('/^https?:\/\//i', $actionPath)) {
2017 $isAbsolute = true;
2018 $actionPath = parse_url($actionPath, PHP_URL_PATH) ?: '/';
2019 }
2020
2021 // Add/replace language prefix
2022 $pathParts = explode('/', $actionPath);
2023 $langIndex = 1;
2024 if (isset($pathParts[$langIndex]) && $pathParts[$langIndex] === 'index.php') $langIndex = 2;
2025 if (!empty($settings['subfolder_installation'])) $langIndex = 2;
2026
2027 if (isset($pathParts[$langIndex]) && in_array(strtolower($pathParts[$langIndex]), $knownLangs)) {
2028 $pathParts[$langIndex] = $translatedLang;
2029 } else {
2030 array_splice($pathParts, $langIndex, 0, [$translatedLang]);
2031 }
2032
2033 $rebuiltPath = '/' . implode('/', array_filter($pathParts, function($p) { return $p !== ''; }));
2034 $rebuiltPath = gpt_trailingslashit_url($rebuiltPath);
2035 $computedAction = $isAbsolute ? (rtrim($siteUrl, '/') . $rebuiltPath) : $rebuiltPath;
2036 $computedAction = gpt_trailingslashit_url($computedAction);
2037
2038 // Try alias match
2039 $translatedAlias = null;
2040 if (!empty($translatedAliasesMap) || !empty($translatedAliasesRelativeMap)) {
2041 if (isset($translatedAliasesMap[$computedAction])) {
2042 $translatedAlias = $translatedAliasesMap[$computedAction];
2043 } elseif (isset($translatedAliasesRelativeMap[$rebuiltPath])) {
2044 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPath];
2045 } elseif (!$isAbsolute && !empty($settings['subfolder_installation'])) {
2046 // Fallback: form action is relative and includes subfolder path
2047 // Map keys strip the subfolder, so retry after stripping it
2048 $siteBasePath = rtrim(parse_url(rtrim($siteUrl, '/'), PHP_URL_PATH) ?: '', '/');
2049 if ($siteBasePath !== '' && strpos($rebuiltPath, $siteBasePath . '/') === 0) {
2050 $rebuiltPathNoSubfolder = substr($rebuiltPath, strlen($siteBasePath));
2051 if (isset($translatedAliasesRelativeMap[$rebuiltPathNoSubfolder])) {
2052 $translatedAlias = $translatedAliasesRelativeMap[$rebuiltPathNoSubfolder];
2053 }
2054 }
2055 }
2056 }
2057
2058 $finalAction = $translatedAlias ? ($translatedAlias . $queryString) : ($computedAction . $queryString);
2059
2060 if (!isset($formNode->{'data-originalaction'})) {
2061 $formNode->{'data-originalaction'} = $originalActionFull;
2062 }
2063 $formNode->action = $finalAction;
2064 }
2065 }
2066
2067 // Check if also images 'alt' are enabled to be translated
2068 if (! empty($settings['translate_altimages'])) {
2069 foreach ($htmlObj->find('img[alt]') as $imgNode) {
2070 $altText = $imgNode->alt;
2071 if ($matchQuotes) {
2072 $altText = str_ireplace('"', "'", $altText);
2073 }
2074 foreach ($altTranslationsArray as $originalText => $translatedText) {
2075 $originalText = normalizeText($originalText);
2076 $altText = normalizeText($altText);
2077
2078 $prevAltText = $altText;
2079 if ($caseInsensitive) {
2080 $altText = str_ireplace(trim($originalText), $translatedText, $altText);
2081 } else {
2082 $altText = str_replace(trim($originalText), $translatedText, $altText);
2083 }
2084 if ($altText !== $prevAltText) {
2085 break;
2086 }
2087 }
2088 $imgNode->alt = $altText;
2089 }
2090
2091 foreach ($htmlObj->find('img[title]') as $imgNode) {
2092 $titleText = $imgNode->title;
2093 if ($matchQuotes) {
2094 $titleText = str_ireplace('"', "'", $titleText);
2095 }
2096 foreach ($altTranslationsArray as $originalText => $translatedText) {
2097 $originalText = normalizeText($originalText);
2098 $titleText = normalizeText($titleText);
2099
2100 $prevTitleText = $titleText;
2101 if ($caseInsensitive) {
2102 $titleText = str_ireplace(trim($originalText), $translatedText, $titleText);
2103 } else {
2104 $titleText = str_replace(trim($originalText), $translatedText, $titleText);
2105 }
2106 if ($titleText !== $prevTitleText) {
2107 break;
2108 }
2109 }
2110 $imgNode->title = $titleText;
2111 }
2112
2113 // Check if also images 'src' are enabled to be translated
2114 if (! empty($settings['translate_srcimages'])) {
2115 foreach ($htmlObj->find('img[src]') as $imgNode) {
2116 $srcValue = ltrim($imgNode->src, '/');
2117
2118 if ($matchQuotes) {
2119 $srcValue = str_ireplace('"', "'", $srcValue);
2120 }
2121
2122 $translatedSrcValue = null;
2123 foreach ($altTranslationsArray as $originalText => $translatedText) {
2124 $originalText = normalizeText($originalText);
2125 $srcValue = normalizeText($srcValue);
2126
2127 $originalText = ltrim($originalText, '/');
2128 if ($caseInsensitive) {
2129 if (strcasecmp(trim($originalText), $srcValue) === 0) {
2130 $translatedSrcValue = $translatedText;
2131 break;
2132 }
2133 } else {
2134 if (trim($originalText) === $srcValue) {
2135 $translatedSrcValue = $translatedText;
2136 break;
2137 }
2138 }
2139 }
2140
2141 // If a translation was found, replace src and srcset
2142 if ($translatedSrcValue !== null) {
2143 // Replace the src attribute
2144 $imgNode->src = $translatedSrcValue;
2145
2146 // Check if srcset exists and replace it with the translated src value
2147 if (isset($imgNode->srcset)) {
2148 $imgNode->srcset = $translatedSrcValue;
2149 }
2150 }
2151 }
2152 }
2153
2154 // Check if also iframes 'src' are enabled to be translated
2155 if (! empty($settings['translate_srciframes'])) {
2156 foreach ($htmlObj->find('iframe[src]') as $iframeNode) {
2157 $srcValue = ltrim($iframeNode->src, '/');
2158
2159 if ($matchQuotes) {
2160 $srcValue = str_ireplace('"', "'", $srcValue);
2161 }
2162
2163 $translatedSrcValue = null;
2164 foreach ($altTranslationsArray as $originalText => $translatedText) {
2165 $originalText = normalizeText($originalText);
2166 $srcValue = normalizeText($srcValue);
2167
2168 $originalText = ltrim($originalText, '/');
2169 if ($caseInsensitive) {
2170 if (strcasecmp(trim($originalText), $srcValue) === 0) {
2171 $translatedSrcValue = $translatedText;
2172 break;
2173 }
2174 } else {
2175 if (trim($originalText) === $srcValue) {
2176 $translatedSrcValue = $translatedText;
2177 break;
2178 }
2179 }
2180 }
2181
2182 if ($translatedSrcValue !== null) {
2183 $iframeNode->src = $translatedSrcValue;
2184 }
2185 }
2186 }
2187
2188 // Check if also videos 'src' are enabled to be translated
2189 if (! empty($settings['translate_srcvideos'])) {
2190 foreach ($htmlObj->find('video[src], video source[src]') as $videoNode) {
2191 $srcValue = ltrim($videoNode->src, '/');
2192
2193 if ($matchQuotes) {
2194 $srcValue = str_ireplace('"', "'", $srcValue);
2195 }
2196
2197 $translatedSrcValue = null;
2198 foreach ($altTranslationsArray as $originalText => $translatedText) {
2199 $originalText = normalizeText($originalText);
2200 $srcValue = normalizeText($srcValue);
2201
2202 $originalText = ltrim($originalText, '/');
2203 if ($caseInsensitive) {
2204 if (strcasecmp(trim($originalText), $srcValue) === 0) {
2205 $translatedSrcValue = $translatedText;
2206 break;
2207 }
2208 } else {
2209 if (trim($originalText) === $srcValue) {
2210 $translatedSrcValue = $translatedText;
2211 break;
2212 }
2213 }
2214 }
2215
2216 if ($translatedSrcValue !== null) {
2217 $videoNode->src = $translatedSrcValue;
2218 }
2219 }
2220 }
2221 }
2222
2223 // Translate iframe locale parameter (e.g. Stripe payment forms)
2224 if (! empty($settings['translate_iframe_locale'])) {
2225 $currentLangCode = strtolower(explode('-', $currentLanguage)[0]);
2226 foreach ($htmlObj->find('iframe[src]') as $iframeNode) {
2227 $src = $iframeNode->src;
2228 if (strpos($src, 'locale=') !== false) {
2229 $iframeNode->src = preg_replace('/locale=[a-z]{2}/i', 'locale=' . $currentLangCode, $src);
2230 }
2231 }
2232 }
2233
2234 // Translate <meta name="description">
2235 foreach ($htmlObj->find('meta[name=description], meta[name=twitter:description], meta[property=og:description], meta[name=dc.description]') as $metaNode) {
2236 $contentValue = $metaNode->content;
2237
2238 // DECODIFICA HTML entities PRIMA del confronto
2239 $contentValue = html_entity_decode($contentValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
2240
2241 if ($matchQuotes) {
2242 $contentValue = str_ireplace('"', "'", $contentValue);
2243 }
2244
2245 foreach ($altTranslationsArray as $originalText => $translatedText) {
2246 $prev = $contentValue;
2247 if ($caseInsensitive) {
2248 $contentValue = str_ireplace(trim($originalText), $translatedText, $contentValue);
2249 } else {
2250 $contentValue = str_replace(trim($originalText), $translatedText, $contentValue);
2251 }
2252 if ($contentValue !== $prev) break;
2253 }
2254
2255 // RI-ENCODIFICA per l'HTML output (sicurezza)
2256 $contentValue = htmlspecialchars($contentValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
2257
2258 $metaNode->content = $contentValue;
2259 }
2260
2261 foreach ($htmlObj->find('meta[property=og:title]') as $metaOgTitleNode) {
2262 // Find the <title> tag to get the translated title
2263 $titleNode = $htmlObj->find('title', 0);
2264
2265 if ($titleNode) {
2266 // Get the translated title text
2267 $translatedTitle = $titleNode->plaintext;
2268
2269 // Set og:title with the same value as <title>
2270 $metaOgTitleNode->content = trim($translatedTitle);
2271 }
2272 }
2273
2274 // Translate <meta name="twitter:title"> with the same value as <title> tag
2275 foreach ($htmlObj->find('meta[name=twitter:title]') as $metaTwitterTitleNode) {
2276 // Find the <title> tag to get the translated title
2277 $titleNode = $htmlObj->find('title', 0);
2278
2279 if ($titleNode) {
2280 // Get the translated title text
2281 $translatedTitle = $titleNode->plaintext;
2282
2283 // Set twitter:title with the same value as <title>
2284 $metaTwitterTitleNode->content = trim($translatedTitle);
2285 }
2286 }
2287
2288 // Translate <meta name="dc:title"> (Dublin Core - SEOPress) with the same value as <title> tag
2289 foreach ($htmlObj->find('meta[name=dc.title]') as $metaDcTitleNode) {
2290 // Find the <title> tag to get the translated title
2291 $titleNode = $htmlObj->find('title', 0);
2292
2293 if ($titleNode) {
2294 // Get the translated title text
2295 $translatedTitle = $titleNode->plaintext;
2296
2297 // Set dc:title with the same value as <title>
2298 $metaDcTitleNode->content = trim($translatedTitle);
2299 }
2300 }
2301
2302 if ($bodyElement = $htmlObj->find('body', 0)) {
2303 $bodyElement->setAttribute('data-gptranslateskip', '1');
2304 $bodyElement->setAttribute ( 'data-gptranslateoriginalalias', htmlspecialchars($row['pagelink'], ENT_QUOTES, 'UTF-8') );
2305 }
2306
2307 $modifiedHtml = $htmlObj->save();
2308 $modifiedHtml = html_entity_decode($modifiedHtml, ENT_QUOTES | ENT_HTML5, 'UTF-8');
2309
2310 // RESTORE protected script/style content (if protection was enabled)
2311 if (!empty($settings['protect_script_style'])) {
2312 foreach ($protectedScriptsStyles as $placeholder => $originalContent) {
2313 $modifiedHtml = str_replace($placeholder, $originalContent, $modifiedHtml);
2314 }
2315 }
2316 $html = $modifiedHtml;
2317 } catch (Exception $e) {
2318 // Handle exceptions as needed
2319 }
2320 } elseif ($settings['serverside_translations_method'] == 'strireplace') {
2321 // Solution 3: simplest approach, unconditional str_ireplace that could cause unintentional replacements
2322 try {
2323 $translationsArray = json_decode($row['translations'], true) ?? [];
2324 $altTranslationsArray = json_decode($row['alt_translations'] ?? '', true) ?: [];
2325
2326 // Do body page translations replacements
2327 uksort($translationsArray, function ($a, $b) {
2328 return strlen($b) - strlen($a);
2329 });
2330
2331 $caseInsensitive = !empty($settings['serverside_translations_caseinsensitive']);
2332 $matchQuotes = !empty($settings['serverside_translations_matchquotes']);
2333
2334 foreach ($translationsArray as $originalText => $translatedText) {
2335 $originalText = normalizeText($originalText);
2336 $html = normalizeText($html);
2337
2338 if ($caseInsensitive) {
2339 $html = str_ireplace(trim($originalText), $translatedText, $html);
2340
2341 // Check also if both single quotes or double quotes should be checked to replace
2342 if ($matchQuotes && strpos($originalText, "'") !== false) {
2343 $originalTextAlt = str_ireplace("'", '"', $originalText);
2344 $html = str_ireplace(trim($originalTextAlt), $translatedText, $html);
2345 }
2346 } else {
2347 $html = str_replace(trim($originalText), $translatedText, $html);
2348
2349 if ($matchQuotes && strpos($originalText, "'") !== false) {
2350 $originalTextAlt = str_replace("'", '"', $originalText);
2351 $html = str_replace(trim($originalTextAlt), $translatedText, $html);
2352 }
2353 }
2354 }
2355
2356 // Check if also images 'alt' are enabled to be translated
2357 if (!empty($settings['translate_altimages'])) {
2358 foreach ($altTranslationsArray as $originalAlt => $translatedAlt) {
2359 if ($matchQuotes) {
2360 $originalAlt = str_ireplace('"', "'", $originalAlt);
2361 }
2362 if ($caseInsensitive) {
2363 $html = str_ireplace('alt="' . trim($originalAlt) . '"', 'alt="' . $translatedAlt . '"', $html);
2364 $html = str_ireplace("alt='" . trim($originalAlt) . "'", "alt='" . $translatedAlt . "'", $html);
2365 } else {
2366 $html = str_replace('alt="' . trim($originalAlt) . '"', 'alt="' . $translatedAlt . '"', $html);
2367 $html = str_replace("alt='" . trim($originalAlt) . "'", "alt='" . $translatedAlt . "'", $html);
2368 }
2369 }
2370 }
2371
2372 $html = str_ireplace('<body', '<body data-gptranslateskip="1" data-gptranslateoriginalalias="' . $row['pagelink'] . '"', $html);
2373
2374 } catch (Exception $e) {
2375 // Handle exceptions as needed
2376 }
2377 }
2378
2379 return $html;
2380 } );
2381 } );
2382