feat(rtl): rendu droite-a-gauche complet pour Word, PowerPoint, Excel et PDF
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m37s

- source unique RTL_LANGUAGES/is_rtl dans core/languages.py (fin des 3 copies)
- Word: bidi partout (corps, tableaux bidiVisual, notes/fin/commentaires,
  zones de texte, 6 zones d'en-tetes/pieds), insertion OOXML ordonnee,
  polices cs elargies aux 11 langues
- PowerPoint: alignements explicites preserves, notes du presentateur,
  indice de police <a:cs> insert a sa place
- Excel: feuilles affichees de droite a gauche, feuilles graphiques ignorees
- PDF: faconnage bidi (arabic-reshaper + python-bidi), polices par ecriture
  (arabe/hebreu), TTF enregistree pour le PDF recompose
- 46 tests nouveaux (tests/test_translators/test_rtl_layout.py), 253 au total
This commit is contained in:
2026-09-01 20:22:46 +02:00
parent fddd7b7428
commit f22f645fab
12 changed files with 1727 additions and 80 deletions

View File

@@ -25,11 +25,7 @@ from docx.section import Section
from lxml import etree
from services.providers.base import TranslationProvider
# Languages written right-to-left
RTL_LANGUAGES: frozenset = frozenset(
{"ar", "he", "fa", "ur", "ku", "ps", "ug", "sd", "yi", "dv", "ckb"}
)
from core.languages import is_rtl
# East-Asian / complex-script font hints: when the target language uses
# glyphs a Latin theme font lacks, Word falls back to a substitute —
@@ -47,6 +43,13 @@ CS_FONTS: dict = {
"he": "Arial",
"fa": "Arial",
"ur": "Arial",
"ps": "Arial",
"ku": "Arial",
"sd": "Arial",
"ug": "Arial",
"yi": "Arial",
"dv": "Arial",
"ckb": "Arial",
}
@@ -123,6 +126,79 @@ def _log_error(event: str, **kwargs):
logger.error(msg)
# --- OOXML schema order ---------------------------------------------------
# w:pPr, w:rPr and w:tblPr require their children in a FIXED order.
# Appending at the end produces out-of-order XML that Word "repairs" on
# open (with a repair prompt in some versions). Each RTL element is
# therefore inserted just before its first successor element, per the
# CT_PPr / CT_RPr / CT_TblPr sequences of the schema.
# Elements that must come AFTER w:bidi inside w:pPr.
_PPR_BIDI_SUCCESSORS = (
qn("w:adjustRightInd"),
qn("w:snapToGrid"),
qn("w:spacing"),
qn("w:ind"),
qn("w:contextualSpacing"),
qn("w:mirrorIndents"),
qn("w:suppressOverlap"),
qn("w:jc"),
qn("w:textDirection"),
qn("w:textAlignment"),
qn("w:textboxTightWrap"),
qn("w:outlineLvl"),
qn("w:divId"),
qn("w:cnfStyle"),
qn("w:rPr"),
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"),
qn("w:em"),
qn("w:lang"),
qn("w:eastAsianLayout"),
qn("w:specVanish"),
qn("w:oMath"),
qn("w:rPrChange"),
)
# Elements that must come AFTER w:bidiVisual inside w:tblPr.
_TBLPR_BIDIVISUAL_SUCCESSORS = (
qn("w:tblStyleRowBandSize"),
qn("w:tblStyleColBandSize"),
qn("w:tblW"),
qn("w:jc"),
qn("w:tblCellSpacing"),
qn("w:tblInd"),
qn("w:tblBorders"),
qn("w:shd"),
qn("w:tblLayout"),
qn("w:tblCellMar"),
qn("w:tblLook"),
qn("w:tblCaption"),
qn("w:tblDescription"),
qn("w:tblPrChange"),
)
def _insert_ordered(parent, child, successors) -> None:
"""Insert child into parent at its schema position.
The child is placed just before the first existing element whose tag
is listed in ``successors`` (the elements that must come after it in
the OOXML sequence); appended at the end when no successor exists.
Works on python-docx oxml elements and plain lxml elements alike.
"""
for existing in parent:
if existing.tag in successors:
existing.addprevious(child)
return
parent.append(child)
def _set_paragraph_rtl(paragraph: Paragraph) -> None:
"""
Enable RTL mode on a paragraph and all its runs.
@@ -133,37 +209,97 @@ def _set_paragraph_rtl(paragraph: Paragraph) -> None:
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
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).
"""
pPr = paragraph._p.get_or_add_pPr()
if pPr.find(qn("w:bidi")) is None:
pPr.append(OxmlElement("w:bidi"))
_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")
pPr.append(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:
rPr.append(OxmlElement("w:rtl"))
_insert_ordered(rPr, OxmlElement("w:rtl"), _RPR_RTL_SUCCESSORS)
def _set_table_rtl(tbl_element) -> None:
"""
Mark a table as visually right-to-left.
w:bidiVisual inside w:tblPr flips the column order at render time:
the first column displays rightmost. Works on python-docx CT_Tbl
elements and on plain lxml elements (notes/comments trees) alike.
"""
tblPr = tbl_element.find(qn("w:tblPr"))
if tblPr is None:
tblPr = OxmlElement("w:tblPr")
tbl_element.insert(0, tblPr)
if tblPr.find(qn("w:bidiVisual")) is None:
_insert_ordered(
tblPr, OxmlElement("w:bidiVisual"), _TBLPR_BIDIVISUAL_SUCCESSORS
)
def _apply_bidi_to_part_tree(root) -> int:
"""
Switch every paragraph, run and table of a footnotes/endnotes/comments
tree to RTL.
These parts are rewritten as raw XML after the document save (they
live outside python-docx's object model), so the RTL direction is
injected into the same in-memory tree just before write-back:
w:bidi on every paragraph, w:rtl on every run, w:bidiVisual on the
tables these parts may contain.
Returns the number of paragraphs switched to RTL.
"""
switched = 0
for p in root.iter(qn("w:p")):
pPr = p.find(qn("w:pPr"))
if pPr is None:
pPr = OxmlElement("w:pPr")
p.insert(0, pPr)
if pPr.find(qn("w:bidi")) is None:
_insert_ordered(pPr, OxmlElement("w:bidi"), _PPR_BIDI_SUCCESSORS)
switched += 1
for r in p.iter(qn("w:r")):
rPr = r.find(qn("w:rPr"))
if rPr is None:
rPr = OxmlElement("w:rPr")
r.insert(0, rPr)
if rPr.find(qn("w:rtl")) is None:
_insert_ordered(rPr, OxmlElement("w:rtl"), _RPR_RTL_SUCCESSORS)
for tbl in root.iter(qn("w:tbl")):
_set_table_rtl(tbl)
return switched
def _apply_rtl_to_document(document: Document) -> None:
"""Apply RTL direction to every paragraph and section in the document."""
# Body paragraphs
for para in document.paragraphs:
_set_paragraph_rtl(para)
# Body tables
for table in document.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
_set_paragraph_rtl(para)
body = document.element.body
# Every paragraph of the body tree in ONE pass: top-level paragraphs,
# table cells (nested tables included), text boxes (w:txbxContent)
# and SDT content all receive w:bidi + run-level w:rtl.
for p in body.iter(qn("w:p")):
_set_paragraph_rtl(Paragraph(p, document))
# Tables: w:bidiVisual flips the visual column order. iter() covers
# nested tables too.
for tbl in body.iter(qn("w:tbl")):
_set_table_rtl(tbl)
# Headers, footers, and section-level RTL (page layout direction)
for section in document.sections:
# Set the section (page) direction to RTL so Word renders margins,
@@ -172,14 +308,37 @@ def _apply_rtl_to_document(document: Document) -> None:
if sectPr.find(qn("w:bidi")) is None:
sectPr.append(OxmlElement("w:bidi"))
for hf in (section.header, section.footer):
for para in hf.paragraphs:
_set_paragraph_rtl(para)
for table in hf.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
_set_paragraph_rtl(para)
# Same six header/footer zones the text collection translates
# (default, first page, even pages) — a translated first-page
# header must not stay left-to-right. Some may be missing
# depending on the python-docx version: getattr defensively.
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
# Touch paragraphs first so a linked/missing definition is
# created exactly like the paragraph walk below would.
paras = hf.paragraphs
root = getattr(hf, "_element", None)
if root is None and paras:
root = paras[0]._p.getroottree().getroot()
if root is not None:
# Full story tree: paragraphs, tables (bidiVisual) and
# text boxes inside the header/footer.
for p in root.iter(qn("w:p")):
_set_paragraph_rtl(Paragraph(p, document))
for tbl in root.iter(qn("w:tbl")):
_set_table_rtl(tbl)
else:
for para in paras:
_set_paragraph_rtl(para)
class WordProcessorError(Exception):
@@ -324,7 +483,9 @@ class WordTranslator:
# that python-docx doesn't manage (footnotes, endnotes).
post_save_callbacks: List[Callable[[Path], None]] = []
self._collect_from_body(document, text_elements, post_save_callbacks)
self._collect_from_body(
document, text_elements, post_save_callbacks, rtl=is_rtl(target_language)
)
# Collect chart text from ZIP (chart titles, axis labels, series names)
self._collect_charts_from_zip(input_path, text_elements, chart_translations)
@@ -420,7 +581,7 @@ class WordTranslator:
)
# Apply RTL layout when the target language is written right-to-left.
if target_language.lower() in RTL_LANGUAGES:
if is_rtl(target_language):
_apply_rtl_to_document(document)
# CJK / Arabic-script font hints so Word renders the target
@@ -677,6 +838,7 @@ class WordTranslator:
def _collect_from_body(
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]],
post_save_callbacks: List[Callable[[Path], None]] = None,
rtl: bool = False,
) -> None:
"""Collect all text elements from document body.
@@ -711,9 +873,9 @@ class WordTranslator:
# Pass 3: footnotes, endnotes and comments (live in separate parts)
if post_save_callbacks is None:
post_save_callbacks = []
self._collect_from_footnotes(document, text_elements, post_save_callbacks)
self._collect_from_endnotes(document, text_elements, post_save_callbacks)
self._collect_from_comments(document, text_elements, post_save_callbacks)
self._collect_from_footnotes(document, text_elements, post_save_callbacks, rtl=rtl)
self._collect_from_endnotes(document, text_elements, post_save_callbacks, rtl=rtl)
self._collect_from_comments(document, text_elements, post_save_callbacks, rtl=rtl)
total = len(text_elements) - count_before
_log_info(
@@ -811,6 +973,7 @@ class WordTranslator:
def _collect_from_footnotes(
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]],
post_save_callbacks: List[Callable[[Path], None]] = None,
rtl: bool = False,
) -> None:
"""Collect text from footnotes.
@@ -856,6 +1019,8 @@ 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)
new_blob = etree.tostring(
footnotes_xml,
xml_declaration=True,
@@ -894,6 +1059,7 @@ class WordTranslator:
def _collect_from_endnotes(
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]],
post_save_callbacks: List[Callable[[Path], None]] = None,
rtl: bool = False,
) -> None:
"""Collect text from endnotes (python-docx 1.x compatible).
@@ -921,6 +1087,8 @@ 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)
new_blob = etree.tostring(
endnotes_xml,
xml_declaration=True,
@@ -944,6 +1112,7 @@ class WordTranslator:
def _collect_from_comments(
self, document: Document, text_elements: List[Tuple[str, Callable[[str], None]]],
post_save_callbacks: List[Callable[[Path], None]] = None,
rtl: bool = False,
) -> None:
"""Collect text from comments/balloons (word/comments.xml part).
@@ -974,6 +1143,8 @@ 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)
new_blob = etree.tostring(
comments_xml,
xml_declaration=True,