Modulul blog_ai_translate extinde Drupal AI Translate și păstrează marcajul HTML original, atributele și linkurile la traducerea conținutului.
Problema
În timpul traducerii, modelul primea textul împreună cu marcajul HTML și putea muta etichetele în raport cu textul, modifica imbricarea sau lăsa o parte din text în afara paragrafului. Acest lucru afecta afișarea articolului, iar linkurile deveneau incorecte. Verificarea anterioară compara doar secvența etichetelor și nu detecta mutarea textului în raport cu acestea.
Ce a fost implementat
Serviciul SafeTextTranslator decorează serviciul standard ai_translate.text_translator. Astfel, protecția funcționează în fluxul existent atât pentru traducerea obișnuită, cât și pentru cea repetată.
Pentru paragrafe și titluri, codul înlocuiește etichetele inline cu marcatori unici, iar limitele blocurilor — cu marcatori separați. Modelul primește textul și marcatorii, dar nu etichetele HTML și atributele acestora. În special, URL-urile din href și valorile src nu sunt incluse în solicitare și nu trec prin filtrul de linkuri al răspunsului.
După traducere, codul verifică dacă fiecare marcator apare exact o dată și în ordinea inițială. Apoi restabilește limitele blocurilor, etichetele originale exacte și atributele acestora. Dacă un marcator este modificat sau pierdut ori dacă modelul adaugă o etichetă HTML, marcajul original este păstrat neschimbat.
Înregistrarea serviciului
În blog_ai_translate.services.yml, traducătorul este înregistrat ca decorator al serviciului original:
services:
blog_ai_translate.text_translator:
class: Drupal\blog_ai_translate\SafeTextTranslator
decorates: ai_translate.text_translator
arguments:
- '@blog_ai_translate.text_translator.inner'
Codul de protecție HTML
SafeTextTranslator.php: ruta pentru traducerea HTML
public function translateContent(
string $input_text,
LanguageInterface $langTo,
?LanguageInterface $langFrom = NULL,
array $context = [],
): string {
$protect_term = in_array($langTo->getId(), ['en', 'ro'], TRUE);
$prepared = $protect_term
? $this->replaceTermInText($input_text)
: $input_text;
if (!str_contains($prepared, '<')) {
$result = $this->inner->translateContent(
$prepared, $langTo, $langFrom, $context,
);
return $protect_term ? $this->replaceTermInText($result) : $result;
}
// Never send source HTML to the provider. Its hostname filter parses
// generated HTML and may normalize broken markup before we can restore it.
$preserved = $this->translateBlocks(
$input_text, $langTo, $langFrom, $context,
);
return $protect_term
? $this->replaceTermInText($preserved)
: $preserved;
}
SafeTextTranslator.php: căutarea paragrafelor și titlurilor
private function translateBlocks(
string $source,
LanguageInterface $lang_to,
?LanguageInterface $lang_from,
array $context,
): string {
preg_match_all(
'/<(p|h[1-6])\b[^>]*>.*?<\/\1>/is',
$source,
$matches,
PREG_OFFSET_CAPTURE,
);
$blocks = $matches[0] ?? [];
if ($blocks !== []) {
$gaps = [];
$cursor = 0;
foreach ($blocks as [$block, $offset]) {
$gap = substr($source, $cursor, $offset - $cursor);
if (trim($gap) !== '') {
return $this->translateSegments(
$source, $lang_to, $lang_from, $context,
);
}
$gaps[] = $gap;
$cursor = $offset + strlen($block);
}
$tail = substr($source, $cursor);
if (trim($tail) === '') {
$gaps[] = $tail;
return $this->translateMarkupBlocks(
array_column($blocks, 0), $gaps, $source,
$lang_to, $lang_from, $context,
);
}
}
return $this->translateSegments(
$source, $lang_to, $lang_from, $context,
);
}
SafeTextTranslator.php: marcatori, traducere și restaurarea etichetelor originale
private function translateMarkupBlocks(
array $blocks,
array $gaps,
string $source,
LanguageInterface $lang_to,
?LanguageInterface $lang_from,
array $context,
): string {
$token_prefix = 'BLOGHTML' . strtoupper(substr(hash('sha256', $source), 0, 12));
$all_tokens = [];
$block_tag_tokens = [];
$combined = '';
foreach ($blocks as $block_index => $block) {
if (!preg_match(
'/^(<(p|h[1-6])\b[^>]*>)(.*)(<\/\2>)$/is',
$block,
$parts,
)) {
return $source;
}
$inline_tokens = [];
$inline_tags = [];
$content = preg_replace_callback(
'/<[^>]+>/u',
static function (array $match) use (
&$all_tokens,
&$inline_tokens,
&$inline_tags,
$token_prefix,
): string {
$token = $token_prefix . 'TAG' . count($all_tokens) . 'END';
$all_tokens[] = $token;
$inline_tokens[] = $token;
$inline_tags[] = $match[0];
return $token;
},
$parts[3],
);
if ($content === NULL) {
return $source;
}
$block_token = $token_prefix . 'BLOCK' . $block_index . 'END';
$all_tokens[] = $block_token;
$block_tag_tokens[] = [
'open' => $parts[1],
'close' => $parts[4],
'content_tokens' => $inline_tokens,
'inline_tags' => $inline_tags,
'block_token' => $block_token,
];
$combined .= $content . $block_token;
}
$translated = $this->translateText(
$combined, $lang_to, $lang_from, $context,
);
if (!$this->containsTokensInOrder($translated, $all_tokens)
|| preg_match('/<\/?[a-z][^>]*>/iu', $translated)) {
return $source;
}
$cursor = 0;
$result = $gaps[0];
foreach ($block_tag_tokens as $block_index => $block_info) {
$boundary_position = strpos(
$translated, $block_info['block_token'], $cursor,
);
if ($boundary_position === FALSE) {
return $source;
}
$block_text = str_replace(
$block_info['content_tokens'],
$block_info['inline_tags'],
substr($translated, $cursor, $boundary_position - $cursor),
);
$cursor = $boundary_position + strlen($block_info['block_token']);
$result .= $block_info['open'] . $block_text . $block_info['close'];
$result .= $gaps[$block_index + 1] ?? '';
}
return $result;
}
SafeTextTranslator.php: verificarea marcatorilor
private function containsTokensInOrder(string $text, array $tokens): bool {
$last_position = -1;
foreach ($tokens as $token) {
if (substr_count($text, $token) !== 1) {
return FALSE;
}
$position = strpos($text, $token);
if ($position === FALSE || $position <= $last_position) {
return FALSE;
}
$last_position = $position;
}
return TRUE;
}
Verificare
O traducere simulată a confirmat că etichetele HTML și URL-urile nu sunt transmise furnizorului, iar apoi sunt restaurate în forma inițială. După retraducerea în engleză, s-a verificat concordanța tuturor celor 90 de etichete HTML originale și păstrarea tuturor celor trei URL-uri originale.
Fragmentele de cod sunt preluate din fișierele proiectului. HTML-ul folosește elementele permise de formatul Drupal basic_html, inclusiv highlight-js; fragmentul poate fi importat într-un câmp care folosește acest format.