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
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:
@@ -855,6 +855,21 @@ class TestPdfLayoutModeRtl:
|
|||||||
assert ok is True
|
assert ok is True
|
||||||
assert page.calls[0]["text"] == "Bonjour le monde"
|
assert page.calls[0]["text"] == "Bonjour le monde"
|
||||||
|
|
||||||
|
def test_ltr_target_is_left_aligned(self):
|
||||||
|
"""Regression guard: a Latin target (e.g. Persian → French) must be
|
||||||
|
left-aligned. A previous rename once made the alignment test the
|
||||||
|
truthiness of the imported is_rtl *function* — always true — and
|
||||||
|
every translated PDF came out right-aligned."""
|
||||||
|
import fitz
|
||||||
|
|
||||||
|
translator = PDFTranslator()
|
||||||
|
page = _FakePage()
|
||||||
|
ok = translator._write_translated_block(
|
||||||
|
page, self._block("Bonjour le monde"), font_path=None, rtl_target=False
|
||||||
|
)
|
||||||
|
assert ok is True
|
||||||
|
assert page.calls[0]["align"] == fitz.TEXT_ALIGN_LEFT
|
||||||
|
|
||||||
|
|
||||||
class TestPdfFontSelection:
|
class TestPdfFontSelection:
|
||||||
def test_arabic_script_target_prefers_arabic_font(self, tmp_path, monkeypatch):
|
def test_arabic_script_target_prefers_arabic_font(self, tmp_path, monkeypatch):
|
||||||
@@ -1054,3 +1069,85 @@ class TestPdfCleanModeRtl:
|
|||||||
# Presentation forms in the output prove both the shaping and the
|
# Presentation forms in the output prove both the shaping and the
|
||||||
# Arabic-capable font reached the PDF (Helvetica cannot encode them).
|
# Arabic-capable font reached the PDF (Helvetica cannot encode them).
|
||||||
assert any(0xFB50 <= ord(c) <= 0xFEFF for c in extracted)
|
assert any(0xFB50 <= ord(c) <= 0xFEFF for c in extracted)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Normalisation LTR — un document source RTL traduit vers une cible latine
|
||||||
|
# (ex. persan → français) doit ressortir en lecture gauche-à-droite
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLtrNormalization:
|
||||||
|
def test_word_rtl_source_latin_target_strips_rtl(self, tmp_path):
|
||||||
|
"""Persan → français : bidi/rtl/bidiVisual hérités de la source
|
||||||
|
sont retirés (corps, section, note de bas de page, tableau)."""
|
||||||
|
# 1) Fabrication d'un document RTL complet (cible fa)
|
||||||
|
t_fa = WordTranslator(
|
||||||
|
provider=MockTranslationProvider({"Hello": "FA1", "Extra": "FA2"})
|
||||||
|
)
|
||||||
|
doc = Document()
|
||||||
|
doc.add_paragraph("Hello")
|
||||||
|
table = doc.add_table(rows=1, cols=2)
|
||||||
|
table.cell(0, 0).text = "Extra"
|
||||||
|
fa_in = tmp_path / "in.docx"
|
||||||
|
doc.save(fa_in)
|
||||||
|
_inject_footnotes_part(fa_in)
|
||||||
|
fa_out = tmp_path / "fa.docx"
|
||||||
|
t_fa.translate_file(fa_in, fa_out, "fa")
|
||||||
|
|
||||||
|
# Sanity : la sortie fa porte bien les marques RTL
|
||||||
|
fa_doc = etree.fromstring(_read_zip_entry(fa_out, "word/document.xml"))
|
||||||
|
assert fa_doc.find(f".//{{{W_NS}}}bidi") is not None
|
||||||
|
fa_notes = etree.fromstring(_read_zip_entry(fa_out, "word/footnotes.xml"))
|
||||||
|
assert fa_notes.find(f".//{{{W_NS}}}bidi") is not None
|
||||||
|
|
||||||
|
# 2) Traduction de ce document RTL vers le français
|
||||||
|
t_fr = WordTranslator(
|
||||||
|
provider=MockTranslationProvider({"FA1": "Bonjour", "FA2": "ExtraFR"})
|
||||||
|
)
|
||||||
|
fr_out = tmp_path / "fr.docx"
|
||||||
|
t_fr.translate_file(fa_out, fr_out, "fr")
|
||||||
|
|
||||||
|
fr_doc = etree.fromstring(_read_zip_entry(fr_out, "word/document.xml"))
|
||||||
|
assert fr_doc.find(f".//{{{W_NS}}}bidi") is None
|
||||||
|
assert fr_doc.find(f".//{{{W_NS}}}rtl") is None
|
||||||
|
assert fr_doc.find(f".//{{{W_NS}}}bidiVisual") is None
|
||||||
|
fr_notes = etree.fromstring(_read_zip_entry(fr_out, "word/footnotes.xml"))
|
||||||
|
assert fr_notes.find(f".//{{{W_NS}}}bidi") is None
|
||||||
|
# La traduction française est bien là
|
||||||
|
assert "Bonjour" in "".join(t.text or "" for t in fr_doc.iter(f"{{{W_NS}}}t"))
|
||||||
|
|
||||||
|
def test_excel_rtl_source_latin_target_restores_ltr(self, tmp_path):
|
||||||
|
wb = Workbook()
|
||||||
|
wb.active["A1"] = "سلام"
|
||||||
|
wb.active.sheet_view.rightToLeft = True # feuille d'origine persane
|
||||||
|
input_file = tmp_path / "in.xlsx"
|
||||||
|
wb.save(input_file)
|
||||||
|
|
||||||
|
out = tmp_path / "out.xlsx"
|
||||||
|
ExcelTranslator(provider=MockTranslationProvider({"سلام": "Bonjour"})).translate_file(
|
||||||
|
input_file, out, "fr"
|
||||||
|
)
|
||||||
|
assert not load_workbook(out).active.sheet_view.rightToLeft
|
||||||
|
|
||||||
|
def test_pptx_rtl_source_latin_target_strips_rtl(self, tmp_path):
|
||||||
|
prs = Presentation()
|
||||||
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||||
|
box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
|
||||||
|
para = box.text_frame.paragraphs[0]
|
||||||
|
para.text = "سلام"
|
||||||
|
# Simule un paragraphe d'origine persane
|
||||||
|
pPr = para._p.get_or_add_pPr()
|
||||||
|
pPr.set("rtl", "1")
|
||||||
|
input_file = tmp_path / "in.pptx"
|
||||||
|
prs.save(input_file)
|
||||||
|
|
||||||
|
out = tmp_path / "out.pptx"
|
||||||
|
PowerPointTranslator(
|
||||||
|
provider=MockTranslationProvider({"سلام": "Bonjour"})
|
||||||
|
).translate_file(input_file, out, "fr")
|
||||||
|
|
||||||
|
out_para = _find_pptx_paragraph(Presentation(str(out)), "Bonjour")
|
||||||
|
assert out_para is not None
|
||||||
|
out_pPr = out_para._p.find(f"{{{A_NS}}}pPr")
|
||||||
|
assert out_pPr is None or out_pPr.get("rtl") != "1"
|
||||||
|
|||||||
@@ -355,25 +355,35 @@ class ExcelTranslator:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log_error("excel_sheet_images_failed", sheet_name=sheet_name, error=str(e))
|
_log_error("excel_sheet_images_failed", sheet_name=sheet_name, error=str(e))
|
||||||
|
|
||||||
# RTL targets: flip the sheet view so every sheet reads
|
# Normalize the sheet reading direction to the TARGET language.
|
||||||
# right-to-left (column A renders rightmost). Content and
|
# RTL targets: flip every sheet so column A renders rightmost.
|
||||||
# formatting are untouched — only the reading direction.
|
# 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
|
# Chartsheets have no `sheet_view` and must never break the
|
||||||
# save: each sheet is guarded individually.
|
# save: each sheet is guarded individually.
|
||||||
if is_rtl(target_language):
|
rtl_target = is_rtl(target_language)
|
||||||
flipped = 0
|
flipped = 0
|
||||||
for sheet_name in workbook.sheetnames:
|
for sheet_name in workbook.sheetnames:
|
||||||
ws = workbook[sheet_name]
|
ws = workbook[sheet_name]
|
||||||
try:
|
try:
|
||||||
if getattr(ws, "sheet_view", None) is not None:
|
if getattr(ws, "sheet_view", None) is None:
|
||||||
|
continue
|
||||||
|
if rtl_target:
|
||||||
ws.sheet_view.rightToLeft = True
|
ws.sheet_view.rightToLeft = True
|
||||||
flipped += 1
|
flipped += 1
|
||||||
|
elif getattr(ws.sheet_view, "rightToLeft", False):
|
||||||
|
ws.sheet_view.rightToLeft = False
|
||||||
|
flipped += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log_error(
|
_log_error(
|
||||||
"excel_rtl_view_failed", sheet=sheet_name, error=str(e)
|
"excel_direction_view_failed", sheet=sheet_name, error=str(e)
|
||||||
)
|
)
|
||||||
if flipped:
|
if flipped:
|
||||||
_log_info("excel_rtl_view_applied", sheets=flipped)
|
_log_info(
|
||||||
|
"excel_rtl_view_applied" if rtl_target else "excel_ltr_view_restored",
|
||||||
|
sheets=flipped,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
workbook.save(output_path)
|
workbook.save(output_path)
|
||||||
|
|||||||
@@ -1063,7 +1063,7 @@ class PDFTranslator:
|
|||||||
target_size = block["font_size"]
|
target_size = block["font_size"]
|
||||||
|
|
||||||
color = self._int_to_rgb(block["color"])
|
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'.
|
# PyMuPDF bug: fontname=None raises AttributeError. Default to 'helv'.
|
||||||
# If a custom font file is available, use it via fontfile (fontname ignored).
|
# If a custom font file is available, use it via fontfile (fontname ignored).
|
||||||
|
|||||||
@@ -121,6 +121,46 @@ def _apply_rtl_to_presentation(presentation: Presentation) -> None:
|
|||||||
_set_pptx_paragraph_rtl(paragraph)
|
_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:
|
def _apply_rtl_to_shape(shape) -> None:
|
||||||
"""Recursively apply RTL to a shape (handles groups and tables)."""
|
"""Recursively apply RTL to a shape (handles groups and tables)."""
|
||||||
if shape.has_text_frame:
|
if shape.has_text_frame:
|
||||||
@@ -468,9 +508,14 @@ class PowerPointTranslator:
|
|||||||
index=i,
|
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):
|
if is_rtl(target_language):
|
||||||
_apply_rtl_to_presentation(presentation)
|
_apply_rtl_to_presentation(presentation)
|
||||||
|
else:
|
||||||
|
_apply_ltr_to_presentation(presentation)
|
||||||
|
|
||||||
# CJK font hint so the target script renders with a proper
|
# CJK font hint so the target script renders with a proper
|
||||||
# typeface instead of shape-dependent fallbacks.
|
# typeface instead of shape-dependent fallbacks.
|
||||||
|
|||||||
@@ -282,6 +282,94 @@ def _apply_bidi_to_part_tree(root) -> int:
|
|||||||
return switched
|
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:
|
def _apply_rtl_to_document(document: Document) -> None:
|
||||||
"""Apply RTL direction to every paragraph and section in the document."""
|
"""Apply RTL direction to every paragraph and section in the document."""
|
||||||
body = document.element.body
|
body = document.element.body
|
||||||
@@ -577,9 +665,14 @@ class WordTranslator:
|
|||||||
index=i,
|
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):
|
if is_rtl(target_language):
|
||||||
_apply_rtl_to_document(document)
|
_apply_rtl_to_document(document)
|
||||||
|
else:
|
||||||
|
_apply_ltr_to_document(document)
|
||||||
|
|
||||||
# CJK / Arabic-script font hints so Word renders the target
|
# CJK / Arabic-script font hints so Word renders the target
|
||||||
# script with a proper typeface instead of per-run fallbacks.
|
# 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.
|
# setters hold references to t_elems inside this tree.
|
||||||
def write_footnotes_back(output_path: Path) -> None:
|
def write_footnotes_back(output_path: Path) -> None:
|
||||||
try:
|
try:
|
||||||
if rtl:
|
_normalize_part_direction(footnotes_xml, rtl)
|
||||||
_apply_bidi_to_part_tree(footnotes_xml)
|
|
||||||
new_blob = etree.tostring(
|
new_blob = etree.tostring(
|
||||||
footnotes_xml,
|
footnotes_xml,
|
||||||
xml_declaration=True,
|
xml_declaration=True,
|
||||||
@@ -1114,8 +1206,7 @@ class WordTranslator:
|
|||||||
if text_elements and post_save_callbacks is not None:
|
if text_elements and post_save_callbacks is not None:
|
||||||
def write_endnotes_back(output_path: Path) -> None:
|
def write_endnotes_back(output_path: Path) -> None:
|
||||||
try:
|
try:
|
||||||
if rtl:
|
_normalize_part_direction(endnotes_xml, rtl)
|
||||||
_apply_bidi_to_part_tree(endnotes_xml)
|
|
||||||
new_blob = etree.tostring(
|
new_blob = etree.tostring(
|
||||||
endnotes_xml,
|
endnotes_xml,
|
||||||
xml_declaration=True,
|
xml_declaration=True,
|
||||||
@@ -1170,8 +1261,7 @@ class WordTranslator:
|
|||||||
if collected and post_save_callbacks is not None:
|
if collected and post_save_callbacks is not None:
|
||||||
def write_comments_back(output_path: Path) -> None:
|
def write_comments_back(output_path: Path) -> None:
|
||||||
try:
|
try:
|
||||||
if rtl:
|
_normalize_part_direction(comments_xml, rtl)
|
||||||
_apply_bidi_to_part_tree(comments_xml)
|
|
||||||
new_blob = etree.tostring(
|
new_blob = etree.tostring(
|
||||||
comments_xml,
|
comments_xml,
|
||||||
xml_declaration=True,
|
xml_declaration=True,
|
||||||
|
|||||||
Reference in New Issue
Block a user