Модуль blog_ai_translate расширяет Drupal AI Translate и сохраняет исходную HTML-разметку, атрибуты и ссылки при переводе материалов.
Проблема
При переводе модель получала текст вместе с HTML-разметкой и могла переместить теги относительно текста, изменить вложенность или оставить часть текста за пределами абзаца. Из-за этого нарушалось отображение статьи, а ссылки становились некорректными. Прежняя проверка сравнивала только последовательность тегов и не обнаруживала, что текст переместился относительно них.
Что реализовано
Сервис SafeTextTranslator декорирует стандартный сервис ai_translate.text_translator. Поэтому защита выполняется в существующем потоке обычного и повторного перевода.
Для абзацев и заголовков код заменяет inline-теги уникальными маркерами, а границы блоков — отдельными маркерами. Модель получает текст и маркеры, но не сами HTML-теги и их атрибуты. В частности, URL из href и значения src не попадают в запрос и не проходят через фильтр ссылок ответа.
После перевода код проверяет наличие каждого маркера ровно один раз и в исходном порядке. Затем он восстанавливает границы блоков, точные исходные теги и их атрибуты. Если маркер изменён или потерян либо модель добавила HTML-тег, сохраняется исходная разметка без изменений.
Регистрация сервиса
В blog_ai_translate.services.yml переводчик регистрируется как декоратор исходного сервиса:
services:
blog_ai_translate.text_translator:
class: Drupal\blog_ai_translate\SafeTextTranslator
decorates: ai_translate.text_translator
arguments:
- '@blog_ai_translate.text_translator.inner'
Код защиты HTML
SafeTextTranslator.php: маршрут перевода 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: поиск абзацев и заголовков
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: маркеры, перевод и восстановление исходных тегов
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: проверка маркеров
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;
}
Проверка
Смоделированный перевод подтвердил, что HTML-теги и URL не передаются провайдеру, а затем восстанавливаются в исходном виде. После повторного перевода на английский проверено совпадение всех 90 исходных HTML-тегов и сохранение всех трёх исходных URL.
Фрагменты кода взяты из файлов проекта. HTML использует элементы, разрешённые форматом Drupal basic_html, включая highlight-js; фрагмент можно импортировать в поле с этим форматом.