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
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user