Drupal AI Translate: Preserving HTML Markup During Machine Translation

Dezvoltatorul lucrează cu cod și traduceri pe laptop.

The blog_ai_translate module extends Drupal AI Translate and preserves the original HTML markup, attributes, and links when translating content.

Problem

During translation, the model received text together with HTML markup. It could move tags relative to the text, change their nesting, or leave part of the text outside its paragraph. This broke the article layout and could corrupt links. The earlier check compared only the sequence of tags, so it missed cases where text moved around them.

Implementation

The SafeTextTranslator decorates the standard service ai_translate.text_translator. This protects both first-time and repeat translations in the existing translation flow.

The code replaces inline tags with unique markers and block boundaries with separate markers. The model receives the text and markers, but not the HTML tags or their attributes. In particular, URLs in href and values in src are kept out of the prompt and are not processed by the response link filter.

After translation, the code checks that every marker appears exactly once and in its original order. It then restores the block boundaries, original tags, and their exact attributes. If a marker changes or goes missing, or the model adds an HTML tag, the original markup is kept unchanged.

Service registration

In blog_ai_translate.services.yml the translator is registered as a decorator of the original service:

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 protection code

SafeTextTranslator.php: HTML translation flow

  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: block translation

  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: tag protection and restoration

  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: marker validation

  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;
  }

Verification

A simulated translation confirmed that HTML tags and URLs are withheld from the provider and then restored exactly. A subsequent translation into English confirmed that all 90 original HTML tags match and all three original URLs are preserved.

The code excerpts come from the project files. The HTML uses elements allowed by Drupal’s basic_html, including highlight-js; you can import this content into a field using that format.

Modulul blog_ai_translate