fix(direction): le sens du document traduit suit la langue cible, dans les deux sens
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m3s

Persan -> francais : le document traduit restait en lecture de droite a
gauche, heritee de la source. Trois causes :

- PDF : regression du renommage precedent — le choix d'alignement
  testait la fonction is_rtl (toujours vraie) au lieu du parametre :
  tout texte traduit s'alignait a droite, quelle que soit la cible.
  Corrige + test epinglant l'alignement gauche pour une cible latine.
- Word : les marques RTL heritees (bidi, rtl, bidiVisual, notes et
  commentaires compris) sont desormais retirees quand la cible est
  latine ; retrait fait sur une liste figee (l'iterateur lxml sautait
  des elements pendant la suppression).
- Excel : les feuilles heritees d'un affichage droite-a-gauche sont
  remises en lecture gauche-a-droite pour une cible latine.
- PowerPoint : les attributs rtl herites sont retires pour une cible
  latine (alignements visuels conserves).

3 tests de bout en bout nouveaux : document RTL traduit vers le
francais ressort en lecture gauche-a-droite dans les trois formats.
This commit is contained in:
2026-09-01 21:28:46 +02:00
parent 2abd0c0b26
commit 5f3d57b4f7
5 changed files with 268 additions and 26 deletions

View File

@@ -355,25 +355,35 @@ 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.
# Normalize the sheet reading direction to the TARGET language.
# RTL targets: flip every sheet so column A renders rightmost.
# Latin targets: restore left-to-right on sheets inherited from
# an RTL source (Persian → French must not keep the RTL view).
# Content and formatting are untouched — only the 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)
rtl_target = is_rtl(target_language)
flipped = 0
for sheet_name in workbook.sheetnames:
ws = workbook[sheet_name]
try:
if getattr(ws, "sheet_view", None) is None:
continue
if rtl_target:
ws.sheet_view.rightToLeft = True
flipped += 1
elif getattr(ws.sheet_view, "rightToLeft", False):
ws.sheet_view.rightToLeft = False
flipped += 1
except Exception as e:
_log_error(
"excel_direction_view_failed", sheet=sheet_name, error=str(e)
)
if flipped:
_log_info(
"excel_rtl_view_applied" if rtl_target else "excel_ltr_view_restored",
sheets=flipped,
)
try:
workbook.save(output_path)

View File

@@ -1063,7 +1063,7 @@ class PDFTranslator:
target_size = block["font_size"]
color = self._int_to_rgb(block["color"])
align = fitz.TEXT_ALIGN_RIGHT if is_rtl else fitz.TEXT_ALIGN_LEFT
align = fitz.TEXT_ALIGN_RIGHT if rtl_target else fitz.TEXT_ALIGN_LEFT
# PyMuPDF bug: fontname=None raises AttributeError. Default to 'helv'.
# If a custom font file is available, use it via fontfile (fontname ignored).

View File

@@ -121,6 +121,46 @@ def _apply_rtl_to_presentation(presentation: Presentation) -> None:
_set_pptx_paragraph_rtl(paragraph)
def _unset_pptx_paragraph_rtl(paragraph) -> None:
"""Drop a paragraph's inherited RTL direction (Latin targets).
Translating a Persian/Arabic source into a Latin target must not keep
the source's rtl="1" marks: with rtl gone the paragraph falls back to
the natural left-to-right direction. Alignment attributes are left
untouched (algn is visual in DrawingML — centered stays centered).
"""
pPr = paragraph._p.find(f"{{{_NS_A}}}pPr")
if pPr is not None and pPr.get("rtl") == "1":
pPr.attrib.pop("rtl", None)
def _apply_ltr_to_presentation(presentation: Presentation) -> None:
"""Strip inherited RTL direction from all slides and notes (Latin targets)."""
for slide in presentation.slides:
for shape in slide.shapes:
_apply_ltr_to_shape(shape)
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:
_unset_pptx_paragraph_rtl(paragraph)
def _apply_ltr_to_shape(shape) -> None:
"""Recursively strip RTL from a shape (handles groups and tables)."""
if shape.has_text_frame:
for paragraph in shape.text_frame.paragraphs:
_unset_pptx_paragraph_rtl(paragraph)
if shape.shape_type == MSO_SHAPE_TYPE.TABLE:
for row in shape.table.rows:
for cell in row.cells:
for paragraph in cell.text_frame.paragraphs:
_unset_pptx_paragraph_rtl(paragraph)
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
for sub_shape in shape.shapes:
_apply_ltr_to_shape(sub_shape)
def _apply_rtl_to_shape(shape) -> None:
"""Recursively apply RTL to a shape (handles groups and tables)."""
if shape.has_text_frame:
@@ -468,9 +508,14 @@ class PowerPointTranslator:
index=i,
)
# Apply RTL layout when the target language is written right-to-left.
# Normalize the direction to the TARGET language: RTL targets
# switch to right-to-left, Latin targets strip the rtl marks
# inherited from an RTL source (Persian → French must read
# left-to-right, not keep the source's layout).
if is_rtl(target_language):
_apply_rtl_to_presentation(presentation)
else:
_apply_ltr_to_presentation(presentation)
# CJK font hint so the target script renders with a proper
# typeface instead of shape-dependent fallbacks.

View File

@@ -282,6 +282,94 @@ def _apply_bidi_to_part_tree(root) -> int:
return switched
def _normalize_part_direction(root, rtl: bool) -> int:
"""
Normalize the direction of a footnotes/endnotes/comments tree.
RTL target: every paragraph/run/table switches to RTL (same marks as
the document body). Latin target: inherited RTL marks from an RTL
source are stripped so the translated notes read left-to-right.
Returns the number of paragraphs switched (RTL) or marks removed (LTR).
"""
if rtl:
return _apply_bidi_to_part_tree(root)
return _strip_rtl_marks(root)
def _strip_rtl_marks(root) -> int:
"""
Remove every RTL direction mark under an element tree (LTR targets).
Translating a Persian/Arabic source into a Latin target must not keep
the source's right-to-left layout: w:bidi (paragraph/section
direction), w:rtl (run-level marks, including paragraph marks inside
w:pPr/w:rPr) and w:bidiVisual (mirrored tables) are stripped so the
translated document reads left-to-right, exactly like a document
written in the target language.
Works on python-docx trees and plain lxml trees (notes/comments
parts) alike. Returns the number of marks removed.
"""
removed = 0
# Materialize the iteration first: removing children while a live
# lxml iterator walks the tree makes it skip elements (seen in
# practice: some w:bidi survived the strip).
for el in list(root.iter()):
if el.tag == qn("w:pPr") or el.tag == qn("w:sectPr"):
bidi = el.find(qn("w:bidi"))
if bidi is not None:
el.remove(bidi)
removed += 1
elif el.tag == qn("w:rPr"):
rtl = el.find(qn("w:rtl"))
if rtl is not None:
el.remove(rtl)
removed += 1
elif el.tag == qn("w:tblPr"):
bidi_visual = el.find(qn("w:bidiVisual"))
if bidi_visual is not None:
el.remove(bidi_visual)
removed += 1
return removed
def _apply_ltr_to_document(document: Document) -> None:
"""
Force left-to-right direction on the whole document (Latin targets).
Mirror of _apply_rtl_to_document: an RTL source translated to a Latin
target must end up reading left-to-right, not keep the source's RTL
layout. Stripping alone is enough — with w:bidi gone, paragraphs fall
back to the natural LTR start edge, and explicit alignments (center,
justify) keep their meaning.
"""
stripped = _strip_rtl_marks(document.element)
for section in document.sections:
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
root = getattr(hf, "_element", None)
if root is None:
paras = hf.paragraphs
if paras:
root = paras[0]._p.getroottree().getroot()
if root is not None:
stripped += _strip_rtl_marks(root)
if stripped:
from core.logging import get_logger as _gl
_gl(__name__).info("word_ltr_normalized", marks_removed=stripped)
def _apply_rtl_to_document(document: Document) -> None:
"""Apply RTL direction to every paragraph and section in the document."""
body = document.element.body
@@ -577,9 +665,14 @@ class WordTranslator:
index=i,
)
# Apply RTL layout when the target language is written right-to-left.
# Normalize the document direction to the TARGET language:
# RTL targets switch to right-to-left, Latin targets strip the
# RTL marks inherited from an RTL source (Persian → French
# must read left-to-right, not keep the source's layout).
if is_rtl(target_language):
_apply_rtl_to_document(document)
else:
_apply_ltr_to_document(document)
# CJK / Arabic-script font hints so Word renders the target
# script with a proper typeface instead of per-run fallbacks.
@@ -1046,8 +1139,7 @@ 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)
_normalize_part_direction(footnotes_xml, rtl)
new_blob = etree.tostring(
footnotes_xml,
xml_declaration=True,
@@ -1114,8 +1206,7 @@ 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)
_normalize_part_direction(endnotes_xml, rtl)
new_blob = etree.tostring(
endnotes_xml,
xml_declaration=True,
@@ -1170,8 +1261,7 @@ 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)
_normalize_part_direction(comments_xml, rtl)
new_blob = etree.tostring(
comments_xml,
xml_declaration=True,