From ffbd85a7b656f82bc6e31cda3b972b15fae2c002 Mon Sep 17 00:00:00 2001 From: sepehr Date: Tue, 1 Sep 2026 20:45:05 +0200 Subject: [PATCH] fix(rtl): les textes centrs en persan s'affichaient du mauvais cote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Word: sous w:bidi, w:jc est logique (left=dbut=droite visuelle, right=fin=gauche visuelle) ; forcer jc=right alignait donc les paragraphes a gauche et crasait le centrage hrit du style. Dsormais le RTL pose w:bidi et w:rtl sans jamais toucher w:jc, comme le fait Word lui-mme pour un document RTL. - PowerPoint: algn n'est plus crit quand le paragraphe n'en dfinit pas, afin de respecter l'alignement hrit du masque (titres centrs) ; algn=l explicite reste converti en r (algn est visuel en DrawingML). - tests: centrage par style Word prserv, aucun w:jic crit, algn absent non cras côté PowerPoint --- tests/test_translators/test_rtl_layout.py | 52 ++++++++++++++++++++++- translators/pptx_translator.py | 14 ++++-- translators/word_translator.py | 23 +++++----- 3 files changed, 70 insertions(+), 19 deletions(-) diff --git a/tests/test_translators/test_rtl_layout.py b/tests/test_translators/test_rtl_layout.py index 238f240..7c48850 100644 --- a/tests/test_translators/test_rtl_layout.py +++ b/tests/test_translators/test_rtl_layout.py @@ -22,6 +22,7 @@ from pathlib import Path import pytest from docx import Document +from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_ALIGN_PARAGRAPH from lxml import etree from openpyxl import Workbook, load_workbook @@ -447,6 +448,46 @@ class TestWordRtlLayout: ) assert marker in part_text, part_name + def test_fa_target_never_writes_alignment(self, tmp_path): + """Reported bug: centered Persian text rendered on the wrong side. + + Under w:bidi, Word interprets w:jc logically: "right" means the + END of the line — the visual LEFT for RTL. Writing jc="right" + pushes unaligned paragraphs to the left and overrides style-level + centering. RTL must add w:bidi (+ run w:rtl) and never touch + w:jc: unaligned paragraphs start at the right edge, explicit + "left" resolves to the right edge, and centering stays centering. + """ + provider = MockTranslationProvider( + {"Plain body": "FA1", "Styled centered title": "FA2"} + ) + translator = WordTranslator(provider=provider) + + doc = Document() + doc.add_paragraph("Plain body") # no alignment anywhere + style = doc.styles.add_style("CenteredCustom", WD_STYLE_TYPE.PARAGRAPH) + style.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER + doc.add_paragraph("Styled centered title", style=style) # centered via style + + input_file = tmp_path / "input.docx" + doc.save(input_file) + output_file = tmp_path / "output.docx" + translator.translate_file(input_file, output_file, "fa") + + doc_out = Document(output_file) + for para in doc_out.paragraphs: + pPr = para._p.find(f"{{{W_NS}}}pPr") + assert pPr is not None + assert pPr.find(f"{{{W_NS}}}bidi") is not None + assert pPr.find(f"{{{W_NS}}}jc") is None, "RTL must never write w:jc" + + # The style-level centering still resolves to centered + assert ( + doc_out.paragraphs[1].style.paragraph_format.alignment + == WD_ALIGN_PARAGRAPH.CENTER + ) + assert "FA2" in doc_out.paragraphs[1].text + def test_fr_target_no_rtl_attributes(self, tmp_path): """A non-RTL target produces no bidi/bidiVisual/rtl markup.""" provider = MockTranslationProvider({"Body text": "FR_Body"}) @@ -552,7 +593,14 @@ class TestPptxRtlLayout: assert pPr.get("rtl") == "1" assert pPr.get("algn") == "just", "justified alignment must be preserved" - def test_alignment_absent_becomes_right(self, tmp_path): + def test_alignment_absent_inherits_from_layout(self, tmp_path): + """Reported bug: centered Persian text rendered on the wrong side. + + When algn is absent the paragraph inherits its alignment from + the layout/master (often centered titles); writing algn="r" + overrides that inheritance. rtl="1" alone already renders the + paragraph from its start edge (right), so algn stays untouched. + """ provider = MockTranslationProvider({"Plain text": "AR_Plain"}) translator = PowerPointTranslator(provider=provider) input_file = self._build_single_text_pptx(tmp_path, "Plain text") @@ -564,7 +612,7 @@ class TestPptxRtlLayout: pPr = para._p.find(f"{{{A_NS}}}pPr") assert pPr is not None assert pPr.get("rtl") == "1" - assert pPr.get("algn") == "r" + assert pPr.get("algn") is None, "inherited alignment must not be overridden" def test_alignment_left_becomes_right(self, tmp_path): provider = MockTranslationProvider({"Left text": "AR_Left"}) diff --git a/translators/pptx_translator.py b/translators/pptx_translator.py index e2e2bcd..23bd36e 100644 --- a/translators/pptx_translator.py +++ b/translators/pptx_translator.py @@ -73,9 +73,15 @@ def _set_pptx_paragraph_rtl(paragraph) -> None: Enable RTL mode on a PowerPoint paragraph. Sets rtl="1" on the element, which controls the paragraph - text direction. The horizontal alignment (algn) is only forced to - "r" when the paragraph has no explicit alignment or is aligned - "l" (left) — a centered or justified title keeps its alignment. + text direction; with rtl="1" and no explicit algn, PowerPoint + renders the paragraph from its start edge — the right edge. + + algn is only rewritten when the paragraph explicitly says "l": in + DrawingML algn is visual (not logical like Word's w:jc), so an + explicit left would keep RTL text on the left edge. When algn is + absent, the inherited layout/master alignment (often centered + titles) must keep playing — writing "r" here would override it, + which is the centered-text mis-rendering reported for Persian. """ p_elem = paragraph._p tag_pPr = f"{{{_NS_A}}}pPr" @@ -84,7 +90,7 @@ def _set_pptx_paragraph_rtl(paragraph) -> None: pPr = etree.Element(tag_pPr) p_elem.insert(0, pPr) pPr.set("rtl", "1") - if pPr.get("algn") in (None, "", "l"): + if pPr.get("algn") == "l": pPr.set("algn", "r") diff --git a/translators/word_translator.py b/translators/word_translator.py index 7827710..196ffb3 100644 --- a/translators/word_translator.py +++ b/translators/word_translator.py @@ -153,8 +153,6 @@ _PPR_BIDI_SUCCESSORS = ( qn("w:sectPr"), qn("w:pPrChange"), ) -# Elements that must come AFTER w:jc inside w:pPr (w:jc itself excluded). -_PPR_JC_SUCCESSORS = _PPR_BIDI_SUCCESSORS[_PPR_BIDI_SUCCESSORS.index(qn("w:jc")) + 1 :] # Elements that must come AFTER w:rtl inside w:rPr. _RPR_RTL_SUCCESSORS = ( qn("w:cs"), @@ -205,11 +203,18 @@ def _set_paragraph_rtl(paragraph: Paragraph) -> None: Sets: - w:pPr/w:bidi → paragraph text direction = RTL - - w:pPr/w:jc → mirrored alignment (left→right), ONLY when the - paragraph has no explicit alignment — centered/justified titles - must not be forced right-aligned. - w:rPr/w:rtl → run-level RTL marker for each run + w:jc is deliberately left untouched. Under w:bidi, Word interprets + w:jc LOGICALLY (start/end), not visually: "left" means the start — + the RIGHT edge for an RTL paragraph — and "right" means the end, the + LEFT edge. Forcing jc="right" therefore aligns paragraphs to the + visual left and overrides style-level centering, which is exactly + the mis-rendering reported for centered Persian text. With bidi + alone: unaligned paragraphs start at the right edge, "left" resolves + to the right edge, and center/justify stay centered/justified — the + same markup Word itself writes for RTL documents. + Every element is inserted at its schema position inside w:pPr/w:rPr (OOXML requires a fixed child order; appended-at-the-end elements make Word flag the file for repair). @@ -219,14 +224,6 @@ def _set_paragraph_rtl(paragraph: Paragraph) -> None: if pPr.find(qn("w:bidi")) is None: _insert_ordered(pPr, OxmlElement("w:bidi"), _PPR_BIDI_SUCCESSORS) - jc = pPr.find(qn("w:jc")) - explicit_alignment = jc is not None and jc.get(qn("w:val")) not in (None, "", "left") - if not explicit_alignment: - if jc is None: - jc = OxmlElement("w:jc") - _insert_ordered(pPr, jc, _PPR_JC_SUCCESSORS) - jc.set(qn("w:val"), "right") - for run in paragraph.runs: rPr = run._r.get_or_add_rPr() if rPr.find(qn("w:rtl")) is None: