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

@@ -95,6 +95,69 @@ def _apply_rtl_to_shape(shape) -> None:
_apply_rtl_to_shape(sub_shape)
# East-Asian typeface hints per target language (DrawingML <a:ea>)
_EA_TYPEFACES = {
"zh": "SimSun",
"zh-CN": "SimSun",
"zh-TW": "PMingLiU",
"ja": "Yu Mincho",
"ko": "Batang",
}
def _ea_typeface_for_target(target_language: str):
code = (target_language or "").strip()
base = code.split("-")[0].lower()
return _EA_TYPEFACES.get(code) or _EA_TYPEFACES.get(base)
def _apply_ea_font_hints(presentation: Presentation, target_language: str) -> None:
"""Set the <a:ea> (east-asian) typeface on every run for CJK targets.
Blanket application is safe — the hint only affects CJK glyphs.
"""
def _hint_shape(shape) -> int:
hinted = 0
if shape.has_text_frame:
hinted += _hint_text_frame(shape.text_frame)
if shape.shape_type == MSO_SHAPE_TYPE.TABLE:
for row in shape.table.rows:
for cell in row.cells:
hinted += _hint_text_frame(cell.text_frame)
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
for sub_shape in shape.shapes:
hinted += _hint_shape(sub_shape)
return hinted
def _hint_text_frame(text_frame) -> int:
hinted = 0
tag_rPr = f"{{{_NS_A}}}rPr"
tag_ea = f"{{{_NS_A}}}ea"
for paragraph in text_frame.paragraphs:
for run in paragraph.runs:
rPr = run._r.find(tag_rPr)
if rPr is None:
rPr = etree.SubElement(run._r, tag_rPr)
ea = rPr.find(tag_ea)
if ea is None:
ea = etree.SubElement(rPr, tag_ea)
if not ea.get("typeface"):
ea.set("typeface", typeface)
hinted += 1
return hinted
typeface = _ea_typeface_for_target(target_language)
if not typeface:
return
total = 0
for slide in presentation.slides:
for shape in slide.shapes:
total += _hint_shape(shape)
if total:
_log_info("pptx_ea_font_hints_applied", runs=total, typeface=typeface)
class PptxProcessorError(Exception):
"""Exception for PowerPoint processing errors with structured error codes."""
@@ -152,6 +215,7 @@ class PowerPointTranslator:
"""
self._provider = provider
self._custom_prompt: Optional[str] = None
self._tm_scope = None # set via set_tm_scope (per-user translation memory)
self._translation_stats = {"attempted": 0, "changed": 0}
def set_provider(self, provider: TranslationProvider) -> None:
@@ -161,6 +225,14 @@ class PowerPointTranslator:
def set_custom_prompt(self, prompt: Optional[str]) -> None:
"""Set custom system prompt for LLM providers."""
self._custom_prompt = prompt
def set_tm_scope(self, user_id, prompt=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 getattr(self, "_custom_prompt", None)
)
def translate_file(
self,
@@ -316,6 +388,10 @@ class PowerPointTranslator:
if target_language.lower() in RTL_LANGUAGES:
_apply_rtl_to_presentation(presentation)
# CJK font hint so the target script renders with a proper
# typeface instead of shape-dependent fallbacks.
_apply_ea_font_hints(presentation, target_language)
if translate_images:
try:
self._translate_images(presentation, target_language)
@@ -405,12 +481,30 @@ class PowerPointTranslator:
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, getattr(self, "_tm_scope", None), _do_translate,
)
changed = sum(1 for orig, trans in zip(texts, translated) if orig != trans and trans.strip())
self._translation_stats["changed"] += changed
@@ -743,7 +837,14 @@ class PowerPointTranslator:
return None
def _apply_chart_translations(self, output_path: Path) -> None:
"""Re-inject chart text translations by modifying chart XML parts.
"""Re-inject chart text translations into the saved .pptx ZIP.
python-pptx's ChartPart exposes a read-only ``blob`` property, so an
in-memory `chart_part.blob = ...` assignment fails silently and the
chart text never reaches the output file. Instead — exactly like the
Word translator — we translate into a fresh parse of each chart
part's XML and rewrite the corresponding ZIP entries of the
already-saved output file.
Matching strategy: prefer the stored `element_path` (set at collect
time) to navigate directly to the right element. Fall back to
@@ -759,6 +860,9 @@ class PowerPointTranslator:
total_translated = 0
total_skipped = 0
# partname (e.g. "ppt/charts/chart1.xml") → updated XML bytes
updated_parts: Dict[str, bytes] = {}
for chart_data in self._chart_entries:
entries = chart_data['entries']
chart_part = chart_data['chart_part']
@@ -804,17 +908,39 @@ class PowerPointTranslator:
target.text = leading + (entry['translated'] or '').strip() + trailing
total_translated += 1
# Update the chart part blob
chart_part.blob = etree.tostring(
chart_xml,
xml_declaration=True,
encoding='UTF-8',
standalone=True,
)
part_name = str(getattr(chart_part, "partname", "")).lstrip("/")
if part_name:
updated_parts[part_name] = etree.tostring(
chart_xml,
xml_declaration=True,
encoding='UTF-8',
standalone=True,
)
except Exception as e:
_log_error("pptx_chart_update_error", error=str(e))
# Single ZIP rewrite pass for all updated chart parts
if updated_parts:
import zipfile
import io as _io
try:
with zipfile.ZipFile(output_path, 'r') as zf_in:
buf = _io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf_out:
for item in zf_in.namelist():
data = updated_parts.get(item, zf_in.read(item))
zf_out.writestr(item, data)
with open(output_path, 'wb') as f:
f.write(buf.getvalue())
_log_info(
"pptx_charts_rewritten",
chart_parts=len(updated_parts),
translated=total_translated,
)
except Exception as e:
_log_error("pptx_chart_zip_rewrite_error", error=str(e))
# Clean up
self._chart_entries = []