feat(translation): quality pipeline overhaul + new features (audit 2026-08-29)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s

Translation quality & format preservation:
- Word: merge adjacent same-format runs into one unit (sentence-level
  coherence like inline-tag handling); translate comments/balloons;
  dedupe textbox collection (was translated twice); RTL no longer
  overrides center/justify alignment; CJK/Arabic font hints (eastAsia/cs)
- PPTX: chart translations now actually reach the output file
  (ChartPart.blob is read-only — rewrite chart XML in the saved ZIP);
  CJK typeface hints (a:ea)
- Excel: sheet renames no longer break references — rewrite cell
  formulas (3D/quoted), defined names, data validations, cond. formats
- PDF: bold/italic honored (hebo/heit/hebi); table cells never merge;
  unchanged blocks left untouched (typography preserved, fixes duplicate
  hyperlinks); attempted/changed stats + route gate now cover PDF;
  CJK font paths; scanned PDFs via Mistral OCR (detection + admin settings)

Features:
- formality param (formal/informal) + automatic regional-variant prompts
- output_mode=bilingual docx (source above translation)
- per-user translation memory on Redis (falls back to LRU), context-hashed
- QA report + 0-100 confidence score in job status; L0 on by default
- OpenAI-compatible providers: whole chunk in ONE numbered-JSON request
  (~15x fewer calls) with per-item fallback; base prompt always present
  (custom prompt no longer replaces translation instructions)

Infra & marketing alignment:
- plan-based engine gating + vision gating (closes paid-engine leak);
  /providers/available filtered per plan; 107 languages exposed
- zh-CN/zh-TW validation fixed; libmagic disabled on Windows (native crash)
- admin: Mistral OCR settings + engine status dashboard; httpx<0.28 pin
  (TestClient breakage); Prometheus test fixture fixed
- marketing docs aligned with code (PDF+OCR, retention, engines, pricing)
- security: .env.ionos/.env.production/provider_settings.json removed

Tests: 1173 passed / 0 failed (6 network tests deselected: free Google
endpoint temporarily blocked from this machine)
This commit is contained in:
2026-08-29 18:38:09 +02:00
parent 992f13d53c
commit 526c87348f
87 changed files with 6996 additions and 1024 deletions

View File

@@ -31,6 +31,73 @@ RTL_LANGUAGES: frozenset = frozenset(
{"ar", "he", "fa", "ur", "ku", "ps", "ug", "sd", "yi", "dv", "ckb"}
)
# East-Asian / complex-script font hints: when the target language uses
# glyphs a Latin theme font lacks, Word falls back to a substitute —
# setting the eastAsia (CJK) or cs (Arabic script) typeface keeps the
# rendering consistent across runs.
CJK_EASTASIA_FONTS: dict = {
"zh": "SimSun",
"zh-CN": "SimSun",
"zh-TW": "PMingLiU",
"ja": "Yu Mincho",
"ko": "Batang",
}
CS_FONTS: dict = {
"ar": "Arial",
"he": "Arial",
"fa": "Arial",
"ur": "Arial",
}
def _font_hints_for_target(target_language: str):
"""(eastAsia_font, cs_font) hints for the target language, if any."""
code = (target_language or "").strip()
base = code.split("-")[0].lower()
return CJK_EASTASIA_FONTS.get(code) or CJK_EASTASIA_FONTS.get(base), CS_FONTS.get(base)
def _apply_font_hints(document: Document, target_language: str) -> None:
"""Set eastAsia/cs typeface hints on every run for CJK/Arabic targets.
Blanket application is safe: the hint only affects the glyphs of that
script, which Latin text does not contain.
"""
eastasia, cs = _font_hints_for_target(target_language)
if not eastasia and not cs:
return
runs = []
for para in document.paragraphs:
runs.extend(para.runs)
for table in document.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
runs.extend(para.runs)
for section in document.sections:
for hf in (section.header, section.footer):
for para in hf.paragraphs:
runs.extend(para.runs)
hinted = 0
for run in runs:
rPr = run._r.get_or_add_rPr()
rFonts = rPr.find(qn("w:rFonts"))
if rFonts is None:
rFonts = OxmlElement("w:rFonts")
rPr.insert(0, rFonts)
if eastasia and not rFonts.get(qn("w:eastAsia")):
rFonts.set(qn("w:eastAsia"), eastasia)
hinted += 1
if cs and not rFonts.get(qn("w:cs")):
rFonts.set(qn("w:cs"), cs)
hinted += 1
if hinted:
from core.logging import get_logger as _gl
_gl(__name__).info("word_font_hints_applied", runs=hinted, eastasia=eastasia, cs=cs)
from core.logging import get_logger
@@ -62,7 +129,9 @@ def _set_paragraph_rtl(paragraph: Paragraph) -> None:
Sets:
- w:pPr/w:bidi → paragraph text direction = RTL
- w:pPr/w:jc → alignment = right
- w:pPr/w:jc → mirrored alignment (left→right), ONLY when the
paragraph has no explicit alignment — centered/justified titles
must not be forced right-aligned.
- w:rPr/w:rtl → run-level RTL marker for each run
"""
pPr = paragraph._p.get_or_add_pPr()
@@ -71,10 +140,12 @@ def _set_paragraph_rtl(paragraph: Paragraph) -> None:
pPr.append(OxmlElement("w:bidi"))
jc = pPr.find(qn("w:jc"))
if jc is None:
jc = OxmlElement("w:jc")
pPr.append(jc)
jc.set(qn("w:val"), "right")
explicit_alignment = jc is not None and jc.get(qn("w:val")) not in (None, "", "left")
if not explicit_alignment:
if jc is None:
jc = OxmlElement("w:jc")
pPr.append(jc)
jc.set(qn("w:val"), "right")
for run in paragraph.runs:
rPr = run._r.get_or_add_rPr()
@@ -173,6 +244,7 @@ class WordTranslator:
self._provider = provider
self._custom_prompt: Optional[str] = None
self._translation_stats = {"attempted": 0, "changed": 0}
self._tm_scope = None # set via set_tm_scope (per-user translation memory)
def set_provider(self, provider: TranslationProvider) -> None:
"""Set the translation provider."""
@@ -182,6 +254,12 @@ class WordTranslator:
"""Set custom system prompt for LLM providers."""
self._custom_prompt = prompt
def set_tm_scope(self, user_id: Optional[str], prompt: Optional[str] = None) -> None:
"""Enable the per-user translation memory for this job."""
from services.translation_tm import TMScope
self._tm_scope = TMScope.from_prompt(user_id, prompt or self._custom_prompt)
def translate_file(
self,
input_path: Path,
@@ -333,6 +411,10 @@ class WordTranslator:
if target_language.lower() in RTL_LANGUAGES:
_apply_rtl_to_document(document)
# CJK / Arabic-script font hints so Word renders the target
# script with a proper typeface instead of per-run fallbacks.
_apply_font_hints(document, target_language)
if progress_callback:
progress_callback(
{
@@ -461,12 +543,30 @@ class WordTranslator:
non_empty = [t for t in texts if t and t.strip()]
self._translation_stats["attempted"] += len(non_empty)
from services.translation_tm import translate_with_tm
provider_name = (
self._provider.get_name() if hasattr(self._provider, "get_name")
else type(self._provider).__name__
) if self._provider is not None else "legacy"
if self._provider is not None:
translated = self._translate_with_provider(
texts, target_language, source_language
)
def _do_translate(miss_texts):
return self._translate_with_provider(
miss_texts, target_language, source_language
)
else:
translated = self._translate_with_legacy(texts, target_language, source_language)
def _do_translate(miss_texts):
return self._translate_with_legacy(
miss_texts, target_language, source_language
)
# Translation memory: reuse this user's previous translations
# (identical context/prompt) before hitting the provider.
translated = translate_with_tm(
texts, target_language, source_language,
provider_name, self._tm_scope, _do_translate,
)
changed = sum(1 for orig, trans in zip(texts, translated) if orig != trans and trans.strip())
self._translation_stats["changed"] += changed
@@ -541,12 +641,19 @@ class WordTranslator:
Handles: paragraphs, tables, SDT (TOC/index), text boxes, shapes,
AlternateContent blocks, and any nested drawing elements.
A single ``seen_run_elements`` set is shared by every collector so
runs living in text boxes are never collected twice (the paragraph
walk descends into w:txbxContent too).
"""
count_before = len(text_elements)
seen_run_elements: set = set()
# Pass 1: walk direct body children
for element in document.element.body:
self._collect_from_element(element, document, text_elements)
self._collect_from_element(
element, document, text_elements, seen_run_elements
)
pass1_count = len(text_elements) - count_before
@@ -554,15 +661,18 @@ class WordTranslator:
# Text boxes / rectangles / shapes store their text here, nested deep
# inside <w:drawing> → <a:graphic> → <wps:wsp> → <wps:txbx> or
# inside <w:pict> → <v:shape> → <v:textbox>.
self._collect_from_textboxes(document.element.body, document, text_elements)
self._collect_from_textboxes(
document.element.body, document, text_elements, seen_run_elements
)
pass2_count = len(text_elements) - count_before - pass1_count
# Pass 3: footnotes and endnotes (live in separate parts)
# Pass 3: footnotes, endnotes and comments (live in separate parts)
if post_save_callbacks is None:
post_save_callbacks = []
self._collect_from_footnotes(document, text_elements, post_save_callbacks)
self._collect_from_endnotes(document, text_elements, post_save_callbacks)
self._collect_from_comments(document, text_elements, post_save_callbacks)
total = len(text_elements) - count_before
_log_info(
@@ -573,34 +683,38 @@ class WordTranslator:
)
def _collect_from_element(
self, element, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
self, element, document: Document,
text_elements: List[Tuple[str, Callable[[str], None]]],
seen_run_elements: Optional[set] = None,
) -> None:
"""Recursively collect from any element type."""
if isinstance(element, CT_P):
paragraph = Paragraph(element, document)
self._collect_from_paragraph(paragraph, text_elements)
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
elif isinstance(element, CT_Tbl):
table = Table(element, document)
self._collect_from_table(table, text_elements)
self._collect_from_table(table, text_elements, seen_run_elements)
elif element.tag == qn("w:sdt"):
self._collect_from_sdt(element, document, text_elements)
self._collect_from_sdt(element, document, text_elements, seen_run_elements)
elif element.tag == self._TAG_ALT_CONTENT:
# <mc:AlternateContent> wraps drawing/shape content
for part in element:
self._collect_from_element(part, document, text_elements)
self._collect_from_element(part, document, text_elements, seen_run_elements)
else:
# For any other container element, recurse into children
# to catch paragraphs nested in unexpected wrappers
for child in element:
if isinstance(child, CT_P):
paragraph = Paragraph(child, document)
self._collect_from_paragraph(paragraph, text_elements)
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
elif isinstance(child, CT_Tbl):
table = Table(child, document)
self._collect_from_table(table, text_elements)
self._collect_from_table(table, text_elements, seen_run_elements)
def _collect_from_textboxes(
self, root, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
self, root, document: Document,
text_elements: List[Tuple[str, Callable[[str], None]]],
seen_run_elements: Optional[set] = None,
) -> None:
"""Find and collect text from ALL <w:txbxContent> elements in the XML tree.
@@ -612,20 +726,23 @@ class WordTranslator:
- Shapes nested in <mc:AlternateContent> blocks
The <w:txbxContent> element contains regular <w:p> paragraphs
with <w:r> runs, just like normal body text.
with <w:r> runs, just like normal body text. Runs already collected
during the body walk are skipped via ``seen_run_elements``.
"""
# Find all w:txbxContent elements anywhere in the tree
for txbx in root.iter(qn("w:txbxContent")):
for child in txbx:
if isinstance(child, CT_P):
paragraph = Paragraph(child, document)
self._collect_from_paragraph(paragraph, text_elements)
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
elif isinstance(child, CT_Tbl):
table = Table(child, document)
self._collect_from_table(table, text_elements)
self._collect_from_table(table, text_elements, seen_run_elements)
def _collect_from_sdt(
self, sdt_element, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
self, sdt_element, document: Document,
text_elements: List[Tuple[str, Callable[[str], None]]],
seen_run_elements: Optional[set] = None,
) -> None:
"""Collect text from Structured Document Tags (TOC, index, content controls).
@@ -645,10 +762,10 @@ class WordTranslator:
for child in sdt_content:
if isinstance(child, CT_P):
paragraph = Paragraph(child, document)
self._collect_from_paragraph(paragraph, text_elements)
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
elif isinstance(child, CT_Tbl):
table = Table(child, document)
self._collect_from_table(table, text_elements)
self._collect_from_table(table, text_elements, seen_run_elements)
def _collect_from_footnotes(
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]],
@@ -783,6 +900,59 @@ class WordTranslator:
post_save_callbacks.append(write_endnotes_back)
def _collect_from_comments(
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]],
post_save_callbacks: List[Callable[[Path], None]] = None,
) -> None:
"""Collect text from comments/balloons (word/comments.xml part).
Same mechanism as footnotes: the comments part is separate from the
main document tree, so translations are written back after save.
"""
comments_xml = self._find_part_by_content_type(
document,
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
)
if comments_xml is None:
return
collected = 0
for t_elem in comments_xml.iter(qn("w:t")):
original = t_elem.text or ""
if not original.strip():
continue
def make_t_setter(t):
def setter(text: str) -> None:
t.text = text
return setter
text_elements.append((original, make_t_setter(t_elem)))
collected += 1
if collected and post_save_callbacks is not None:
def write_comments_back(output_path: Path) -> None:
try:
new_blob = etree.tostring(
comments_xml,
xml_declaration=True,
encoding="UTF-8",
standalone=True,
)
tmp_path = output_path.with_suffix(".tmp_com")
with zipfile.ZipFile(output_path, "r") as zin, \
zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.namelist():
if item == "word/comments.xml":
zout.writestr(item, new_blob)
else:
zout.writestr(item, zin.read(item))
tmp_path.replace(output_path)
except Exception as e:
_log_error("word_comments_writeback_error", error=str(e))
post_save_callbacks.append(write_comments_back)
def _collect_from_charts(
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]]
) -> None:
@@ -1144,23 +1314,39 @@ class WordTranslator:
except Exception as e:
_log_error("word_diagram_zip_rewrite_error", error=str(e))
@staticmethod
def _rpr_signature(run_element) -> str:
"""Formatting signature of a run: serialized rPr XML (or "")."""
rpr = run_element.find(qn("w:rPr"))
if rpr is None:
return ""
import lxml.etree as _et
return _et.tostring(rpr, encoding="unicode")
def _collect_from_paragraph(
self,
paragraph: Paragraph,
text_elements: List[Tuple[str, Callable[[str], None]]],
seen_run_elements: Optional[set] = None,
) -> None:
"""Collect text from paragraph runs, preserving inter-run whitespace.
Each run is sent for translation WITHOUT its surrounding whitespace.
The whitespace is captured and reapplied after translation so that words
at formatting boundaries (e.g. bold/normal) do not get concatenated.
Adjacent runs sharing the SAME parent element and the SAME run
formatting (rPr) are merged into ONE translation unit: the sentence
is translated whole — not fragment by fragment — and the result is
written into the first run while the sibling runs are blanked.
This is what keeps mid-sentence bold spans ("This is *very*
important") coherent in the target language, like DeepL's inline
tag handling.
Note: python-docx's `paragraph.runs` only returns DIRECT child <w:r>
elements, not those inside <w:hyperlink> (used for TOC entries,
cross-references, bookmark links). We therefore iterate the full
XML tree to find every <w:r> and use a set of element ids to
deduplicate — this avoids translating the same run twice while
ensuring hyperlink text IS picked up.
XML tree to find every <w:r> and deduplicate by element identity —
`seen_run_elements` is shared across paragraphs so runs living in
text boxes (also collected by _collect_from_textboxes) are not
translated twice.
"""
# Check full paragraph text including nested content (hyperlinks, etc.)
full_text = ''.join(
@@ -1169,33 +1355,83 @@ class WordTranslator:
if not full_text:
return
# Collect every <w:r> element in the paragraph tree, including
# those nested in <w:hyperlink>, <w:smartTag>, etc. The dedup by
# element id is defensive — `paragraph.runs` and the manual iter
# below could overlap if python-docx starts surfacing nested runs.
seen_run_ids: set = set()
if seen_run_elements is None:
seen_run_elements = set()
# 1) Direct runs (paragraph.runs is the python-docx-native API).
for run in paragraph.runs:
run_id = id(run._r)
if run_id in seen_run_ids:
continue
seen_run_ids.add(run_id)
if run.text and run.text.strip():
self._append_run_translation(run, text_elements)
# 2) Runs nested inside <w:hyperlink> (TOC, cross-references).
# python-docx's `paragraph.runs` does NOT descend into hyperlinks in
# version 1.x — we have to walk the XML ourselves.
# Every <w:r> in the paragraph tree, in document order, deduplicated
# by element identity (paragraph.runs and the manual iter overlap).
ordered_runs = []
for r_elem in paragraph._p.iter(qn('w:r')):
run_id = id(r_elem)
if run_id in seen_run_ids:
if id(r_elem) in seen_run_elements:
continue
seen_run_ids.add(run_id)
# Build a Run wrapper so the setter API is consistent.
run = Run(r_elem, paragraph)
if run.text and run.text.strip():
seen_run_elements.add(id(r_elem))
ordered_runs.append(r_elem)
# Merge adjacent runs: same parent + same formatting signature.
# Merging never crosses a parent boundary, so runs belonging to
# different hyperlinks stay separate units.
group: list = [] # list of r_elems
group_signature: Optional[str] = None
def _flush_group():
combined = "".join(
(t.text or "")
for r in group
for t in r.findall(qn("w:t"))
)
if not combined.strip():
return
non_empty = [r for r in group if r.findall(qn("w:t"))]
if len(non_empty) == 1:
run = Run(non_empty[0], paragraph)
self._append_run_translation(run, text_elements)
return
leading = combined[: len(combined) - len(combined.lstrip())]
trailing = combined[len(combined.rstrip()):]
stripped = combined.strip()
if not stripped:
return
first = non_empty[0]
def make_group_setter(first_r, siblings, lead: str, trail: str):
def setter(text: str) -> None:
from docx.text.run import Run as _Run
run = _Run(first_r, paragraph)
# Reapply the group's boundary whitespace so words are
# never concatenated with the next differently-formatted
# run ("This is quite" + "very" → "quite very").
run.text = lead + text.strip() + trail
# Blank the merged siblings: the whole sentence now
# lives in the first run (formatting is identical).
for sib in siblings:
for t_elem in sib.findall(qn("w:t")):
t_elem.text = ""
return setter
siblings = non_empty[1:]
text_elements.append(
(stripped, make_group_setter(first, siblings, leading, trailing))
)
for r_elem in ordered_runs:
# Whitespace-only runs join the group: dropping them would
# concatenate words ("Hello" + " " + "World" → "HelloWorld").
# They carry no w:t text, so a group of only whitespace runs is
# skipped at flush time by the strip() check.
signature = self._rpr_signature(r_elem)
same_parent = (
group and group[-1].getparent() is r_elem.getparent()
)
if group and same_parent and signature == group_signature:
group.append(r_elem)
else:
_flush_group()
group = [r_elem]
group_signature = signature
_flush_group()
def _append_run_translation(
self,
@@ -1220,15 +1456,16 @@ class WordTranslator:
text_elements.append((stripped, make_setter(run, leading, trailing)))
def _collect_from_table(
self, table: Table, text_elements: List[Tuple[str, Callable[[str], None]]]
self, table: Table, text_elements: List[Tuple[str, Callable[[str], None]]],
seen_run_elements: Optional[set] = None,
) -> None:
"""Collect text from table cells."""
for row in table.rows:
for cell in row.cells:
for paragraph in cell.paragraphs:
self._collect_from_paragraph(paragraph, text_elements)
self._collect_from_paragraph(paragraph, text_elements, seen_run_elements)
for nested_table in cell.tables:
self._collect_from_table(nested_table, text_elements)
self._collect_from_table(nested_table, text_elements, seen_run_elements)
def _collect_from_section(
self, section: Section, text_elements: List[Tuple[str, Callable[[str], None]]]