Files
office_translator/tests/test_translators/test_rtl_layout.py
sepehr f22f645fab
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m37s
feat(rtl): rendu droite-a-gauche complet pour Word, PowerPoint, Excel et PDF
- 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
2026-09-01 20:22:46 +02:00

1009 lines
40 KiB
Python

"""
Tests for RTL (right-to-left) layout support across the translators.
Covers the spec matrix:
- is_rtl(): regional prefixes (fa-IR, ar-EG) and case are normalized
- Word: w:bidi on body / footnotes / text boxes, w:bidiVisual on tables,
w:bidi on sectPr, explicit alignments (center) never overwritten
- PowerPoint: rtl="1" on paragraphs, algn kept when explicit (ctr) and
only forced to "r" when absent or "l", speaker notes RTL,
<a:cs typeface="Arial"> hints for RTL targets
- Excel: sheet_view.rightToLeft = True on every sheet
- PDF: Arabic-script font selection per target language, bidi shaping
(arabic-reshaper + python-bidi), graceful fallback when the shaping
libraries are missing, wordWrap="RTL" + registered TTF font in the
clean-PDF mode
- Non-RTL targets (fr): no RTL attribute added anywhere
"""
import sys
import zipfile
from pathlib import Path
import pytest
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from lxml import etree
from openpyxl import Workbook, load_workbook
from pptx import Presentation
from pptx.enum.text import PP_ALIGN
from pptx.util import Inches
from core.languages import RTL_LANGUAGES, is_rtl
from services.providers.schemas import TranslationRequest, TranslationResponse
from translators.excel_translator import ExcelTranslator
from translators.pdf_translator import PDFTranslator, _shape_rtl
from translators.pptx_translator import PowerPointTranslator
from translators.word_translator import (
CS_FONTS,
WordTranslator,
_font_hints_for_target,
)
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
class MockTranslationProvider:
"""Mock translation provider for testing."""
def __init__(self, translations: dict = None):
self._translations = translations or {}
self._call_count = 0
self._requests_received: list[TranslationRequest] = []
def get_name(self) -> str:
return "mock"
def is_available(self) -> bool:
return True
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
self._call_count += 1
self._requests_received.append(request)
text = request.text
translated = self._translations.get(text, f"TR_{text}")
return TranslationResponse(
translated_text=translated,
provider_name="mock",
source_language=request.source_language,
)
def translate_batch(
self, requests: list[TranslationRequest]
) -> list[TranslationResponse]:
return [self.translate_text(req) for req in requests]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _read_zip_entry(path: Path, name: str) -> bytes:
with zipfile.ZipFile(path) as zf:
return zf.read(name)
def _rewrite_zip_entry(path: Path, name: str, data: bytes) -> None:
with zipfile.ZipFile(path) as zin:
items = {n: zin.read(n) for n in zin.namelist()}
items[name] = data
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zout:
for entry_name, entry_data in items.items():
zout.writestr(entry_name, entry_data)
_FOOTNOTES_PART = b"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:footnote w:type="separator" w:id="-1">
<w:p><w:r><w:separator/></w:r></w:p>
</w:footnote>
<w:footnote w:type="continuationSeparator" w:id="0">
<w:p><w:r><w:continuationSeparator/></w:r></w:p>
</w:footnote>
<w:footnote w:id="1">
<w:p><w:r><w:t>Footnote content</w:t></w:r></w:p>
</w:footnote>
</w:footnotes>"""
_ENDNOTES_PART = b"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:endnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:endnote w:type="separator" w:id="-1">
<w:p><w:r><w:separator/></w:r></w:p>
</w:endnote>
<w:endnote w:id="1">
<w:p><w:r><w:t>Endnote content</w:t></w:r></w:p>
</w:endnote>
<w:endnote w:id="2">
<w:tbl>
<w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>
<w:tr><w:tc><w:p><w:r><w:t>Endnote cell</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
</w:endnote>
</w:endnotes>"""
_COMMENTS_PART = b"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:comment w:id="1" w:author="Reviewer" w:date="2026-01-01T00:00:00Z" w:initials="RV">
<w:p><w:r><w:t>Comment content</w:t></w:r></w:p>
</w:comment>
<w:comment w:id="2" w:author="Reviewer" w:date="2026-01-01T00:00:00Z" w:initials="RV">
<w:tbl>
<w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>
<w:tr><w:tc><w:p><w:r><w:t>Comment cell</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
</w:comment>
</w:comments>"""
def _inject_part(docx_path: Path, kind: str, xml_bytes: bytes, rel_id: str) -> None:
"""Add a minimal word/<kind>.xml part (footnotes/endnotes/comments)
to a saved .docx, with its content-type override and relationship."""
part_name = f"word/{kind}.xml"
content_type = (
"application/vnd.openxmlformats-officedocument.wordprocessingml."
f"{kind}+xml"
)
ct = _read_zip_entry(docx_path, "[Content_Types].xml").decode("utf-8")
if f"{kind}+xml" not in ct:
ct = ct.replace(
"</Types>",
f'<Override PartName="/{part_name}" '
f'ContentType="{content_type}"/></Types>',
)
rels = _read_zip_entry(docx_path, "word/_rels/document.xml.rels").decode("utf-8")
if part_name not in rels:
rels = rels.replace(
"</Relationships>",
f'<Relationship Id="{rel_id}" Type="http://schemas.openxmlformats.org'
f'/officeDocument/2006/relationships/{kind}" '
f'Target="{kind}.xml"/></Relationships>',
)
_rewrite_zip_entry(docx_path, "[Content_Types].xml", ct.encode("utf-8"))
_rewrite_zip_entry(docx_path, "word/_rels/document.xml.rels", rels.encode("utf-8"))
_rewrite_zip_entry(docx_path, part_name, xml_bytes)
def _inject_footnotes_part(docx_path: Path) -> None:
"""Add a minimal word/footnotes.xml part to a saved .docx."""
_inject_part(docx_path, "footnotes", _FOOTNOTES_PART, "rIdRtlFootnotes")
def _inject_textbox(docx_path: Path) -> None:
"""Add a VML text box (w:txbxContent) to the document body."""
doc_xml = _read_zip_entry(docx_path, "word/document.xml").decode("utf-8")
textbox = (
'<w:p><w:r><w:pict>'
'<v:shape id="rtlTb1" style="width:100pt;height:20pt">'
"<v:textbox><w:txbxContent>"
"<w:p><w:r><w:t>TextBox content</w:t></w:r></w:p>"
"</w:txbxContent></v:textbox></v:shape>"
"</w:pict></w:r></w:p>"
)
# The sectPr must stay the LAST child of w:body.
if "<w:sectPr" in doc_xml:
doc_xml = doc_xml.replace("<w:sectPr", textbox + "<w:sectPr", 1)
else:
doc_xml = doc_xml.replace("</w:body>", textbox + "</w:body>")
_rewrite_zip_entry(docx_path, "word/document.xml", doc_xml.encode("utf-8"))
def _word_paragraph_has_bidi(p_elem) -> bool:
pPr = p_elem.find(f"{{{W_NS}}}pPr")
return pPr is not None and pPr.find(f"{{{W_NS}}}bidi") is not None
def _find_pptx_paragraph(prs, needle: str):
"""First paragraph (slides + notes) whose text contains needle."""
for slide in prs.slides:
for shape in slide.shapes:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
if needle in para.text:
return para
if slide.has_notes_slide and slide.notes_slide.notes_text_frame is not None:
for para in slide.notes_slide.notes_text_frame.paragraphs:
if needle in para.text:
return para
return None
def _host_rtl_font() -> str:
"""An Arabic-script font actually installed on this host.
Searches ONLY the dedicated Arabic list — not the generic fallback —
so the skipif really skips on hosts without an Arabic font.
"""
translator = PDFTranslator()
for p in translator._RTL_ARABIC_FONT_SEARCH_PATHS:
if Path(p).exists():
return p
return None
# ---------------------------------------------------------------------------
# is_rtl() — single source of truth
# ---------------------------------------------------------------------------
class TestIsRtl:
def test_regional_prefix_normalized(self):
assert is_rtl("fa-IR") is True
assert is_rtl("ar-EG") is True
assert is_rtl("he-IL") is True
assert is_rtl("ckb") is True
def test_case_insensitive(self):
assert is_rtl("AR") is True
assert is_rtl("Fa") is True
def test_non_rtl_targets(self):
assert is_rtl("fr") is False
assert is_rtl("en") is False
assert is_rtl("zh-CN") is False
assert is_rtl("") is False
assert is_rtl(None) is False
def test_full_set_is_rtl(self):
for code in RTL_LANGUAGES:
assert is_rtl(code) is True
def test_rtl_set_exists_only_in_core(self):
"""RTL_LANGUAGES must not be duplicated in the translators."""
import translators.excel_translator as xl
import translators.pdf_translator as pdft
import translators.pptx_translator as pt
import translators.word_translator as wt
for module in (wt, pt, pdft, xl):
assert not hasattr(module, "RTL_LANGUAGES"), module.__name__
# ---------------------------------------------------------------------------
# Word — body, table, footnote, text box, section
# ---------------------------------------------------------------------------
class TestWordRtlLayout:
def test_fa_target_full_rtl_coverage(self, tmp_path):
"""bidi on body/centered title/header, bidiVisual on the table,
bidi on the footnote and text box, bidi on sectPr."""
provider = MockTranslationProvider(
{
"Body text": "FA_Body",
"Centered title": "FA_Title",
"Header text": "FA_Header",
"Cell": "FA_Cell",
"TextBox content": "FA_Box",
"Footnote content": "FA_Note",
}
)
translator = WordTranslator(provider=provider)
doc = Document()
doc.add_paragraph("Body text")
centered = doc.add_paragraph()
centered.alignment = WD_ALIGN_PARAGRAPH.CENTER
centered.add_run("Centered title")
table = doc.add_table(rows=1, cols=2)
table.cell(0, 0).text = "Cell"
doc.sections[0].header.paragraphs[0].text = "Header text"
input_file = tmp_path / "input.docx"
doc.save(input_file)
_inject_textbox(input_file)
_inject_footnotes_part(input_file)
output_file = tmp_path / "output.docx"
translator.translate_file(input_file, output_file, "fa")
doc_out = Document(output_file)
# Body paragraph: w:bidi + run-level w:rtl
body_para = doc_out.paragraphs[0]
assert _word_paragraph_has_bidi(body_para._p)
assert "FA_Body" in body_para.text
# Centered title keeps its explicit alignment (never overwritten)
centered_out = doc_out.paragraphs[1]
assert _word_paragraph_has_bidi(centered_out._p)
assert centered_out.alignment == WD_ALIGN_PARAGRAPH.CENTER
jc = centered_out._p.find(f"{{{W_NS}}}pPr").find(f"{{{W_NS}}}jc")
assert jc.get(f"{{{W_NS}}}val") == "center"
# OOXML schema order: w:bidi must precede spacing/ind/jc/rPr
# (out-of-order pPr children make Word flag the file for repair)
centered_pPr = centered_out._p.find(f"{{{W_NS}}}pPr")
child_tags = [child.tag for child in centered_pPr]
bidi_pos = child_tags.index(f"{{{W_NS}}}bidi")
for later in ("w:spacing", "w:ind", "w:jc", "w:rPr"):
tag = f"{{{W_NS}}}{later}"
if tag in child_tags:
assert bidi_pos < child_tags.index(tag), later
# Same for the run-level marker: w:rtl after w:rFonts
body_rPr = body_para.runs[0]._r.find(f"{{{W_NS}}}rPr")
assert body_rPr is not None
run_tags = [child.tag for child in body_rPr]
if f"{{{W_NS}}}rFonts" in run_tags:
assert run_tags.index(f"{{{W_NS}}}rFonts") < run_tags.index(
f"{{{W_NS}}}rtl"
)
# Table: w:bidiVisual (column order flipped visually)
tblPr = doc_out.tables[0]._tbl.find(f"{{{W_NS}}}tblPr")
assert tblPr is not None
assert tblPr.find(f"{{{W_NS}}}bidiVisual") is not None
# Schema order: w:bidiVisual after tblStyle, before tblW/tblLook
tbl_tags = [child.tag for child in tblPr]
if f"{{{W_NS}}}tblStyle" in tbl_tags:
assert tbl_tags.index(f"{{{W_NS}}}tblStyle") < tbl_tags.index(
f"{{{W_NS}}}bidiVisual"
)
for later in ("w:tblW", "w:tblLook"):
tag = f"{{{W_NS}}}{later}"
if tag in tbl_tags:
assert tbl_tags.index(f"{{{W_NS}}}bidiVisual") < tbl_tags.index(
tag
), later
# Section: w:bidi (page layout direction)
assert doc_out.sections[0]._sectPr.find(f"{{{W_NS}}}bidi") is not None
# Header paragraph: w:bidi
header_para = doc_out.sections[0].header.paragraphs[0]
assert _word_paragraph_has_bidi(header_para._p)
# Text box: w:bidi on the paragraph inside w:txbxContent
doc_root = etree.fromstring(_read_zip_entry(output_file, "word/document.xml"))
txbx = doc_root.find(f".//{{{W_NS}}}txbxContent")
assert txbx is not None, "text box part missing from output"
box_para = txbx.find(f"{{{W_NS}}}p")
assert _word_paragraph_has_bidi(box_para)
# Footnote: w:bidi on every paragraph of footnotes.xml, w:rtl on
# every text run, and the translated text made it through the
# ZIP write-back.
foot_root = etree.fromstring(
_read_zip_entry(output_file, "word/footnotes.xml")
)
foot_paras = list(foot_root.iter(f"{{{W_NS}}}p"))
assert foot_paras, "footnotes.xml has no paragraphs"
for p in foot_paras:
assert _word_paragraph_has_bidi(p)
for r in foot_root.iter(f"{{{W_NS}}}r"):
if r.find(f"{{{W_NS}}}t") is None:
continue
rPr = r.find(f"{{{W_NS}}}rPr")
assert rPr is not None, "footnote run has no rPr"
assert rPr.find(f"{{{W_NS}}}rtl") is not None, "footnote run has no w:rtl"
foot_text = "".join(
t.text or "" for t in foot_root.iter(f"{{{W_NS}}}t")
)
assert "FA_Note" in foot_text
def test_fa_target_endnotes_and_comments_rtl(self, tmp_path):
"""Endnotes and comments (separate ZIP parts) receive w:bidi on
their paragraphs, w:rtl on their runs and w:bidiVisual on their
tables when the target is RTL."""
provider = MockTranslationProvider(
{
"Body text": "FA_Body",
"Endnote content": "FA_Endnote",
"Endnote cell": "FA_EndCell",
"Comment content": "FA_Comment",
"Comment cell": "FA_ComCell",
}
)
translator = WordTranslator(provider=provider)
doc = Document()
doc.add_paragraph("Body text")
input_file = tmp_path / "input.docx"
doc.save(input_file)
_inject_part(input_file, "endnotes", _ENDNOTES_PART, "rIdRtlEndnotes")
_inject_part(input_file, "comments", _COMMENTS_PART, "rIdRtlComments")
output_file = tmp_path / "output.docx"
translator.translate_file(input_file, output_file, "fa")
for part_name, marker in (
("word/endnotes.xml", "FA_Endnote"),
("word/comments.xml", "FA_Comment"),
):
root = etree.fromstring(_read_zip_entry(output_file, part_name))
# Every paragraph: w:bidi
paras = list(root.iter(f"{{{W_NS}}}p"))
assert paras, f"{part_name} has no paragraphs"
for p in paras:
assert _word_paragraph_has_bidi(p), part_name
# Every text run: w:rPr/w:rtl
text_runs = [
r
for r in root.iter(f"{{{W_NS}}}r")
if r.find(f"{{{W_NS}}}t") is not None
]
assert text_runs, f"{part_name} has no text runs"
for r in text_runs:
rPr = r.find(f"{{{W_NS}}}rPr")
assert rPr is not None, f"{part_name}: run has no rPr"
assert rPr.find(f"{{{W_NS}}}rtl") is not None, part_name
# Tables inside the part: w:bidiVisual
tables = list(root.iter(f"{{{W_NS}}}tbl"))
assert tables, f"{part_name} should contain a table"
for tbl in tables:
tblPr = tbl.find(f"{{{W_NS}}}tblPr")
assert tblPr is not None, part_name
assert tblPr.find(f"{{{W_NS}}}bidiVisual") is not None, part_name
# The translation itself made it through the write-back
part_text = "".join(
t.text or "" for t in root.iter(f"{{{W_NS}}}t")
)
assert marker in part_text, part_name
def test_fr_target_no_rtl_attributes(self, tmp_path):
"""A non-RTL target produces no bidi/bidiVisual/rtl markup."""
provider = MockTranslationProvider({"Body text": "FR_Body"})
translator = WordTranslator(provider=provider)
doc = Document()
doc.add_paragraph("Body text")
table = doc.add_table(rows=1, cols=2)
table.cell(0, 0).text = "Cell"
input_file = tmp_path / "input.docx"
output_file = tmp_path / "output.docx"
doc.save(input_file)
_inject_footnotes_part(input_file)
_inject_part(input_file, "endnotes", _ENDNOTES_PART, "rIdRtlEndnotes")
_inject_part(input_file, "comments", _COMMENTS_PART, "rIdRtlComments")
translator.translate_file(input_file, output_file, "fr")
doc_xml = _read_zip_entry(output_file, "word/document.xml").decode("utf-8")
assert "w:bidi" not in doc_xml # also covers bidiVisual
assert "w:rtl" not in doc_xml
for part_name in (
"word/footnotes.xml",
"word/endnotes.xml",
"word/comments.xml",
):
part_xml = _read_zip_entry(output_file, part_name).decode("utf-8")
assert "w:bidi" not in part_xml, part_name
assert "w:rtl" not in part_xml, part_name
# ---------------------------------------------------------------------------
# Word — complex-script font hints (CS_FONTS coverage)
# ---------------------------------------------------------------------------
class TestWordCsFontHints:
@pytest.mark.parametrize("code", sorted(CS_FONTS))
def test_every_cs_language_gets_arial_hint(self, code):
"""Each complex-script target must resolve to an Arial cs hint —
pinning every CS_FONTS entry (dropping one must fail here)."""
eastasia, cs = _font_hints_for_target(code)
assert cs == "Arial", code
def test_cs_fonts_covers_every_rtl_language(self):
"""The CS hint map must cover the full RTL language set, not just
the original four entries."""
assert set(CS_FONTS) >= set(RTL_LANGUAGES)
# ---------------------------------------------------------------------------
# PowerPoint — rtl attr, alignment policy, notes, cs font hint
# ---------------------------------------------------------------------------
class TestPptxRtlLayout:
def _build_single_text_pptx(self, tmp_path, text, alignment=None):
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
textbox = slide.shapes.add_textbox(
Inches(1), Inches(1), Inches(6), Inches(1)
)
para = textbox.text_frame.paragraphs[0]
para.text = text
if alignment is not None:
para.alignment = alignment
input_file = tmp_path / "input.pptx"
prs.save(str(input_file))
return input_file
def test_centered_title_keeps_alignment(self, tmp_path):
provider = MockTranslationProvider({"Centered title": "AR_Title"})
translator = PowerPointTranslator(provider=provider)
input_file = self._build_single_text_pptx(
tmp_path, "Centered title", PP_ALIGN.CENTER
)
output_file = tmp_path / "out.pptx"
translator.translate_file(input_file, output_file, "ar")
prs_out = Presentation(str(output_file))
para = _find_pptx_paragraph(prs_out, "AR_Title")
assert para is not None
pPr = para._p.find(f"{{{A_NS}}}pPr")
assert pPr is not None
assert pPr.get("rtl") == "1"
assert pPr.get("algn") == "ctr", "centered alignment must be preserved"
def test_justified_title_keeps_alignment(self, tmp_path):
provider = MockTranslationProvider({"Justified text": "AR_Just"})
translator = PowerPointTranslator(provider=provider)
input_file = self._build_single_text_pptx(
tmp_path, "Justified text", PP_ALIGN.JUSTIFY
)
output_file = tmp_path / "out.pptx"
translator.translate_file(input_file, output_file, "ar")
para = _find_pptx_paragraph(Presentation(str(output_file)), "AR_Just")
assert para is not None
pPr = para._p.find(f"{{{A_NS}}}pPr")
assert pPr is not None
assert pPr.get("rtl") == "1"
assert pPr.get("algn") == "just", "justified alignment must be preserved"
def test_alignment_absent_becomes_right(self, tmp_path):
provider = MockTranslationProvider({"Plain text": "AR_Plain"})
translator = PowerPointTranslator(provider=provider)
input_file = self._build_single_text_pptx(tmp_path, "Plain text")
output_file = tmp_path / "out.pptx"
translator.translate_file(input_file, output_file, "ar")
para = _find_pptx_paragraph(Presentation(str(output_file)), "AR_Plain")
assert para is not None
pPr = para._p.find(f"{{{A_NS}}}pPr")
assert pPr is not None
assert pPr.get("rtl") == "1"
assert pPr.get("algn") == "r"
def test_alignment_left_becomes_right(self, tmp_path):
provider = MockTranslationProvider({"Left text": "AR_Left"})
translator = PowerPointTranslator(provider=provider)
input_file = self._build_single_text_pptx(
tmp_path, "Left text", PP_ALIGN.LEFT
)
output_file = tmp_path / "out.pptx"
translator.translate_file(input_file, output_file, "ar")
para = _find_pptx_paragraph(Presentation(str(output_file)), "AR_Left")
assert para is not None
pPr = para._p.find(f"{{{A_NS}}}pPr")
assert pPr.get("algn") == "r"
def test_speaker_notes_rtl(self, tmp_path):
provider = MockTranslationProvider({"Speaker notes": "AR_Notes"})
translator = PowerPointTranslator(provider=provider)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0])
slide.notes_slide.notes_text_frame.text = "Speaker notes"
slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1))
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "out.pptx"
prs.save(str(input_file))
translator.translate_file(input_file, output_file, "ar")
prs_out = Presentation(str(output_file))
slide_out = prs_out.slides[0]
assert slide_out.has_notes_slide
notes_para = slide_out.notes_slide.notes_text_frame.paragraphs[0]
assert "AR_Notes" in notes_para.text
pPr = notes_para._p.find(f"{{{A_NS}}}pPr")
assert pPr is not None
assert pPr.get("rtl") == "1"
# Notes runs get the <a:cs> complex-script hint too
for run in notes_para.runs:
rPr = run._r.find(f"{{{A_NS}}}rPr")
assert rPr is not None, "rPr missing on notes run"
cs = rPr.find(f"{{{A_NS}}}cs")
assert cs is not None, "<a:cs> hint missing on notes run"
assert cs.get("typeface") == "Arial"
def test_cs_font_hint_for_rtl_target(self, tmp_path):
provider = MockTranslationProvider({"Hello": "AR_Hello"})
translator = PowerPointTranslator(provider=provider)
input_file = self._build_single_text_pptx(tmp_path, "Hello")
output_file = tmp_path / "out.pptx"
translator.translate_file(input_file, output_file, "ar")
para = _find_pptx_paragraph(Presentation(str(output_file)), "AR_Hello")
assert para is not None
for run in para.runs:
rPr = run._r.find(f"{{{A_NS}}}rPr")
assert rPr is not None, "rPr missing on run"
cs = rPr.find(f"{{{A_NS}}}cs")
assert cs is not None, "<a:cs> hint missing on run"
assert cs.get("typeface") == "Arial"
def test_fr_target_no_rtl_attributes(self, tmp_path):
provider = MockTranslationProvider({"Hello": "FR_Hello"})
translator = PowerPointTranslator(provider=provider)
input_file = self._build_single_text_pptx(tmp_path, "Hello")
output_file = tmp_path / "out.pptx"
translator.translate_file(input_file, output_file, "fr")
prs_out = Presentation(str(output_file))
para = _find_pptx_paragraph(prs_out, "FR_Hello")
assert para is not None
pPr = para._p.find(f"{{{A_NS}}}pPr")
assert pPr is None or pPr.get("rtl") is None
for run in para.runs:
rPr = run._r.find(f"{{{A_NS}}}rPr")
if rPr is not None:
assert rPr.find(f"{{{A_NS}}}cs") is None
# ---------------------------------------------------------------------------
# Excel — sheet view direction
# ---------------------------------------------------------------------------
class TestExcelRtlLayout:
def _build_workbook(self, tmp_path):
wb = Workbook()
wb.active["A1"] = "Alpha"
wb.create_sheet("Data2")["A1"] = "Beta"
wb.create_sheet("Data3")["A1"] = "Gamma"
input_file = tmp_path / "input.xlsx"
wb.save(input_file)
return input_file
def test_he_target_all_sheets_right_to_left(self, tmp_path):
provider = MockTranslationProvider(
{"Alpha": "HE_Alpha", "Beta": "HE_Beta", "Gamma": "HE_Gamma"}
)
translator = ExcelTranslator(provider=provider)
input_file = self._build_workbook(tmp_path)
output_file = tmp_path / "out.xlsx"
translator.translate_file(input_file, output_file, "he")
out = load_workbook(output_file)
assert len(out.sheetnames) == 3
for name in out.sheetnames:
assert out[name].sheet_view.rightToLeft is True, name
def test_fr_target_sheets_unchanged(self, tmp_path):
provider = MockTranslationProvider(
{"Alpha": "FR_Alpha", "Beta": "FR_Beta", "Gamma": "FR_Gamma"}
)
translator = ExcelTranslator(provider=provider)
input_file = self._build_workbook(tmp_path)
output_file = tmp_path / "out.xlsx"
translator.translate_file(input_file, output_file, "fr")
out = load_workbook(output_file)
for name in out.sheetnames:
assert not out[name].sheet_view.rightToLeft, name
def test_chartsheet_does_not_break_rtl_translation(self, tmp_path):
"""A workbook containing a chartsheet (no sheet_view, no cell grid)
must still translate, flip the worksheet views, and keep the
chartsheet intact in the output."""
from openpyxl.chart import BarChart, Reference
from openpyxl.chartsheet import Chartsheet
provider = MockTranslationProvider({"Alpha": "HE_Alpha"})
translator = ExcelTranslator(provider=provider)
wb = Workbook()
wb.active["A1"] = "Alpha"
data_sheet = wb.create_sheet("Data2")
data_sheet["B2"] = 42
chart = BarChart()
chart.add_data(Reference(data_sheet, min_col=2, min_row=2, max_row=5))
wb.create_chartsheet("ChartSheet").add_chart(chart)
input_file = tmp_path / "input.xlsx"
wb.save(input_file)
output_file = tmp_path / "out.xlsx"
# Must not raise (the chartsheet has neither sheet_view nor cells)
translator.translate_file(input_file, output_file, "he")
out = load_workbook(output_file)
chartsheets = [
name for name in out.sheetnames if isinstance(out[name], Chartsheet)
]
assert chartsheets, "chartsheet lost during translation"
worksheets = [
name for name in out.sheetnames if not isinstance(out[name], Chartsheet)
]
assert worksheets, "no worksheet found in output"
for name in worksheets:
assert out[name].sheet_view.rightToLeft is True, name
# ---------------------------------------------------------------------------
# PDF — shaping, font selection, clean-PDF mode
# ---------------------------------------------------------------------------
class TestPdfRtlShaping:
def test_shape_rtl_produces_presentation_forms(self):
shaped = _shape_rtl("سلام")
assert shaped != "سلام"
assert any(0xFB50 <= ord(c) <= 0xFEFF for c in shaped)
def test_shape_rtl_latin_passthrough(self):
assert _shape_rtl("Hello world") == "Hello world"
def test_shape_rtl_empty(self):
assert _shape_rtl("") == ""
def test_shape_rtl_missing_libraries_fallback(self, monkeypatch):
"""Missing arabic-reshaper / python-bidi must degrade to the raw
text (warning logged), never raise."""
import translators.pdf_translator as pdf_mod
# Reset the once-only warning flag so this test neither depends
# on nor leaks into the execution order of other tests.
monkeypatch.setattr(pdf_mod, "_RTL_SHAPING_MISSING_WARNED", False)
monkeypatch.setitem(sys.modules, "arabic_reshaper", None)
monkeypatch.setitem(sys.modules, "bidi", None)
monkeypatch.setitem(sys.modules, "bidi.algorithm", None)
text = "سلام دنیا"
assert _shape_rtl(text) == text
class _FakePage:
"""Minimal fitz.Page double: records every insert_textbox call."""
def __init__(self):
import fitz
self._fitz = fitz
self.rect = fitz.Rect(0, 0, 612, 792)
self.calls = []
def insert_textbox(self, rect, text, **kwargs):
self.calls.append({"text": text, **kwargs})
return 0 # text fits
class TestPdfLayoutModeRtl:
def _block(self, translated):
return {
"bbox": (72, 72, 300, 100),
"text": "orig",
"translated": translated,
"font_size": 12.0,
"color": 0,
"is_bold": False,
"is_italic": False,
}
def test_rtl_block_is_shaped_and_right_aligned(self):
import fitz
translator = PDFTranslator()
page = _FakePage()
ok = translator._write_translated_block(
page, self._block("سلام دنیا"), font_path=None, rtl_target=True
)
assert ok is True
assert page.calls, "no insert_textbox call recorded"
call = page.calls[0]
assert call["text"] != "سلام دنیا", "Arabic text must be shaped"
assert any(0xFB50 <= ord(c) <= 0xFEFF for c in call["text"])
assert call["align"] == fitz.TEXT_ALIGN_RIGHT
def test_ltr_block_is_not_shaped(self):
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]["text"] == "Bonjour le monde"
class TestPdfFontSelection:
def test_arabic_script_target_prefers_arabic_font(self, tmp_path, monkeypatch):
fake_arabic = tmp_path / "NotoNaskhArabic-Regular.ttf"
fake_arabic.write_bytes(b"fake")
fake_hebrew = tmp_path / "NotoSansHebrew-Regular.ttf"
fake_hebrew.write_bytes(b"fake")
fake_generic = tmp_path / "NotoSans-Regular.ttf"
fake_generic.write_bytes(b"fake")
translator = PDFTranslator()
monkeypatch.setattr(
translator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [str(fake_arabic)]
)
monkeypatch.setattr(
translator, "_RTL_HEBREW_FONT_SEARCH_PATHS", [str(fake_hebrew)]
)
monkeypatch.setattr(translator, "_FONT_SEARCH_PATHS", [str(fake_generic)])
assert translator._get_font_path("fa") == str(fake_arabic)
assert translator._get_font_path("ar-EG") == str(fake_arabic)
assert translator._get_font_path("ckb") == str(fake_arabic)
def test_hebrew_target_prefers_hebrew_font(self, tmp_path, monkeypatch):
"""A Hebrew target must NOT pick the Arabic font (tofu) — it
searches the Hebrew list first."""
fake_arabic = tmp_path / "NotoNaskhArabic-Regular.ttf"
fake_arabic.write_bytes(b"fake")
fake_hebrew = tmp_path / "NotoSansHebrew-Regular.ttf"
fake_hebrew.write_bytes(b"fake")
fake_generic = tmp_path / "NotoSans-Regular.ttf"
fake_generic.write_bytes(b"fake")
translator = PDFTranslator()
monkeypatch.setattr(
translator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [str(fake_arabic)]
)
monkeypatch.setattr(
translator, "_RTL_HEBREW_FONT_SEARCH_PATHS", [str(fake_hebrew)]
)
monkeypatch.setattr(translator, "_FONT_SEARCH_PATHS", [str(fake_generic)])
assert translator._get_font_path("he") == str(fake_hebrew)
assert translator._get_font_path("yi") == str(fake_hebrew)
def test_hebrew_target_falls_back_to_generic_font(self, tmp_path, monkeypatch):
fake_generic = tmp_path / "NotoSans-Regular.ttf"
fake_generic.write_bytes(b"fake")
translator = PDFTranslator()
monkeypatch.setattr(translator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [])
monkeypatch.setattr(translator, "_RTL_HEBREW_FONT_SEARCH_PATHS", [])
monkeypatch.setattr(translator, "_FONT_SEARCH_PATHS", [str(fake_generic)])
# Documented degradation: generic font + warning, never a failure.
assert translator._get_font_path("he") == str(fake_generic)
def test_arabic_target_falls_back_to_generic_font(self, tmp_path, monkeypatch):
fake_generic = tmp_path / "NotoSans-Regular.ttf"
fake_generic.write_bytes(b"fake")
translator = PDFTranslator()
monkeypatch.setattr(translator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [])
monkeypatch.setattr(translator, "_RTL_HEBREW_FONT_SEARCH_PATHS", [])
monkeypatch.setattr(translator, "_FONT_SEARCH_PATHS", [str(fake_generic)])
assert translator._get_font_path("ar") == str(fake_generic)
def test_thana_target_uses_generic_list_directly(self, tmp_path, monkeypatch):
"""dv (Thaana) has no dedicated font list — generic list directly,
never the Arabic/Hebrew ones."""
fake_arabic = tmp_path / "NotoNaskhArabic-Regular.ttf"
fake_arabic.write_bytes(b"fake")
fake_hebrew = tmp_path / "NotoSansHebrew-Regular.ttf"
fake_hebrew.write_bytes(b"fake")
fake_generic = tmp_path / "NotoSans-Regular.ttf"
fake_generic.write_bytes(b"fake")
translator = PDFTranslator()
monkeypatch.setattr(
translator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [str(fake_arabic)]
)
monkeypatch.setattr(
translator, "_RTL_HEBREW_FONT_SEARCH_PATHS", [str(fake_hebrew)]
)
monkeypatch.setattr(translator, "_FONT_SEARCH_PATHS", [str(fake_generic)])
assert translator._get_font_path("dv") == str(fake_generic)
def test_non_rtl_target_ignores_rtl_only_fonts(self, tmp_path, monkeypatch):
fake_arabic = tmp_path / "NotoNaskhArabic-Regular.ttf"
fake_arabic.write_bytes(b"fake")
translator = PDFTranslator()
monkeypatch.setattr(
translator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [str(fake_arabic)]
)
monkeypatch.setattr(translator, "_FONT_SEARCH_PATHS", [])
assert translator._get_font_path("fr") is None
class TestPdfFontRegistration:
def test_register_rtl_font_names_unique_per_path(self, tmp_path, monkeypatch):
"""Two different fonts (Arabic then Hebrew) must get distinct
reportlab names; the same path registers only once."""
import reportlab.pdfbase.pdfmetrics as pdfmetrics_mod
import reportlab.pdfbase.ttfonts as ttf_mod
import translators.pdf_translator as pdf_mod
registered = []
class FakeTTFont:
def __init__(self, name, path):
self.fontName = name
registered.append((name, path))
# Only the naming/registry logic is under test here — neutralize
# reportlab's own bookkeeping.
monkeypatch.setattr(ttf_mod, "TTFont", FakeTTFont)
monkeypatch.setattr(pdfmetrics_mod, "registerFont", lambda font: None)
monkeypatch.setattr(pdf_mod, "_RTL_FONT_REGISTRATIONS", {})
font_a = tmp_path / "hebrew.ttf"
font_a.write_bytes(b"x")
font_b = tmp_path / "arabic.ttf"
font_b.write_bytes(b"y")
name_a1 = pdf_mod._register_rtl_font(str(font_a))
name_a2 = pdf_mod._register_rtl_font(str(font_a))
name_b = pdf_mod._register_rtl_font(str(font_b))
assert name_a1 == name_a2, "same path must reuse its registration"
assert name_a1 != name_b, "different fonts must not share a name"
assert name_a1.startswith("WordlyRTL-")
assert len(registered) == 2, "same path must be registered once"
class TestPdfCleanModeRtl:
def _spy_on_body_style(self, monkeypatch):
import reportlab.lib.styles as rls
captured = {}
original = rls.ParagraphStyle
class SpyStyle(original):
def __init__(self, name, **kwargs):
super().__init__(name, **kwargs)
if name == "BodyText_Custom":
captured.update(kwargs)
monkeypatch.setattr(rls, "ParagraphStyle", SpyStyle)
return captured
def test_rtl_style_wordwrap_and_alignment(self, tmp_path, monkeypatch):
captured = self._spy_on_body_style(monkeypatch)
monkeypatch.setattr(PDFTranslator, "_RTL_ARABIC_FONT_SEARCH_PATHS", [])
monkeypatch.setattr(PDFTranslator, "_RTL_HEBREW_FONT_SEARCH_PATHS", [])
monkeypatch.setattr(PDFTranslator, "_FONT_SEARCH_PATHS", [])
from reportlab.lib.enums import TA_RIGHT
translator = PDFTranslator()
out = tmp_path / "clean_fa.pdf"
translator._generate_clean_pdf(["سلام دنیا"], out, "fa")
assert out.exists()
assert captured.get("wordWrap") == "RTL"
assert captured.get("alignment") == TA_RIGHT
# No Arabic-capable font on this (fake) host → Helvetica fallback
assert captured.get("fontName") == "Helvetica"
def test_ltr_style_unchanged(self, tmp_path, monkeypatch):
captured = self._spy_on_body_style(monkeypatch)
from reportlab.lib.enums import TA_JUSTIFY
translator = PDFTranslator()
out = tmp_path / "clean_fr.pdf"
translator._generate_clean_pdf(["Bonjour le monde"], out, "fr")
assert out.exists()
assert captured.get("wordWrap") is None
assert captured.get("alignment") == TA_JUSTIFY
assert captured.get("fontName") == "Helvetica"
@pytest.mark.skipif(_host_rtl_font() is None, reason="no Arabic-capable font on this host")
def test_rtl_real_font_registered_and_shaped(self, tmp_path):
"""End-to-end: shaped Arabic rendered with an Arabic-capable TTF."""
translator = PDFTranslator()
font_path = translator._get_font_path("fa")
assert font_path is not None
out = tmp_path / "clean_fa_real.pdf"
translator._generate_clean_pdf(["سلام دنیا این یک آزمایش است"], out, "fa")
import fitz
doc = fitz.open(str(out))
extracted = doc[0].get_text()
doc.close()
# Presentation forms in the output prove both the shaping and the
# Arabic-capable font reached the PDF (Helvetica cannot encode them).
assert any(0xFB50 <= ord(c) <= 0xFEFF for c in extracted)