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
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:
@@ -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 = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user