Drupal AI Translate: сохранение HTML-разметки при машинном переводе

Dezvoltatorul lucrează cu cod și traduceri pe laptop.

Модуль 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; фрагмент можно импортировать в поле с этим форматом.

Modulul blog_ai_translate