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:
@@ -564,60 +564,46 @@ class TestPptxChartWhitespace:
|
||||
</c:chartSpace>
|
||||
"""
|
||||
|
||||
def test_padded_chart_text_via_internal_method(self):
|
||||
"""The internal chart apply logic should preserve whitespace."""
|
||||
def test_chart_translation_reaches_output_file(self, tmp_path):
|
||||
"""Chart translations must reach the OUTPUT FILE (real ChartPart).
|
||||
|
||||
python-pptx's ChartPart.blob is read-only — the previous in-memory
|
||||
`blob = ...` write never landed in the saved .pptx. This end-to-end
|
||||
test uses a real chart and verifies the chart XML inside the
|
||||
output ZIP.
|
||||
"""
|
||||
from pptx.chart.data import CategoryChartData
|
||||
from pptx.enum.chart import XL_CHART_TYPE
|
||||
|
||||
provider = MockProvider({"Padded chart title": "Titre avec espaces"})
|
||||
translator = PowerPointTranslator(provider=provider)
|
||||
|
||||
# Build a chart entry by hand (simulating collect time)
|
||||
chart_xml = etree.fromstring(self.CHART_PADDED_XML.encode("utf-8"))
|
||||
entries = []
|
||||
for t_elem in chart_xml.iter(f"{{{_NS_A}}}t"):
|
||||
text_raw = t_elem.text or ""
|
||||
text = text_raw.strip()
|
||||
if not text:
|
||||
continue
|
||||
entry = {
|
||||
"element": t_elem,
|
||||
"original": text,
|
||||
"original_raw": text_raw,
|
||||
"translated": "Titre avec espaces",
|
||||
"tag": "a:t",
|
||||
"element_path": translator._get_element_path(t_elem),
|
||||
}
|
||||
entries.append(entry)
|
||||
|
||||
if not hasattr(translator, "_chart_entries"):
|
||||
translator._chart_entries = []
|
||||
|
||||
class _FakePart:
|
||||
def __init__(self, blob):
|
||||
self._blob = blob
|
||||
@property
|
||||
def blob(self):
|
||||
return self._blob
|
||||
@blob.setter
|
||||
def blob(self, value):
|
||||
self._blob = value
|
||||
|
||||
fake_part = _FakePart(
|
||||
etree.tostring(
|
||||
chart_xml, xml_declaration=True, encoding="UTF-8", standalone=True
|
||||
)
|
||||
prs = Presentation()
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[5])
|
||||
chart_data = CategoryChartData()
|
||||
chart_data.categories = ["A", "B"]
|
||||
chart_data.add_series("Series 1", (1, 2))
|
||||
graphic_frame = slide.shapes.add_chart(
|
||||
XL_CHART_TYPE.COLUMN_CLUSTERED, 10, 10, 400, 300, chart_data
|
||||
)
|
||||
translator._chart_entries.append({
|
||||
"chart_part": fake_part,
|
||||
"entries": entries,
|
||||
})
|
||||
chart = graphic_frame.chart
|
||||
chart.has_title = True
|
||||
chart.chart_title.text_frame.text = "Padded chart title"
|
||||
|
||||
# Apply
|
||||
translator._apply_chart_translations(Path("dummy"))
|
||||
input_file = tmp_path / "chart_in.pptx"
|
||||
output_file = tmp_path / "chart_out.pptx"
|
||||
prs.save(str(input_file))
|
||||
|
||||
# Re-parse and check whitespace preserved
|
||||
updated_xml = etree.fromstring(fake_part._blob)
|
||||
all_t = list(updated_xml.iter(f"{{{_NS_A}}}t"))
|
||||
# Find the title text
|
||||
title_text = all_t[0].text or ""
|
||||
assert " Titre avec espaces " in title_text, (
|
||||
f"Chart whitespace not preserved: {title_text!r}"
|
||||
translator.translate_file(input_file, output_file, "fr", "en")
|
||||
|
||||
with zipfile.ZipFile(output_file, "r") as zf:
|
||||
chart_parts = [
|
||||
n for n in zf.namelist() if n.startswith("ppt/charts/chart")
|
||||
]
|
||||
assert chart_parts, "chart part missing from output file"
|
||||
chart_xml = zf.read(chart_parts[0]).decode("utf-8")
|
||||
|
||||
assert "Titre avec espaces" in chart_xml, (
|
||||
"Chart title translation never reached the output file"
|
||||
)
|
||||
assert "Padded chart title" not in chart_xml
|
||||
|
||||
139
tests/test_translators/test_pdf_quality_fixes.py
Normal file
139
tests/test_translators/test_pdf_quality_fixes.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""PDF quality fixes: table-cell merge guard, bold/italic fonts, unchanged
|
||||
blocks left untouched (no redaction/rewrite), and stats propagation."""
|
||||
|
||||
import fitz
|
||||
import pytest
|
||||
|
||||
from translators.pdf_translator import PDFTranslator
|
||||
|
||||
|
||||
class TestTableCellMergeGuard:
|
||||
def test_table_cells_never_merge(self):
|
||||
t = PDFTranslator(provider=None)
|
||||
a = {
|
||||
"bbox": (72, 100, 200, 115),
|
||||
"font_size": 11,
|
||||
"_is_table_cell": True,
|
||||
}
|
||||
b = {
|
||||
"bbox": (72, 118, 200, 133), # same column, next row
|
||||
"font_size": 11,
|
||||
"_is_table_cell": True,
|
||||
}
|
||||
assert t._should_merge_blocks(a, b) is False
|
||||
|
||||
def test_table_cell_and_paragraph_do_not_merge(self):
|
||||
t = PDFTranslator(provider=None)
|
||||
a = {"bbox": (72, 100, 200, 115), "font_size": 11, "_is_table_cell": True}
|
||||
b = {"bbox": (72, 118, 200, 133), "font_size": 11}
|
||||
assert t._should_merge_blocks(a, b) is False
|
||||
|
||||
def test_regular_paragraphs_still_merge(self):
|
||||
t = PDFTranslator(provider=None)
|
||||
a = {"bbox": (72, 100, 300, 115), "font_size": 11}
|
||||
b = {"bbox": (72, 118, 302, 133), "font_size": 11}
|
||||
assert t._should_merge_blocks(a, b) is True
|
||||
|
||||
|
||||
class TestBoldItalicFontSelection:
|
||||
def _capturing_page(self):
|
||||
page = fitz.open().new_page()
|
||||
calls = []
|
||||
original = page.insert_textbox
|
||||
|
||||
def capture(rect, text, fontname=None, fontfile=None, fontsize=None, **kw):
|
||||
calls.append({"fontname": fontname, "fontfile": fontfile})
|
||||
return 0
|
||||
|
||||
page.insert_textbox = capture
|
||||
return page, calls
|
||||
|
||||
def _block(self, **kwargs):
|
||||
block = {
|
||||
"bbox": (72, 100, 400, 130),
|
||||
"text": "Bold heading",
|
||||
"translated": "Titre en gras",
|
||||
"font_size": 20,
|
||||
"color": 0,
|
||||
"line_count": 1,
|
||||
"sub_bboxes": [(72, 100, 400, 130)],
|
||||
}
|
||||
block.update(kwargs)
|
||||
return block
|
||||
|
||||
def test_bold_block_uses_hebo(self):
|
||||
page, calls = self._capturing_page()
|
||||
t = PDFTranslator(provider=None)
|
||||
t._font_path = None # force base-14 selection
|
||||
t._write_translated_block(page, self._block(is_bold=True), None, False)
|
||||
assert calls and calls[0]["fontname"] == "hebo"
|
||||
|
||||
def test_italic_block_uses_heit(self):
|
||||
page, calls = self._capturing_page()
|
||||
t = PDFTranslator(provider=None)
|
||||
t._font_path = None
|
||||
t._write_translated_block(page, self._block(is_italic=True), None, False)
|
||||
assert calls and calls[0]["fontname"] == "heit"
|
||||
|
||||
def test_bold_italic_uses_hebi(self):
|
||||
page, calls = self._capturing_page()
|
||||
t = PDFTranslator(provider=None)
|
||||
t._font_path = None
|
||||
t._write_translated_block(
|
||||
page, self._block(is_bold=True, is_italic=True), None, False
|
||||
)
|
||||
assert calls and calls[0]["fontname"] == "hebi"
|
||||
|
||||
def test_regular_block_keeps_helv(self):
|
||||
page, calls = self._capturing_page()
|
||||
t = PDFTranslator(provider=None)
|
||||
t._font_path = None
|
||||
t._write_translated_block(page, self._block(), None, False)
|
||||
assert calls and calls[0]["fontname"] == "helv"
|
||||
|
||||
|
||||
class _IdentityProvider:
|
||||
"""Returns the input text unchanged (new-style provider)."""
|
||||
|
||||
def get_name(self):
|
||||
return "identity"
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
def translate_text(self, request):
|
||||
from services.providers.schemas import TranslationResponse
|
||||
|
||||
return TranslationResponse(
|
||||
translated_text=request.text,
|
||||
provider_name="identity",
|
||||
from_cache=False,
|
||||
)
|
||||
|
||||
|
||||
class TestUnchangedBlocksUntouched:
|
||||
def test_identity_translation_stats_and_no_rewrite(self, tmp_path):
|
||||
"""Provider returning the source text: stats stay changed=0 (so the
|
||||
route gate detects it) and blocks are left un-redacted."""
|
||||
doc = fitz.open()
|
||||
page = doc.new_page()
|
||||
page.insert_text((72, 100), "Already in English, nothing to do.", fontsize=11)
|
||||
src = tmp_path / "en.pdf"
|
||||
doc.save(str(src))
|
||||
doc.close()
|
||||
|
||||
t = PDFTranslator(provider=_IdentityProvider())
|
||||
out = tmp_path / "out.pdf"
|
||||
result = t.translate_file(src, out, "en", "auto")
|
||||
|
||||
assert result.exists()
|
||||
stats = t.get_translation_stats()
|
||||
assert stats["attempted"] >= 1
|
||||
assert stats["changed"] == 0
|
||||
|
||||
# The text is still there, byte-for-byte same rendering (block was
|
||||
# not redacted + rewritten in the substitute font).
|
||||
check = fitz.open(str(result))
|
||||
text = check[0].get_text("text")
|
||||
check.close()
|
||||
assert "Already in English" in text
|
||||
@@ -190,20 +190,23 @@ class TestParagraphTranslation:
|
||||
"""Tests for paragraph text translation (AC1)."""
|
||||
|
||||
def test_translate_paragraph_runs(self, tmp_path):
|
||||
"""Test that paragraph runs are translated."""
|
||||
"""Adjacent runs with identical formatting merge into ONE unit.
|
||||
|
||||
"Hello" + " " + "World" (same formatting, rsid-style splits) must be
|
||||
translated as the whole sentence, not as separate fragments.
|
||||
"""
|
||||
mock_provider = MockTranslationProvider(
|
||||
{
|
||||
"Hello": "Bonjour",
|
||||
"World": "Monde",
|
||||
"Hello World": "Bonjour le monde",
|
||||
}
|
||||
)
|
||||
translator = WordTranslator(provider=mock_provider)
|
||||
|
||||
doc = Document()
|
||||
para = doc.add_paragraph()
|
||||
run1 = para.add_run("Hello")
|
||||
run2 = para.add_run(" ")
|
||||
run3 = para.add_run("World")
|
||||
para.add_run("Hello")
|
||||
para.add_run(" ")
|
||||
para.add_run("World")
|
||||
|
||||
input_file = tmp_path / "input.docx"
|
||||
output_file = tmp_path / "output.docx"
|
||||
@@ -214,8 +217,41 @@ class TestParagraphTranslation:
|
||||
doc_out = Document(output_file)
|
||||
text = doc_out.paragraphs[0].text
|
||||
|
||||
assert "Bonjour" in text
|
||||
assert "Monde" in text
|
||||
assert text == "Bonjour le monde"
|
||||
# One merged unit → a single provider call
|
||||
assert mock_provider._call_count == 1
|
||||
|
||||
def test_bold_span_kept_separate_and_coherent(self, tmp_path):
|
||||
"""A formatting change mid-sentence splits the units; spaces survive."""
|
||||
mock_provider = MockTranslationProvider(
|
||||
{
|
||||
"This is": "Ceci est",
|
||||
"very important": "très important",
|
||||
}
|
||||
)
|
||||
translator = WordTranslator(provider=mock_provider)
|
||||
|
||||
doc = Document()
|
||||
para = doc.add_paragraph()
|
||||
para.add_run("This is ")
|
||||
bold = para.add_run("very important")
|
||||
bold.bold = True
|
||||
|
||||
input_file = tmp_path / "input.docx"
|
||||
output_file = tmp_path / "output.docx"
|
||||
doc.save(input_file)
|
||||
|
||||
translator.translate_file(input_file, output_file, "fr")
|
||||
|
||||
doc_out = Document(output_file)
|
||||
text = doc_out.paragraphs[0].text
|
||||
assert text == "Ceci est très important"
|
||||
|
||||
# Bold formatting survives on the right span
|
||||
runs = [r for r in doc_out.paragraphs[0].runs if r.text.strip()]
|
||||
assert len(runs) == 2
|
||||
assert runs[1].bold is True
|
||||
assert runs[1].text == "très important"
|
||||
|
||||
def test_empty_paragraphs_not_translated(self, tmp_path):
|
||||
"""Test that empty paragraphs are not translated."""
|
||||
|
||||
Reference in New Issue
Block a user