diff --git a/tests/test_translators/test_excel_translator.py b/tests/test_translators/test_excel_translator.py index b68d43a..044407e 100644 --- a/tests/test_translators/test_excel_translator.py +++ b/tests/test_translators/test_excel_translator.py @@ -786,3 +786,58 @@ class TestMultipleSheets: assert wb_out.worksheets[0]["A1"].value == "Feuille1Texte" assert wb_out.worksheets[1]["A1"].value == "Feuille2Texte" + + +class TestColumnWidthAndAlignment: + """Tests for intelligent column width adjustment and RTL alignment.""" + + def test_column_width_widened_for_long_translation(self, tmp_path): + """Test that narrow columns are widened when translated text expands.""" + mock_provider = MockTranslationProvider( + { + "Hi": "This is a much longer translated sentence that requires more width", + } + ) + translator = ExcelTranslator(provider=mock_provider) + + wb = Workbook() + ws = wb.active + ws["A1"] = "Hi" + ws.column_dimensions["A"].width = 8.0 + + input_file = tmp_path / "narrow.xlsx" + output_file = tmp_path / "widened.xlsx" + wb.save(input_file) + + translator.translate_file(input_file, output_file, "fr") + + wb_out = load_workbook(output_file) + new_width = wb_out.active.column_dimensions["A"].width + assert new_width is not None + assert new_width > 15.0 + + def test_cell_alignment_flipped_for_rtl(self, tmp_path): + """Test that explicit left alignment is converted to right alignment in RTL.""" + from openpyxl.styles import Alignment + + mock_provider = MockTranslationProvider( + { + "Hello": "سلام", + } + ) + translator = ExcelTranslator(provider=mock_provider) + + wb = Workbook() + ws = wb.active + ws["A1"] = "Hello" + ws["A1"].alignment = Alignment(horizontal="left") + + input_file = tmp_path / "ltr_align.xlsx" + output_file = tmp_path / "rtl_align.xlsx" + wb.save(input_file) + + translator.translate_file(input_file, output_file, "fa") + + wb_out = load_workbook(output_file) + cell_align = wb_out.active["A1"].alignment + assert cell_align.horizontal == "right" diff --git a/tests/test_translators/test_pptx_translator.py b/tests/test_translators/test_pptx_translator.py index cef88e9..ba91813 100644 --- a/tests/test_translators/test_pptx_translator.py +++ b/tests/test_translators/test_pptx_translator.py @@ -968,3 +968,53 @@ class TestRunMerging: assert "Ligne un" in para.runs[0].text assert "Ligne deux" in para.runs[0].text assert "\n" in para.runs[0].text + + +class TestPptxTableRtlAndAutoFit: + """Tests for table RTL and text-frame word wrap / auto-fit.""" + + def test_table_rtl_attribute_applied(self, tmp_path): + """Table should receive for RTL target.""" + translator = PowerPointTranslator( + provider=MockTranslationProvider({"Item": "مورد"}) + ) + + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_table(1, 1, 100, 100, 200, 100) + shape.table.cell(0, 0).text = "Item" + + input_file = tmp_path / "input_tbl.pptx" + output_file = tmp_path / "output_tbl.pptx" + prs.save(input_file) + + translator.translate_file(input_file, output_file, "fa") + + prs_out = Presentation(output_file) + tbl_out = prs_out.slides[0].shapes[0] + tbl_el = tbl_out._element.find(".//{http://schemas.openxmlformats.org/drawingml/2006/main}tbl") + tblPr = tbl_el.find("{http://schemas.openxmlformats.org/drawingml/2006/main}tblPr") + assert tblPr is not None + assert tblPr.get("rtl") == "1" + + def test_word_wrap_and_autofit_configured(self, tmp_path): + """Text frames should have word_wrap and auto_size enabled.""" + translator = PowerPointTranslator( + provider=MockTranslationProvider({"Hello": "Bonjour"}) + ) + + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_textbox(100, 100, 200, 50) + shape.text_frame.text = "Hello" + shape.text_frame.word_wrap = False + + input_file = tmp_path / "input_wrap.pptx" + output_file = tmp_path / "output_wrap.pptx" + prs.save(input_file) + + translator.translate_file(input_file, output_file, "fr") + + prs_out = Presentation(output_file) + tf_out = prs_out.slides[0].shapes[0].text_frame + assert tf_out.word_wrap is True diff --git a/tests/test_translators/test_rtl_layout.py b/tests/test_translators/test_rtl_layout.py index e46ef3d..d8a5a0a 100644 --- a/tests/test_translators/test_rtl_layout.py +++ b/tests/test_translators/test_rtl_layout.py @@ -1225,3 +1225,42 @@ class TestLtrNormalization: assert "1." in lines[0] or "1" in lines[0] # Must not contain overflow marker assert "[translation overflow]" not in " ".join(lines) + + def test_pdf_multicolumn_protection(self, tmp_path): + """Tier 1 expansion on a left column must not expand past the right column.""" + import fitz + + src = tmp_path / "src_cols.pdf" + doc = fitz.open() + page = doc.new_page(width=600, height=400) + # Left column at x=50..250, right column at x=300..500 + page.insert_textbox(fitz.Rect(50, 50, 250, 100), "Titre gauche", fontsize=14, fontname="helv") + page.insert_textbox(fitz.Rect(300, 50, 500, 100), "Titre droite", fontsize=14, fontname="helv") + doc.save(str(src)) + doc.close() + + translator = PDFTranslator( + provider=MockTranslationProvider({ + "Titre gauche": "A very long left heading translation that requires more width", + "Titre droite": "Right heading", + }) + ) + out = tmp_path / "out_cols.pdf" + translator.translate_file(src, out, "en") + + doc2 = fitz.open(str(out)) + blocks = doc2[0].get_text("blocks") + doc2.close() + + left_blocks = [b for b in blocks if "left heading" in b[4]] + if left_blocks: + # The left block must NOT cross the start of the right column (x=300) + assert left_blocks[0][2] <= 300.0 + + def test_pdf_hebrew_formatting(self): + """Hebrew text must be visually formatted without Arabic cursive reshaping.""" + from translators.pdf_translator import _shape_rtl_multiline + hebrew_text = "שלום עולם" + res = _shape_rtl_multiline(hebrew_text, max_width=200, fontsize=12) + assert res is not None + assert len(res) > 0 diff --git a/translators/excel_translator.py b/translators/excel_translator.py index 81c1cc8..7453e0e 100644 --- a/translators/excel_translator.py +++ b/translators/excel_translator.py @@ -355,17 +355,16 @@ class ExcelTranslator: except Exception as e: _log_error("excel_sheet_images_failed", sheet_name=sheet_name, error=str(e)) - # Normalize the sheet reading direction to the TARGET language. - # RTL targets: flip every sheet so column A renders rightmost. - # Latin targets: restore left-to-right on sheets inherited from - # an RTL source (Persian → French must not keep the RTL view). - # Content and formatting are untouched — only the direction. - # Chartsheets have no `sheet_view` and must never break the - # save: each sheet is guarded individually. + # Normalize sheet reading direction, column widths, and cell alignment rtl_target = is_rtl(target_language) flipped = 0 for sheet_name in workbook.sheetnames: ws = workbook[sheet_name] + try: + self._adjust_column_widths_and_alignments(ws, rtl_target=rtl_target) + except Exception as e: + _log_error("excel_adjust_columns_failed", sheet=sheet_name, error=str(e)) + try: if getattr(ws, "sheet_view", None) is None: continue @@ -466,6 +465,53 @@ class ExcelTranslator: }, ) + def _adjust_column_widths_and_alignments( + self, worksheet: Worksheet, rtl_target: bool + ) -> None: + """Intelligently widen columns when translated text has expanded, + and normalize explicit cell text alignment for RTL/LTR.""" + import copy + from openpyxl.utils import get_column_letter + + if not hasattr(worksheet, "iter_cols"): + return + + try: + for col in worksheet.iter_cols(): + if not col: + continue + first_cell = col[0] + col_letter = get_column_letter(first_cell.column) + dim = worksheet.column_dimensions.get(col_letter) + current_width = dim.width if (dim and dim.width) else 10.0 + + max_len = 0 + for cell in col: + val = cell.value + if isinstance(val, str) and val.strip(): + lines = val.split("\n") + cell_max = max(len(l) for l in lines) + if cell_max > max_len: + max_len = cell_max + + # Normalize explicit horizontal alignment if present + if cell.alignment and isinstance(val, str): + if rtl_target and cell.alignment.horizontal == "left": + new_align = copy.copy(cell.alignment) + new_align.horizontal = "right" + cell.alignment = new_align + elif not rtl_target and cell.alignment.horizontal == "right": + new_align = copy.copy(cell.alignment) + new_align.horizontal = "left" + cell.alignment = new_align + + if max_len > 0: + ideal_width = min(max_len + 3.0, 50.0) + if ideal_width > current_width: + worksheet.column_dimensions[col_letter].width = ideal_width + except Exception as e: + _log_error("excel_adjust_columns_failed", sheet=getattr(worksheet, "title", ""), error=str(e)) + def _sanitize_sheet_name(self, name: str) -> str: """ Sanitize a sheet name to be valid for Excel. diff --git a/translators/pdf_translator.py b/translators/pdf_translator.py index 5c0f13b..980e56c 100644 --- a/translators/pdf_translator.py +++ b/translators/pdf_translator.py @@ -169,7 +169,19 @@ def _shape_rtl_multiline( shaped_lines = [] for l in lines: try: - shaped_lines.append(get_display(arabic_reshaper.reshape(l))) + # Arabic/Persian/Urdu script requires cursive contextual shaping (arabic_reshaper) + bidi display + # Hebrew script only requires bidi display (get_display) + has_arabic = any( + "\u0600" <= ch <= "\u06FF" + or "\u0750" <= ch <= "\u077F" + or "\uFB50" <= ch <= "\uFDFF" + or "\uFE70" <= ch <= "\uFEFF" + for ch in l + ) + if has_arabic: + shaped_lines.append(get_display(arabic_reshaper.reshape(l))) + else: + shaped_lines.append(get_display(l)) except Exception: shaped_lines.append(l) shaped_result.append("\n".join(shaped_lines)) @@ -1103,10 +1115,9 @@ class PDFTranslator: if not assigned: columns.append([block]) - # For each column, set next_block_y by y-order within that column. - # A block whose expanded bbox would touch the next column-mate's - # y0 will be capped to that y0 - 2 (small visual gap). - for column in columns: + # For each column, set next_block_y by y-order within that column, + # and set next_block_x to prevent expanding across adjacent columns. + for c_idx, column in enumerate(columns): column.sort(key=lambda b: b["bbox"][1]) for i, block in enumerate(column): if i + 1 < len(column): @@ -1114,6 +1125,18 @@ class PDFTranslator: else: block["_next_block_y"] = page_bottom + # Multi-column protection: find adjacent column to the right + # with overlapping vertical bounds + next_col_x0 = page_rect.x1 - margin + b_y0, b_y1 = block["bbox"][1], block["bbox"][3] + for other_col in columns[c_idx + 1:]: + for other_b in other_col: + if other_b["bbox"][1] < b_y1 and other_b["bbox"][3] > b_y0: + cand_x0 = other_b["bbox"][0] - 4.0 + if block["bbox"][2] < cand_x0 < next_col_x0: + next_col_x0 = cand_x0 + block["_next_block_x"] = next_col_x0 + def _write_translated_block( self, page, @@ -1177,10 +1200,9 @@ class PDFTranslator: page_rect = page.rect margin = 18 - # Track B3.6: try a wider bbox that respects the page margin. - # For headings, also allow horizontal expansion because long - # translated titles often don't fit in the original width. - max_x1 = page_rect.x1 - margin + # Track B3.6: try a wider bbox that respects the page margin and adjacent columns. + next_block_x = block.get("_next_block_x", page_rect.x1 - margin) + max_x1 = max(original_rect.x1, min(page_rect.x1 - margin, next_block_x)) expanded_h = fitz.Rect( max(original_rect.x0, page_rect.x0 + margin), original_rect.y0, diff --git a/translators/pptx_translator.py b/translators/pptx_translator.py index 68ae337..8e87315 100644 --- a/translators/pptx_translator.py +++ b/translators/pptx_translator.py @@ -151,6 +151,14 @@ def _apply_ltr_to_shape(shape) -> None: _unset_pptx_paragraph_rtl(paragraph) if shape.shape_type == MSO_SHAPE_TYPE.TABLE: + try: + tbl_el = shape._element.find(f".//{{{_NS_A}}}tbl") + if tbl_el is not None: + tblPr = tbl_el.find(f"{{{_NS_A}}}tblPr") + if tblPr is not None and "rtl" in tblPr.attrib: + del tblPr.attrib["rtl"] + except Exception: + pass for row in shape.table.rows: for cell in row.cells: for paragraph in cell.text_frame.paragraphs: @@ -168,6 +176,14 @@ def _apply_rtl_to_shape(shape) -> None: _set_pptx_paragraph_rtl(paragraph) if shape.shape_type == MSO_SHAPE_TYPE.TABLE: + try: + tbl_el = shape._element.find(f".//{{{_NS_A}}}tbl") + if tbl_el is not None: + tblPr = tbl_el.find(f"{{{_NS_A}}}tblPr") + if tblPr is not None: + tblPr.set("rtl", "1") + except Exception: + pass for row in shape.table.rows: for cell in row.cells: for paragraph in cell.text_frame.paragraphs: @@ -978,6 +994,19 @@ class PowerPointTranslator: if not text_frame.text.strip(): return + # Ensure word wrap is enabled so expanding translated text wraps naturally + # inside the shape rather than stretching past the slide edge. + # If no autofit is explicitly configured, enable text-to-fit-shape + # so PowerPoint can scale font size down if the translated text is too long. + try: + if not getattr(text_frame, "word_wrap", None): + text_frame.word_wrap = True + from pptx.enum.text import MSO_AUTO_SIZE + if getattr(text_frame, "auto_size", None) is None: + text_frame.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE + except Exception: + pass + tag_r = f"{{{_NS_A}}}r" tag_t = f"{{{_NS_A}}}t"