fix(rtl): les textes centrs en persan s'affichaient du mauvais cote
- 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
This commit is contained in:
@@ -22,6 +22,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from docx import Document
|
from docx import Document
|
||||||
|
from docx.enum.style import WD_STYLE_TYPE
|
||||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
from openpyxl import Workbook, load_workbook
|
from openpyxl import Workbook, load_workbook
|
||||||
@@ -447,6 +448,46 @@ class TestWordRtlLayout:
|
|||||||
)
|
)
|
||||||
assert marker in part_text, part_name
|
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):
|
def test_fr_target_no_rtl_attributes(self, tmp_path):
|
||||||
"""A non-RTL target produces no bidi/bidiVisual/rtl markup."""
|
"""A non-RTL target produces no bidi/bidiVisual/rtl markup."""
|
||||||
provider = MockTranslationProvider({"Body text": "FR_Body"})
|
provider = MockTranslationProvider({"Body text": "FR_Body"})
|
||||||
@@ -552,7 +593,14 @@ class TestPptxRtlLayout:
|
|||||||
assert pPr.get("rtl") == "1"
|
assert pPr.get("rtl") == "1"
|
||||||
assert pPr.get("algn") == "just", "justified alignment must be preserved"
|
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"})
|
provider = MockTranslationProvider({"Plain text": "AR_Plain"})
|
||||||
translator = PowerPointTranslator(provider=provider)
|
translator = PowerPointTranslator(provider=provider)
|
||||||
input_file = self._build_single_text_pptx(tmp_path, "Plain text")
|
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")
|
pPr = para._p.find(f"{{{A_NS}}}pPr")
|
||||||
assert pPr is not None
|
assert pPr is not None
|
||||||
assert pPr.get("rtl") == "1"
|
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):
|
def test_alignment_left_becomes_right(self, tmp_path):
|
||||||
provider = MockTranslationProvider({"Left text": "AR_Left"})
|
provider = MockTranslationProvider({"Left text": "AR_Left"})
|
||||||
|
|||||||
@@ -73,9 +73,15 @@ def _set_pptx_paragraph_rtl(paragraph) -> None:
|
|||||||
Enable RTL mode on a PowerPoint paragraph.
|
Enable RTL mode on a PowerPoint paragraph.
|
||||||
|
|
||||||
Sets rtl="1" on the <a:pPr> element, which controls the paragraph
|
Sets rtl="1" on the <a:pPr> element, which controls the paragraph
|
||||||
text direction. The horizontal alignment (algn) is only forced to
|
text direction; with rtl="1" and no explicit algn, PowerPoint
|
||||||
"r" when the paragraph has no explicit alignment or is aligned
|
renders the paragraph from its start edge — the right edge.
|
||||||
"l" (left) — a centered or justified title keeps its alignment.
|
|
||||||
|
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
|
p_elem = paragraph._p
|
||||||
tag_pPr = f"{{{_NS_A}}}pPr"
|
tag_pPr = f"{{{_NS_A}}}pPr"
|
||||||
@@ -84,7 +90,7 @@ def _set_pptx_paragraph_rtl(paragraph) -> None:
|
|||||||
pPr = etree.Element(tag_pPr)
|
pPr = etree.Element(tag_pPr)
|
||||||
p_elem.insert(0, pPr)
|
p_elem.insert(0, pPr)
|
||||||
pPr.set("rtl", "1")
|
pPr.set("rtl", "1")
|
||||||
if pPr.get("algn") in (None, "", "l"):
|
if pPr.get("algn") == "l":
|
||||||
pPr.set("algn", "r")
|
pPr.set("algn", "r")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -153,8 +153,6 @@ _PPR_BIDI_SUCCESSORS = (
|
|||||||
qn("w:sectPr"),
|
qn("w:sectPr"),
|
||||||
qn("w:pPrChange"),
|
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.
|
# Elements that must come AFTER w:rtl inside w:rPr.
|
||||||
_RPR_RTL_SUCCESSORS = (
|
_RPR_RTL_SUCCESSORS = (
|
||||||
qn("w:cs"),
|
qn("w:cs"),
|
||||||
@@ -205,11 +203,18 @@ def _set_paragraph_rtl(paragraph: Paragraph) -> None:
|
|||||||
|
|
||||||
Sets:
|
Sets:
|
||||||
- w:pPr/w:bidi → paragraph text direction = RTL
|
- 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: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
|
Every element is inserted at its schema position inside w:pPr/w:rPr
|
||||||
(OOXML requires a fixed child order; appended-at-the-end elements
|
(OOXML requires a fixed child order; appended-at-the-end elements
|
||||||
make Word flag the file for repair).
|
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:
|
if pPr.find(qn("w:bidi")) is None:
|
||||||
_insert_ordered(pPr, OxmlElement("w:bidi"), _PPR_BIDI_SUCCESSORS)
|
_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:
|
for run in paragraph.runs:
|
||||||
rPr = run._r.get_or_add_rPr()
|
rPr = run._r.get_or_add_rPr()
|
||||||
if rPr.find(qn("w:rtl")) is None:
|
if rPr.find(qn("w:rtl")) is None:
|
||||||
|
|||||||
Reference in New Issue
Block a user