""" 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, 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""" Footnote content """ _ENDNOTES_PART = b""" Endnote content Endnote cell """ _COMMENTS_PART = b""" Comment content Comment cell """ def _inject_part(docx_path: Path, kind: str, xml_bytes: bytes, rel_id: str) -> None: """Add a minimal word/.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( "", f'', ) rels = _read_zip_entry(docx_path, "word/_rels/document.xml.rels").decode("utf-8") if part_name not in rels: rels = rels.replace( "", f'', ) _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 = ( '' '' "" "TextBox content" "" "" ) # The sectPr must stay the LAST child of w:body. if "", textbox + "") _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 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, " 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, " 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)