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:
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 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,
and­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 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 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 |
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:
- the field is listed in
HTML_FIELDSfor its model, - the field class name announces it —
RichTextField,HTMLField,CKEditor5Field,TinyMCEField,QuillField,ProseField…, - 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
, ­, &, < and > come back exactly as they
were written. Other named entities are decoded to the character they stand
for — é 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,
)