From 5f3d57b4f7d364fbd8b8e048ca4fb5ffde35733c Mon Sep 17 00:00:00 2001 From: sepehr Date: Tue, 1 Sep 2026 21:28:46 +0200 Subject: [PATCH] fix(direction): le sens du document traduit suit la langue cible, dans les deux sens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persan -> francais : le document traduit restait en lecture de droite a gauche, heritee de la source. Trois causes : - PDF : regression du renommage precedent — le choix d'alignement testait la fonction is_rtl (toujours vraie) au lieu du parametre : tout texte traduit s'alignait a droite, quelle que soit la cible. Corrige + test epinglant l'alignement gauche pour une cible latine. - Word : les marques RTL heritees (bidi, rtl, bidiVisual, notes et commentaires compris) sont desormais retirees quand la cible est latine ; retrait fait sur une liste figee (l'iterateur lxml sautait des elements pendant la suppression). - Excel : les feuilles heritees d'un affichage droite-a-gauche sont remises en lecture gauche-a-droite pour une cible latine. - PowerPoint : les attributs rtl herites sont retires pour une cible latine (alignements visuels conserves). 3 tests de bout en bout nouveaux : document RTL traduit vers le francais ressort en lecture gauche-a-droite dans les trois formats. --- tests/test_translators/test_rtl_layout.py | 97 ++++++++++++++++++++ translators/excel_translator.py | 44 +++++---- translators/pdf_translator.py | 2 +- translators/pptx_translator.py | 47 +++++++++- translators/word_translator.py | 104 ++++++++++++++++++++-- 5 files changed, 268 insertions(+), 26 deletions(-) diff --git a/tests/test_translators/test_rtl_layout.py b/tests/test_translators/test_rtl_layout.py index 7c48850..83e7753 100644 --- a/tests/test_translators/test_rtl_layout.py +++ b/tests/test_translators/test_rtl_layout.py @@ -855,6 +855,21 @@ class TestPdfLayoutModeRtl: assert ok is True assert page.calls[0]["text"] == "Bonjour le monde" + def test_ltr_target_is_left_aligned(self): + """Regression guard: a Latin target (e.g. Persian → French) must be + left-aligned. A previous rename once made the alignment test the + truthiness of the imported is_rtl *function* — always true — and + every translated PDF came out right-aligned.""" + import fitz + + translator = PDFTranslator() + page = _FakePage() + ok = translator._write_translated_block( + page, self._block("Bonjour le monde"), font_path=None, rtl_target=False + ) + assert ok is True + assert page.calls[0]["align"] == fitz.TEXT_ALIGN_LEFT + class TestPdfFontSelection: def test_arabic_script_target_prefers_arabic_font(self, tmp_path, monkeypatch): @@ -1054,3 +1069,85 @@ class TestPdfCleanModeRtl: # Presentation forms in the output prove both the shaping and the # Arabic-capable font reached the PDF (Helvetica cannot encode them). assert any(0xFB50 <= ord(c) <= 0xFEFF for c in extracted) + + +# --------------------------------------------------------------------------- +# Normalisation LTR — un document source RTL traduit vers une cible latine +# (ex. persan → français) doit ressortir en lecture gauche-à-droite +# --------------------------------------------------------------------------- + + +class TestLtrNormalization: + def test_word_rtl_source_latin_target_strips_rtl(self, tmp_path): + """Persan → français : bidi/rtl/bidiVisual hérités de la source + sont retirés (corps, section, note de bas de page, tableau).""" + # 1) Fabrication d'un document RTL complet (cible fa) + t_fa = WordTranslator( + provider=MockTranslationProvider({"Hello": "FA1", "Extra": "FA2"}) + ) + doc = Document() + doc.add_paragraph("Hello") + table = doc.add_table(rows=1, cols=2) + table.cell(0, 0).text = "Extra" + fa_in = tmp_path / "in.docx" + doc.save(fa_in) + _inject_footnotes_part(fa_in) + fa_out = tmp_path / "fa.docx" + t_fa.translate_file(fa_in, fa_out, "fa") + + # Sanity : la sortie fa porte bien les marques RTL + fa_doc = etree.fromstring(_read_zip_entry(fa_out, "word/document.xml")) + assert fa_doc.find(f".//{{{W_NS}}}bidi") is not None + fa_notes = etree.fromstring(_read_zip_entry(fa_out, "word/footnotes.xml")) + assert fa_notes.find(f".//{{{W_NS}}}bidi") is not None + + # 2) Traduction de ce document RTL vers le français + t_fr = WordTranslator( + provider=MockTranslationProvider({"FA1": "Bonjour", "FA2": "ExtraFR"}) + ) + fr_out = tmp_path / "fr.docx" + t_fr.translate_file(fa_out, fr_out, "fr") + + fr_doc = etree.fromstring(_read_zip_entry(fr_out, "word/document.xml")) + assert fr_doc.find(f".//{{{W_NS}}}bidi") is None + assert fr_doc.find(f".//{{{W_NS}}}rtl") is None + assert fr_doc.find(f".//{{{W_NS}}}bidiVisual") is None + fr_notes = etree.fromstring(_read_zip_entry(fr_out, "word/footnotes.xml")) + assert fr_notes.find(f".//{{{W_NS}}}bidi") is None + # La traduction française est bien là + assert "Bonjour" in "".join(t.text or "" for t in fr_doc.iter(f"{{{W_NS}}}t")) + + def test_excel_rtl_source_latin_target_restores_ltr(self, tmp_path): + wb = Workbook() + wb.active["A1"] = "سلام" + wb.active.sheet_view.rightToLeft = True # feuille d'origine persane + input_file = tmp_path / "in.xlsx" + wb.save(input_file) + + out = tmp_path / "out.xlsx" + ExcelTranslator(provider=MockTranslationProvider({"سلام": "Bonjour"})).translate_file( + input_file, out, "fr" + ) + assert not load_workbook(out).active.sheet_view.rightToLeft + + def test_pptx_rtl_source_latin_target_strips_rtl(self, tmp_path): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1)) + para = box.text_frame.paragraphs[0] + para.text = "سلام" + # Simule un paragraphe d'origine persane + pPr = para._p.get_or_add_pPr() + pPr.set("rtl", "1") + input_file = tmp_path / "in.pptx" + prs.save(input_file) + + out = tmp_path / "out.pptx" + PowerPointTranslator( + provider=MockTranslationProvider({"سلام": "Bonjour"}) + ).translate_file(input_file, out, "fr") + + out_para = _find_pptx_paragraph(Presentation(str(out)), "Bonjour") + assert out_para is not None + out_pPr = out_para._p.find(f"{{{A_NS}}}pPr") + assert out_pPr is None or out_pPr.get("rtl") != "1" diff --git a/translators/excel_translator.py b/translators/excel_translator.py index d1abb21..81c1cc8 100644 --- a/translators/excel_translator.py +++ b/translators/excel_translator.py @@ -355,25 +355,35 @@ class ExcelTranslator: except Exception as e: _log_error("excel_sheet_images_failed", sheet_name=sheet_name, error=str(e)) - # RTL targets: flip the sheet view so every sheet reads - # right-to-left (column A renders rightmost). Content and - # formatting are untouched — only the reading direction. + # Normalize the sheet reading direction to the TARGET language. + # RTL targets: flip every sheet so column A renders rightmost. + # Latin targets: restore left-to-right on sheets inherited from + # an RTL source (Persian → French must not keep the RTL view). + # Content and formatting are untouched — only the direction. # Chartsheets have no `sheet_view` and must never break the # save: each sheet is guarded individually. - if is_rtl(target_language): - flipped = 0 - for sheet_name in workbook.sheetnames: - ws = workbook[sheet_name] - try: - if getattr(ws, "sheet_view", None) is not None: - ws.sheet_view.rightToLeft = True - flipped += 1 - except Exception as e: - _log_error( - "excel_rtl_view_failed", sheet=sheet_name, error=str(e) - ) - if flipped: - _log_info("excel_rtl_view_applied", sheets=flipped) + rtl_target = is_rtl(target_language) + flipped = 0 + for sheet_name in workbook.sheetnames: + ws = workbook[sheet_name] + try: + if getattr(ws, "sheet_view", None) is None: + continue + if rtl_target: + ws.sheet_view.rightToLeft = True + flipped += 1 + elif getattr(ws.sheet_view, "rightToLeft", False): + ws.sheet_view.rightToLeft = False + flipped += 1 + except Exception as e: + _log_error( + "excel_direction_view_failed", sheet=sheet_name, error=str(e) + ) + if flipped: + _log_info( + "excel_rtl_view_applied" if rtl_target else "excel_ltr_view_restored", + sheets=flipped, + ) try: workbook.save(output_path) diff --git a/translators/pdf_translator.py b/translators/pdf_translator.py index 9ce19df..f9e761e 100644 --- a/translators/pdf_translator.py +++ b/translators/pdf_translator.py @@ -1063,7 +1063,7 @@ class PDFTranslator: target_size = block["font_size"] color = self._int_to_rgb(block["color"]) - align = fitz.TEXT_ALIGN_RIGHT if is_rtl else fitz.TEXT_ALIGN_LEFT + align = fitz.TEXT_ALIGN_RIGHT if rtl_target else fitz.TEXT_ALIGN_LEFT # PyMuPDF bug: fontname=None raises AttributeError. Default to 'helv'. # If a custom font file is available, use it via fontfile (fontname ignored). diff --git a/translators/pptx_translator.py b/translators/pptx_translator.py index a3ff684..68ae337 100644 --- a/translators/pptx_translator.py +++ b/translators/pptx_translator.py @@ -121,6 +121,46 @@ def _apply_rtl_to_presentation(presentation: Presentation) -> None: _set_pptx_paragraph_rtl(paragraph) +def _unset_pptx_paragraph_rtl(paragraph) -> None: + """Drop a paragraph's inherited RTL direction (Latin targets). + + Translating a Persian/Arabic source into a Latin target must not keep + the source's rtl="1" marks: with rtl gone the paragraph falls back to + the natural left-to-right direction. Alignment attributes are left + untouched (algn is visual in DrawingML — centered stays centered). + """ + pPr = paragraph._p.find(f"{{{_NS_A}}}pPr") + if pPr is not None and pPr.get("rtl") == "1": + pPr.attrib.pop("rtl", None) + + +def _apply_ltr_to_presentation(presentation: Presentation) -> None: + """Strip inherited RTL direction from all slides and notes (Latin targets).""" + for slide in presentation.slides: + for shape in slide.shapes: + _apply_ltr_to_shape(shape) + if slide.has_notes_slide and slide.notes_slide.notes_text_frame is not None: + for paragraph in slide.notes_slide.notes_text_frame.paragraphs: + _unset_pptx_paragraph_rtl(paragraph) + + +def _apply_ltr_to_shape(shape) -> None: + """Recursively strip RTL from a shape (handles groups and tables).""" + if shape.has_text_frame: + for paragraph in shape.text_frame.paragraphs: + _unset_pptx_paragraph_rtl(paragraph) + + if shape.shape_type == MSO_SHAPE_TYPE.TABLE: + for row in shape.table.rows: + for cell in row.cells: + for paragraph in cell.text_frame.paragraphs: + _unset_pptx_paragraph_rtl(paragraph) + + if shape.shape_type == MSO_SHAPE_TYPE.GROUP: + for sub_shape in shape.shapes: + _apply_ltr_to_shape(sub_shape) + + def _apply_rtl_to_shape(shape) -> None: """Recursively apply RTL to a shape (handles groups and tables).""" if shape.has_text_frame: @@ -468,9 +508,14 @@ class PowerPointTranslator: index=i, ) - # Apply RTL layout when the target language is written right-to-left. + # Normalize the direction to the TARGET language: RTL targets + # switch to right-to-left, Latin targets strip the rtl marks + # inherited from an RTL source (Persian → French must read + # left-to-right, not keep the source's layout). if is_rtl(target_language): _apply_rtl_to_presentation(presentation) + else: + _apply_ltr_to_presentation(presentation) # CJK font hint so the target script renders with a proper # typeface instead of shape-dependent fallbacks. diff --git a/translators/word_translator.py b/translators/word_translator.py index 6317dbc..ea1fafc 100644 --- a/translators/word_translator.py +++ b/translators/word_translator.py @@ -282,6 +282,94 @@ def _apply_bidi_to_part_tree(root) -> int: return switched +def _normalize_part_direction(root, rtl: bool) -> int: + """ + Normalize the direction of a footnotes/endnotes/comments tree. + + RTL target: every paragraph/run/table switches to RTL (same marks as + the document body). Latin target: inherited RTL marks from an RTL + source are stripped so the translated notes read left-to-right. + Returns the number of paragraphs switched (RTL) or marks removed (LTR). + """ + if rtl: + return _apply_bidi_to_part_tree(root) + return _strip_rtl_marks(root) + + +def _strip_rtl_marks(root) -> int: + """ + Remove every RTL direction mark under an element tree (LTR targets). + + Translating a Persian/Arabic source into a Latin target must not keep + the source's right-to-left layout: w:bidi (paragraph/section + direction), w:rtl (run-level marks, including paragraph marks inside + w:pPr/w:rPr) and w:bidiVisual (mirrored tables) are stripped so the + translated document reads left-to-right, exactly like a document + written in the target language. + + Works on python-docx trees and plain lxml trees (notes/comments + parts) alike. Returns the number of marks removed. + """ + removed = 0 + # Materialize the iteration first: removing children while a live + # lxml iterator walks the tree makes it skip elements (seen in + # practice: some w:bidi survived the strip). + for el in list(root.iter()): + if el.tag == qn("w:pPr") or el.tag == qn("w:sectPr"): + bidi = el.find(qn("w:bidi")) + if bidi is not None: + el.remove(bidi) + removed += 1 + elif el.tag == qn("w:rPr"): + rtl = el.find(qn("w:rtl")) + if rtl is not None: + el.remove(rtl) + removed += 1 + elif el.tag == qn("w:tblPr"): + bidi_visual = el.find(qn("w:bidiVisual")) + if bidi_visual is not None: + el.remove(bidi_visual) + removed += 1 + return removed + + +def _apply_ltr_to_document(document: Document) -> None: + """ + Force left-to-right direction on the whole document (Latin targets). + + Mirror of _apply_rtl_to_document: an RTL source translated to a Latin + target must end up reading left-to-right, not keep the source's RTL + layout. Stripping alone is enough — with w:bidi gone, paragraphs fall + back to the natural LTR start edge, and explicit alignments (center, + justify) keep their meaning. + """ + stripped = _strip_rtl_marks(document.element) + + for section in document.sections: + headers_footers = ( + section.header, + section.footer, + getattr(section, "first_page_header", None), + getattr(section, "first_page_footer", None), + getattr(section, "even_page_header", None), + getattr(section, "even_page_footer", None), + ) + for hf in headers_footers: + if hf is None: + continue + root = getattr(hf, "_element", None) + if root is None: + paras = hf.paragraphs + if paras: + root = paras[0]._p.getroottree().getroot() + if root is not None: + stripped += _strip_rtl_marks(root) + + if stripped: + from core.logging import get_logger as _gl + _gl(__name__).info("word_ltr_normalized", marks_removed=stripped) + + def _apply_rtl_to_document(document: Document) -> None: """Apply RTL direction to every paragraph and section in the document.""" body = document.element.body @@ -577,9 +665,14 @@ class WordTranslator: index=i, ) - # Apply RTL layout when the target language is written right-to-left. + # Normalize the document direction to the TARGET language: + # RTL targets switch to right-to-left, Latin targets strip the + # RTL marks inherited from an RTL source (Persian → French + # must read left-to-right, not keep the source's layout). if is_rtl(target_language): _apply_rtl_to_document(document) + else: + _apply_ltr_to_document(document) # CJK / Arabic-script font hints so Word renders the target # script with a proper typeface instead of per-run fallbacks. @@ -1046,8 +1139,7 @@ class WordTranslator: # setters hold references to t_elems inside this tree. def write_footnotes_back(output_path: Path) -> None: try: - if rtl: - _apply_bidi_to_part_tree(footnotes_xml) + _normalize_part_direction(footnotes_xml, rtl) new_blob = etree.tostring( footnotes_xml, xml_declaration=True, @@ -1114,8 +1206,7 @@ class WordTranslator: if text_elements and post_save_callbacks is not None: def write_endnotes_back(output_path: Path) -> None: try: - if rtl: - _apply_bidi_to_part_tree(endnotes_xml) + _normalize_part_direction(endnotes_xml, rtl) new_blob = etree.tostring( endnotes_xml, xml_declaration=True, @@ -1170,8 +1261,7 @@ class WordTranslator: if collected and post_save_callbacks is not None: def write_comments_back(output_path: Path) -> None: try: - if rtl: - _apply_bidi_to_part_tree(comments_xml) + _normalize_part_direction(comments_xml, rtl) new_blob = etree.tostring( comments_xml, xml_declaration=True,