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
1009 lines
40 KiB
Python
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)
|