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)
193 lines
7.1 KiB
Python
193 lines
7.1 KiB
Python
"""Scanned PDF detection and the Mistral OCR translation path."""
|
|
|
|
import fitz
|
|
import pytest
|
|
|
|
from config import config
|
|
from services.mistral_ocr import MistralOCRClient
|
|
from services.providers.base import TranslationProvider
|
|
from services.providers.schemas import TranslationRequest, TranslationResponse
|
|
from translators.pdf_translator import PDFTranslator
|
|
|
|
|
|
FAKE_OCR_PAGES = [
|
|
"# Facture\n\nMontant total : 1 250 euros\n\n",
|
|
"Le paiement est du sous 30 jours.",
|
|
]
|
|
|
|
# Minimal French → English mapping for the fake provider; anything else is
|
|
# prefixed so tests can assert replacement happened.
|
|
TRANSLATIONS = {
|
|
"Facture": "Invoice",
|
|
"Montant total : 1 250 euros": "Total amount: 1,250 euros",
|
|
"Le paiement est du sous 30 jours.": "Payment is due within 30 days.",
|
|
}
|
|
|
|
|
|
class FakeProvider(TranslationProvider):
|
|
"""New-style provider with canned translations (no network)."""
|
|
|
|
def get_name(self) -> str:
|
|
return "fake"
|
|
|
|
def is_available(self) -> bool:
|
|
return True
|
|
|
|
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
|
|
# Pages arrive as multi-line text: translate line by line so the
|
|
# canned mapping applies regardless of how lines are grouped.
|
|
lines = []
|
|
for line in request.text.split("\n"):
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
lines.append(line)
|
|
continue
|
|
lines.append(TRANSLATIONS.get(stripped, f"[EN] {stripped}"))
|
|
return TranslationResponse(
|
|
translated_text="\n".join(lines),
|
|
provider_name=self.get_name(),
|
|
from_cache=False,
|
|
)
|
|
|
|
|
|
def _make_scanned_pdf(path):
|
|
"""Image-only PDF: one page almost fully covered by a picture, no text."""
|
|
pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 10, 10))
|
|
pix.clear_with(90)
|
|
png_bytes = pix.tobytes("png")
|
|
doc = fitz.open()
|
|
page = doc.new_page(width=612, height=792)
|
|
page.insert_image(fitz.Rect(20, 20, 592, 772), stream=png_bytes)
|
|
doc.save(str(path))
|
|
doc.close()
|
|
return path
|
|
|
|
|
|
def _make_text_pdf(path):
|
|
doc = fitz.open()
|
|
page = doc.new_page()
|
|
page.insert_text(
|
|
(72, 100),
|
|
"Real selectable text with plenty of characters to exceed the scanned threshold. " * 2,
|
|
fontsize=11,
|
|
)
|
|
doc.save(str(path))
|
|
doc.close()
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def no_api_key(monkeypatch):
|
|
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
|
monkeypatch.setattr(config, "MISTRAL_API_KEY", "")
|
|
monkeypatch.setattr(config, "MISTRAL_OCR_ENABLED", True)
|
|
|
|
|
|
@pytest.fixture
|
|
def api_key(monkeypatch):
|
|
# Set BOTH the env var and the config attribute: some other test in the
|
|
# full suite reloads the config module, and pdf_translator imports
|
|
# `config` at call time — after a reload only the env var is still
|
|
# visible (the class attributes are re-read from the environment).
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
|
monkeypatch.setattr(config, "MISTRAL_API_KEY", "test-key")
|
|
monkeypatch.setattr(config, "MISTRAL_OCR_ENABLED", True)
|
|
|
|
|
|
class TestScannedDetection:
|
|
def test_image_only_pdf_is_scanned(self, tmp_path):
|
|
pdf = _make_scanned_pdf(tmp_path / "scan.pdf")
|
|
translator = PDFTranslator(provider=None)
|
|
assert translator._is_scanned_pdf(pdf) is True
|
|
|
|
def test_text_pdf_is_not_scanned(self, tmp_path):
|
|
pdf = _make_text_pdf(tmp_path / "text.pdf")
|
|
translator = PDFTranslator(provider=None)
|
|
assert translator._is_scanned_pdf(pdf) is False
|
|
|
|
def test_title_only_pdf_is_not_scanned(self, tmp_path):
|
|
"""A sparse but textual page (no raster image) stays on the layout path."""
|
|
doc = fitz.open()
|
|
page = doc.new_page()
|
|
page.insert_text((72, 100), "Facture 2026", fontsize=18)
|
|
doc.save(str(tmp_path / "title.pdf"))
|
|
doc.close()
|
|
translator = PDFTranslator(provider=None)
|
|
assert translator._is_scanned_pdf(tmp_path / "title.pdf") is False
|
|
|
|
|
|
class TestMarkdownCleanup:
|
|
def test_images_links_headings_removed(self):
|
|
md = "# Titre\n\n\n\n[lien](http://x) texte"
|
|
out = PDFTranslator._markdown_to_text(md)
|
|
assert "Titre" in out
|
|
assert "![img]" not in out
|
|
assert "(http://x)" not in out
|
|
assert "#" not in out
|
|
assert "lien texte" in out
|
|
|
|
def test_table_pipes_removed(self):
|
|
out = PDFTranslator._markdown_to_text("| A | B |\n|---|---|\n| un | deux |")
|
|
assert "|" not in out
|
|
assert "un" in out and "deux" in out
|
|
|
|
|
|
class TestScannedOCRPath:
|
|
def test_translate_scanned_pdf_end_to_end(self, tmp_path, api_key, monkeypatch):
|
|
pdf = _make_scanned_pdf(tmp_path / "scan.pdf")
|
|
monkeypatch.setattr(
|
|
MistralOCRClient,
|
|
"extract_pdf_text",
|
|
lambda self, path, progress_callback=None: list(FAKE_OCR_PAGES),
|
|
)
|
|
|
|
translator = PDFTranslator(provider=FakeProvider())
|
|
# Configure OCR the way the production route does (set_ocr_config).
|
|
# Going through the config module is fragile in the full suite:
|
|
# another test replaces sys.modules["config"], so a config-module
|
|
# patch may never reach the translator.
|
|
translator.set_ocr_config(api_key="test-key", enabled=True)
|
|
out = tmp_path / "out.pdf"
|
|
result = translator.translate_file(pdf, out, "en", "fr")
|
|
|
|
assert result.exists() and result.stat().st_size > 0
|
|
doc = fitz.open(str(result))
|
|
text = "\n".join(p.get_text("text") for p in doc)
|
|
doc.close()
|
|
|
|
assert "Invoice" in text
|
|
assert "Payment is due within 30 days" in text
|
|
assert "Facture" not in text
|
|
assert "logo" not in text # markdown images stripped
|
|
assert "|" not in text # markdown tables stripped
|
|
|
|
def test_missing_api_key_raises_clear_error(self, tmp_path, no_api_key):
|
|
pdf = _make_scanned_pdf(tmp_path / "scan.pdf")
|
|
translator = PDFTranslator(provider=FakeProvider())
|
|
with pytest.raises(RuntimeError) as exc:
|
|
translator.translate_file(pdf, tmp_path / "out.pdf", "en", "fr")
|
|
assert "MISTRAL_API_KEY" in str(exc.value)
|
|
|
|
def test_disabled_ocr_raises_clear_error(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(config, "MISTRAL_API_KEY", "test-key")
|
|
monkeypatch.setattr(config, "MISTRAL_OCR_ENABLED", False)
|
|
pdf = _make_scanned_pdf(tmp_path / "scan.pdf")
|
|
translator = PDFTranslator(provider=FakeProvider())
|
|
with pytest.raises(RuntimeError):
|
|
translator.translate_file(pdf, tmp_path / "out.pdf", "en", "fr")
|
|
|
|
def test_text_pdf_bypasses_ocr(self, tmp_path, api_key, monkeypatch):
|
|
pdf = _make_text_pdf(tmp_path / "text.pdf")
|
|
called = {"ocr": False}
|
|
|
|
def _fail(self, path, progress_callback=None):
|
|
called["ocr"] = True
|
|
return []
|
|
|
|
monkeypatch.setattr(MistralOCRClient, "extract_pdf_text", _fail)
|
|
|
|
translator = PDFTranslator(provider=FakeProvider())
|
|
out = tmp_path / "out.pdf"
|
|
translator.translate_file(pdf, out, "en", "fr")
|
|
assert called["ocr"] is False
|