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

@@ -23,6 +23,7 @@ from openpyxl.cell.cell import Cell
from openpyxl.utils import get_column_letter
from services.providers.base import TranslationProvider
from core.languages import is_rtl
from core.logging import get_logger
@@ -354,6 +355,26 @@ 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.
# 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)
try:
workbook.save(output_path)
except Exception as e:
@@ -580,7 +601,14 @@ class ExcelTranslator:
) -> None:
"""Collect all translatable text from worksheet cells, cell comments,
and cell hyperlinks (URLs are preserved as-is, but their display
labels are translated)."""
labels are translated).
Chartsheets are skipped: they have no cell grid (no iter_rows)
— their text lives in the chart XML, collected separately from
the ZIP.
"""
if not hasattr(worksheet, "iter_rows"):
return
for row in worksheet.iter_rows():
for cell in row:
if cell.value is not None:

View File

@@ -33,10 +33,12 @@ Scanned PDFs:
import time
import shutil
import subprocess
import zlib
from pathlib import Path
from typing import Dict, Any, Optional, Callable, List
from core.logging import get_logger
from core.languages import is_rtl
logger = get_logger(__name__)
@@ -60,8 +62,70 @@ HEADING_MIN_SCALE = 0.90
# Maximum font shrink for body text
BODY_MIN_SCALE = 0.75
# RTL language codes
RTL_LANGUAGES = frozenset({"ar", "he", "fa", "ur", "ku", "ps", "ug", "sd", "yi", "dv", "ckb"})
# Warn at most once when the RTL shaping libraries are missing.
_RTL_SHAPING_MISSING_WARNED = False
def _shape_rtl(text: str) -> str:
"""Shape Arabic-script text for PDF engines that do no bidi of their own.
arabic-reshaper joins the letters into their contextual forms and
python-bidi reorders the string for visual (left-to-right) rendering.
Both libraries are optional on purpose: when they are missing or
fail, the text is returned unchanged with a warning — the
translation itself must always succeed, only the rendering degrades.
"""
global _RTL_SHAPING_MISSING_WARNED
if not text:
return text
try:
import arabic_reshaper
from bidi.algorithm import get_display
except Exception:
if not _RTL_SHAPING_MISSING_WARNED:
logger.warning(
"rtl_shaping_libs_missing",
hint="pip install arabic-reshaper python-bidi for correct RTL rendering",
)
_RTL_SHAPING_MISSING_WARNED = True
return text
try:
return get_display(arabic_reshaper.reshape(text))
except Exception as e:
logger.warning("rtl_shaping_failed", error=str(e)[:200])
return text
# Font path → reportlab font name, for RTL fonts registered in this
# process. A fixed name would make two consecutive translations with
# different fonts (e.g. Arabic then Hebrew) overwrite each other's
# registration.
_RTL_FONT_REGISTRATIONS: Dict[str, str] = {}
def _register_rtl_font(font_path: str) -> str:
"""Register a TTF under a stable per-path name, once per process.
Returns the reportlab font name, or "Helvetica" when registration
fails (warning logged — a font problem must never fail the job).
"""
registered = _RTL_FONT_REGISTRATIONS.get(font_path)
if registered:
return registered
try:
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
digest = format(zlib.crc32(str(font_path).encode("utf-8")) & 0xFFFFFFFF, "08x")
name = f"WordlyRTL-{digest}"
pdfmetrics.registerFont(TTFont(name, font_path))
_RTL_FONT_REGISTRATIONS[font_path] = name
return name
except Exception as e:
logger.warning(
"pdf_rtl_font_register_failed", error=str(e)[:200], font=font_path
)
return "Helvetica"
def _record_format_loss_metric(element_type: str, count: int = 1) -> None:
@@ -109,6 +173,29 @@ def _libreoffice_available() -> bool:
_libreoffice_available._cache = None # type: ignore[attr-defined]
# Script groups among the RTL languages — the preferred font depends on
# the writing system, not just the direction: a Hebrew target would
# render as tofu with an Arabic-only font.
_ARABIC_SCRIPT_LANGUAGES = frozenset(
{"ar", "fa", "ur", "ps", "ku", "sd", "ug", "ckb"}
)
_HEBREW_SCRIPT_LANGUAGES = frozenset({"he", "yi"})
def _rtl_script(code: str) -> str:
"""Script group of an RTL language code: "arabic", "hebrew" or ""
("" meaning: use the generic Unicode font list — e.g. Thaana "dv",
for which no dedicated font is searched).
"""
base = (code or "").strip().replace("_", "-").split("-")[0].lower()
if base in _ARABIC_SCRIPT_LANGUAGES:
return "arabic"
if base in _HEBREW_SCRIPT_LANGUAGES:
return "hebrew"
return ""
class PDFTranslator:
"""Translates PDF files with layout preservation using PyMuPDF."""
@@ -133,9 +220,37 @@ class PDFTranslator:
"/System/Library/Fonts/Helvetica.ttc",
]
# Arabic-script-capable fonts (ar, fa, ur, ps, ku, sd, ug, ckb),
# searched FIRST for those targets. Debian's fonts-noto package
# ships Noto Naskh Arabic and Noto Sans Arabic under
# /usr/share/fonts/truetype/noto/; on Windows, Arial covers the
# whole Arabic script.
_RTL_ARABIC_FONT_SEARCH_PATHS = [
"/usr/share/fonts/truetype/noto/NotoNaskhArabic-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf",
"/usr/share/fonts/opentype/noto/NotoNaskhArabic-Regular.ttf",
"/usr/share/fonts/opentype/noto/NotoSansArabic-Regular.ttf",
"/app/fonts/NotoNaskhArabic-Regular.ttf",
"/app/fonts/NotoSansArabic-Regular.ttf",
"C:/Windows/Fonts/arial.ttf",
]
# Hebrew-script-capable fonts (he, yi), searched FIRST for those
# targets. Same Debian package provides Noto Sans Hebrew; Arial
# covers Hebrew on Windows.
_RTL_HEBREW_FONT_SEARCH_PATHS = [
"/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf",
"/usr/share/fonts/opentype/noto/NotoSansHebrew-Regular.ttf",
"/app/fonts/NotoSansHebrew-Regular.ttf",
"C:/Windows/Fonts/arial.ttf",
]
def __init__(self, provider=None):
self._provider = provider
self._font_path: Optional[str] = None
# script group ("arabic"/"hebrew"/"") → resolved font path, so a
# single instance can serve Arabic and Hebrew jobs in a row.
self._rtl_font_paths: Dict[str, str] = {}
self._translation_stats = {"attempted": 0, "changed": 0}
self._custom_prompt: Optional[str] = None
# OCR overrides (admin settings); None → fall back to config.MISTRAL_*
@@ -184,14 +299,56 @@ class PDFTranslator:
recorder = getattr(self, "_segment_recorder", None)
return recorder.get_pairs() if recorder is not None else []
def _get_font_path(self) -> Optional[str]:
"""Resolve a Unicode-capable TTF/OTF font file."""
if self._font_path is not None:
return self._font_path
for p in self._FONT_SEARCH_PATHS:
@staticmethod
def _first_existing_font(paths) -> Optional[str]:
for p in paths:
if Path(p).exists():
self._font_path = p
return p
return None
def _get_font_path(self, target_language: str = "") -> Optional[str]:
"""Resolve a Unicode-capable TTF/OTF font file.
RTL targets first search a font covering THEIR script — Noto
Naskh/Sans Arabic for the Arabic script (ar, fa, ur…), Noto Sans
Hebrew for Hebrew (he, yi); other RTL scripts (Thaana "dv") use
the generic Unicode list directly. A missing script font falls
back to the generic list with a warning — a missing font
degrades the rendering but never fails the job. Non-RTL targets
use the generic list only (unchanged behavior).
"""
if not is_rtl(target_language):
if self._font_path:
return self._font_path
found = self._first_existing_font(self._FONT_SEARCH_PATHS)
if not found:
logger.warning("no_unicode_font_found")
return None
self._font_path = found
return found
script = _rtl_script(target_language)
cached = self._rtl_font_paths.get(script)
if cached:
return cached
if script == "arabic":
preferred = self._RTL_ARABIC_FONT_SEARCH_PATHS
elif script == "hebrew":
preferred = self._RTL_HEBREW_FONT_SEARCH_PATHS
else:
preferred = []
found = self._first_existing_font(preferred + self._FONT_SEARCH_PATHS)
if found:
if preferred and found not in preferred:
logger.warning(
"rtl_script_font_not_found_current_font_used",
script=script,
font=found,
)
self._rtl_font_paths[script] = found
return found
logger.warning("no_unicode_font_found")
return None
@@ -259,7 +416,7 @@ class PDFTranslator:
doc.close()
raise RuntimeError("PDF has no pages.")
font_path = self._get_font_path()
font_path = self._get_font_path(target_language)
logger.info(
"pdf_layout_start",
pages=total_pages,
@@ -311,7 +468,7 @@ class PDFTranslator:
"""Core PyMuPDF in-place processing — one page at a time."""
import fitz
is_rtl = target_language.lower() in RTL_LANGUAGES
rtl_target = is_rtl(target_language)
total_blocks = 0
translated_blocks = 0
@@ -514,7 +671,7 @@ class PDFTranslator:
for block in blocks:
if block.get("translated"):
self._write_translated_block(
page, block, font_path, is_rtl
page, block, font_path, rtl_target
)
if progress_callback:
@@ -871,7 +1028,7 @@ class PDFTranslator:
page,
block: Dict,
font_path: Optional[str],
is_rtl: bool,
rtl_target: bool,
) -> bool:
"""Write translated text into the block's bounding box.
@@ -897,6 +1054,12 @@ class PDFTranslator:
original_rect = fitz.Rect(block["bbox"])
translated = block["translated"]
if rtl_target:
# Shape Arabic-script text (contextual letter forms + visual
# order) before insertion — insert_textbox does no bidi
# processing of its own. Missing/failed libs degrade to the
# raw text (warning logged inside _shape_rtl).
translated = _shape_rtl(translated)
target_size = block["font_size"]
color = self._int_to_rgb(block["color"])
@@ -1443,8 +1606,20 @@ class PDFTranslator:
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
from reportlab.lib.styles import getSampleStyleSheet
is_rtl = target_language.lower() in RTL_LANGUAGES
alignment = TA_RIGHT if is_rtl else TA_JUSTIFY
rtl_target = is_rtl(target_language)
alignment = TA_RIGHT if rtl_target else TA_JUSTIFY
# RTL targets need a TTF font covering the target script (the
# built-in Helvetica has no Arabic/Hebrew glyphs). Registration
# failures degrade to Helvetica with a warning — never a failed
# job.
font_name = "Helvetica"
if rtl_target:
rtl_font = self._get_font_path(target_language)
if rtl_font:
font_name = _register_rtl_font(rtl_font)
else:
logger.warning("pdf_rtl_font_missing_helvetica_fallback")
styles = getSampleStyleSheet()
@@ -1464,6 +1639,8 @@ class PDFTranslator:
leading=16,
spaceAfter=6,
alignment=alignment,
fontName=font_name,
wordWrap="RTL" if rtl_target else None,
textColor=colors.HexColor("#1a1a1a"),
)
@@ -1490,6 +1667,11 @@ class PDFTranslator:
elements.append(Spacer(1, 4))
continue
if rtl_target:
# Shape Arabic-script text before rendering —
# reportlab does no bidi processing of its own.
para_text = _shape_rtl(para_text)
safe = (
para_text
.replace("&", "&amp;")

View File

@@ -20,16 +20,30 @@ from pptx.shapes.group import GroupShape
from pptx.enum.shapes import MSO_SHAPE_TYPE
from services.providers.base import TranslationProvider
from core.languages import is_rtl
# DrawingML namespace used by pptx XML
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
# Languages written right-to-left
RTL_LANGUAGES: frozenset = frozenset(
{"ar", "he", "fa", "ur", "ku", "ps", "ug", "sd", "yi", "dv", "ckb"}
# a:rPr children that must come AFTER <a:latin>/<a:ea>/<a:cs> per the
# DrawingML CT_TextCharacterProperties sequence. Inserting <a:ea>/<a:cs>
# before them keeps the XML schema-valid (PowerPoint repairs otherwise).
_A_RPR_HINT_SUCCESSORS = tuple(
f"{{{_NS_A}}}{tag}"
for tag in ("sym", "hlinkClick", "hlinkMouseOver", "rtl", "extLst")
)
def _insert_a_rpr_child(rPr, child) -> None:
"""Insert child into a:rPr at its schema position (before sym,
hlink*, rtl and extLst — or appended at the end when none exist)."""
for existing in rPr:
if existing.tag in _A_RPR_HINT_SUCCESSORS:
existing.addprevious(child)
return
rPr.append(child)
from core.logging import get_logger
logger = get_logger(__name__)
@@ -58,8 +72,10 @@ def _set_pptx_paragraph_rtl(paragraph) -> None:
"""
Enable RTL mode on a PowerPoint paragraph.
Sets rtl="1" and algn="r" on the <a:pPr> element, which controls
both text direction and horizontal alignment in DrawingML.
Sets rtl="1" on the <a:pPr> 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.
"""
p_elem = paragraph._p
tag_pPr = f"{{{_NS_A}}}pPr"
@@ -68,14 +84,20 @@ def _set_pptx_paragraph_rtl(paragraph) -> None:
pPr = etree.Element(tag_pPr)
p_elem.insert(0, pPr)
pPr.set("rtl", "1")
pPr.set("algn", "r")
if pPr.get("algn") in (None, "", "l"):
pPr.set("algn", "r")
def _apply_rtl_to_presentation(presentation: Presentation) -> None:
"""Apply RTL direction to every paragraph in all slides."""
"""Apply RTL direction to every paragraph in all slides and notes."""
for slide in presentation.slides:
for shape in slide.shapes:
_apply_rtl_to_shape(shape)
# Speaker notes are translated like any other text — they read
# right-to-left too when the target language does.
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:
_set_pptx_paragraph_rtl(paragraph)
def _apply_rtl_to_shape(shape) -> None:
@@ -111,11 +133,10 @@ def _ea_typeface_for_target(target_language: str):
return _EA_TYPEFACES.get(code) or _EA_TYPEFACES.get(base)
def _apply_ea_font_hints(presentation: Presentation, target_language: str) -> None:
"""Set the <a:ea> (east-asian) typeface on every run for CJK targets.
Blanket application is safe — the hint only affects CJK glyphs.
"""
def _apply_run_typeface_hint(
presentation: Presentation, tag: str, typeface: str, log_event: str
) -> None:
"""Set the <a:{tag}> typeface hint on every run of every slide shape."""
def _hint_shape(shape) -> int:
hinted = 0
@@ -133,29 +154,59 @@ def _apply_ea_font_hints(presentation: Presentation, target_language: str) -> No
def _hint_text_frame(text_frame) -> int:
hinted = 0
tag_rPr = f"{{{_NS_A}}}rPr"
tag_ea = f"{{{_NS_A}}}ea"
tag_hint = f"{{{_NS_A}}}{tag}"
for paragraph in text_frame.paragraphs:
for run in paragraph.runs:
rPr = run._r.find(tag_rPr)
if rPr is None:
rPr = etree.SubElement(run._r, tag_rPr)
ea = rPr.find(tag_ea)
if ea is None:
ea = etree.SubElement(rPr, tag_ea)
if not ea.get("typeface"):
ea.set("typeface", typeface)
# a:rPr must be the FIRST child of a:r (before a:t).
rPr = etree.Element(tag_rPr)
run._r.insert(0, rPr)
hint = rPr.find(tag_hint)
if hint is None:
hint = etree.Element(tag_hint)
_insert_a_rpr_child(rPr, hint)
if not hint.get("typeface"):
hint.set("typeface", typeface)
hinted += 1
return hinted
typeface = _ea_typeface_for_target(target_language)
if not typeface:
return
total = 0
for slide in presentation.slides:
for shape in slide.shapes:
total += _hint_shape(shape)
# Speaker notes get the RTL direction — they need the typeface
# hint too.
if slide.has_notes_slide and slide.notes_slide.notes_text_frame is not None:
total += _hint_text_frame(slide.notes_slide.notes_text_frame)
if total:
_log_info("pptx_ea_font_hints_applied", runs=total, typeface=typeface)
_log_info(log_event, runs=total, typeface=typeface)
def _apply_ea_font_hints(presentation: Presentation, target_language: str) -> None:
"""Set the <a:ea> (east-asian) typeface on every run for CJK targets.
Blanket application is safe — the hint only affects CJK glyphs.
"""
typeface = _ea_typeface_for_target(target_language)
if not typeface:
return
_apply_run_typeface_hint(
presentation, "ea", typeface, "pptx_ea_font_hints_applied"
)
def _apply_cs_font_hints(presentation: Presentation, target_language: str) -> None:
"""Set the <a:cs> (complex-script) typeface on every run for RTL targets.
Blanket application is safe — the hint only affects Arabic-script
glyphs, and Arial covers the whole Arabic script on every platform.
"""
if not is_rtl(target_language):
return
_apply_run_typeface_hint(
presentation, "cs", "Arial", "pptx_cs_font_hints_applied"
)
class PptxProcessorError(Exception):
@@ -397,13 +448,17 @@ class PowerPointTranslator:
)
# 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_presentation(presentation)
# CJK font hint so the target script renders with a proper
# typeface instead of shape-dependent fallbacks.
_apply_ea_font_hints(presentation, target_language)
# Arabic-script font hint for RTL targets (same idea as the
# CJK hint, via the <a:cs> typeface).
_apply_cs_font_hints(presentation, target_language)
if translate_images:
try:
self._translate_images(presentation, target_language)

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,