Skip to content

Rich text

The problem with markup

A rich-text field is not plain text. Send this to a translation API as one string and you get back a paragraph whose tags have been stripped, mangled, or translated as if they were words:

<h2>Le cacao</h2>
<p>Un secteur <strong>strategique</strong>, mais <em>fragile</em>.</p>

Splitting it yourself is not better: translating Un secteur, strategique and , mais separately gives three fragments with no grammar between them. Word order changes between languages; a fragment does not know that.

And cutting the document into pieces that fit the provider's request limit only works until the document is one piece. This is a single top-level element:

<figure class="table"><table><tbody>
  <tr><td>Cacao</td><td>450&nbsp;kg/ha</td></tr>
  ...four hundred more rows...
</tbody></table></figure>

There is nowhere to cut it without breaking the table in half. Every editor produces documents shaped like this — a <table>, a <figure>, a <div> wrapper around the whole body. That is why django-traduire does not cut documents at all.

What django-traduire does

The document is parsed once, and planned: structure on one side, prose on the other. Only the prose is ever sent.

  • Structural tags open a translation unit and are never sent to the provider: <h1>…<h6>, <p>, <div>, <section>, <article>, <ul>, <ol>, <li>, <table>, <tr>, <td>, <th>, <figure>, <figcaption>, <blockquote>, <details>, and the rest of the block-level set. A heading is translated as a heading, not as the first words of the paragraph below it — and a table cell is its own request.
  • Inline tags stay inside the sentence: <strong>, <em>, <b>, <i>, <a>, <span>, <br>, <img>… The sentence is sent whole, with each inline tag standing in as a numbered placeholder, and the tags are put back around the translated words.
  • Every request fits the provider's budget by construction. A cell, a heading or a paragraph longer than one request is cut further on paragraph, then sentence, then word boundaries — never through a tag, and never through a placeholder.
  • Code is never touched: <script>, <style>, <code>, <pre>, <kbd>, <samp>, <var>, <svg>, <math>, <template>, <iframe>, <canvas>, <noscript> and everything inside them is copied through untouched.
  • Template syntax is never touched either: {{ user.name }}, {% if x %} and {# … #} inside prose are set aside like markup.
  • Attributes, entities and whitespace are preserved exactly as written, &nbsp; and &shy; included.

Because the structure is never handed to a provider, document size stops mattering. A 400-row table, a table nested inside a table, a thirty-level <div> stack and a 20 000-character article all work the same way.

<!-- source, fr -->
<h2>Rendements par culture</h2>
<figure class="table"><table>
  <thead><tr><th scope="col">Culture</th><th scope="col">Rendement moyen</th></tr></thead>
  <tbody>
    <tr><td>Cacao</td><td>450&nbsp;kg par hectare</td></tr>
    <tr><td>Cajou</td><td>La production reste <em>stable</em> cette annee.</td></tr>
  </tbody>
</table></figure>

<!-- result, en -->
<h2>Yields per crop</h2>
<figure class="table"><table>
  <thead><tr><th scope="col">Crop</th><th scope="col">Average yield</th></tr></thead>
  <tbody>
    <tr><td>Cocoa</td><td>450&nbsp;kg per hectare</td></tr>
    <tr><td>Cashew</td><td>Production remains <em>stable</em> this year.</td></tr>
  </tbody>
</table></figure>

Attributes

Some attributes carry words the reader sees. They are translated too, and only their value is rewritten — quoting style, attribute order and every other attribute are left exactly as the editor wrote them:

Translated Never touched
title, alt, placeholder, aria-label, aria-description, <option label>, <th abbr>, <table summary> href, src, class, id, style, data-*, everything else
TRADUIRE = {
    "TRANSLATE_ATTRIBUTES": False,  # default: True
}

Two strategies, picked for you

Provider supports_html What it receives
DeepL, Google Cloud, OpenAI True one block of prose at a time, inline tags left in: Un secteur <strong>vital</strong>.
Google (free), most custom backends False the same block, inline tags replaced by [[0]] placeholders

Either way the structural markup stays home.

When a provider answers badly

Providers do lose placeholders, and models do invent tags. Nothing a provider returns is written back before it has been checked:

  • markup that does not nest is refused;
  • a tag the segment never carried is refused — a model that returns <script> cannot get it into a field your template renders with |safe;
  • a placeholder that came back damaged, duplicated or missing is refused;
  • an empty answer never replaces the text it was meant to translate.

A refused segment falls back to translating its text nodes one by one. Every tag is preserved; only the sentence context is lost, and only for that segment. The fallback is logged as a warning, so it shows up in your logs rather than in your pages.

How a field is recognised as rich text

In the default HTML_MODE of "auto", in this order:

  1. the field is listed in HTML_FIELDS for its model,
  2. the field class name announces it — RichTextField, HTMLField, CKEditor5Field, TinyMCEField, QuillField, ProseField…,
  3. the value itself carries markup.
TRADUIRE = {
    # Explicit beats sniffed. Recommended for content you control.
    "HTML_FIELDS": {
        "blog.Article": ["body", "chapo"],
        "shop.Product": ["description"],
    },
}

Force the decision when you know better than the heuristic:

TRADUIRE = {
    "HTML_MODE": "always",  # every field is markup
    # "HTML_MODE": "never",  # no field is; translate everything as plain text
}

Untrusted HTML

django-traduire preserves the markup it is given, including any tag it does not recognise. It is not a sanitiser. Clean user-submitted HTML with bleach or nh3 before storing it, as you would anyway.

Named entities

&nbsp;, &shy;, &amp;, &lt; and &gt; come back exactly as they were written. Other named entities are decoded to the character they stand for — &eacute; comes back as é. The page reads identically; the bytes change once, on the first translation.

Using the HTML pipeline directly

It is a plain function, usable outside the Django models:

from django_traduire.richtext import translate_html
from django_traduire.translator import get_backend

backend = get_backend()
translated = translate_html(
    html,
    lambda texts: backend.translate_batch(texts, source="fr", target="de"),
    max_chars=backend.max_chars,  # keep every request inside the budget
    inline_html=backend.supports_html,  # send inline tags rather than placeholders
    translate_attributes=True,
)