fix(translators): ameliorer le respect des formats de fichiers pour Excel, PowerPoint et PDF
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m33s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m33s
- Excel: elargissement intelligent des colonnes pour le texte etendu et alignement RTL des cellules - PowerPoint: retour a la ligne et auto-fit anti-debordement, colonnes de tableaux en RTL - PDF: protection multi-colonnes pour eviter les chevauchements et formatage hebreu - Tests: validation de non-regression pour tous les formats
This commit is contained in:
@@ -786,3 +786,58 @@ class TestMultipleSheets:
|
|||||||
|
|
||||||
assert wb_out.worksheets[0]["A1"].value == "Feuille1Texte"
|
assert wb_out.worksheets[0]["A1"].value == "Feuille1Texte"
|
||||||
assert wb_out.worksheets[1]["A1"].value == "Feuille2Texte"
|
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"
|
||||||
|
|||||||
@@ -968,3 +968,53 @@ class TestRunMerging:
|
|||||||
assert "Ligne un" in para.runs[0].text
|
assert "Ligne un" in para.runs[0].text
|
||||||
assert "Ligne deux" in para.runs[0].text
|
assert "Ligne deux" in para.runs[0].text
|
||||||
assert "\n" 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 <a:tblPr rtl='1'> 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
|
||||||
|
|||||||
@@ -1225,3 +1225,42 @@ class TestLtrNormalization:
|
|||||||
assert "1." in lines[0] or "1" in lines[0]
|
assert "1." in lines[0] or "1" in lines[0]
|
||||||
# Must not contain overflow marker
|
# Must not contain overflow marker
|
||||||
assert "[translation overflow]" not in " ".join(lines)
|
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
|
||||||
|
|||||||
@@ -355,17 +355,16 @@ class ExcelTranslator:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log_error("excel_sheet_images_failed", sheet_name=sheet_name, error=str(e))
|
_log_error("excel_sheet_images_failed", sheet_name=sheet_name, error=str(e))
|
||||||
|
|
||||||
# Normalize the sheet reading direction to the TARGET language.
|
# Normalize sheet reading direction, column widths, and cell alignment
|
||||||
# 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.
|
|
||||||
rtl_target = is_rtl(target_language)
|
rtl_target = is_rtl(target_language)
|
||||||
flipped = 0
|
flipped = 0
|
||||||
for sheet_name in workbook.sheetnames:
|
for sheet_name in workbook.sheetnames:
|
||||||
ws = workbook[sheet_name]
|
ws = workbook[sheet_name]
|
||||||
|
try:
|
||||||
|
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:
|
try:
|
||||||
if getattr(ws, "sheet_view", None) is None:
|
if getattr(ws, "sheet_view", None) is None:
|
||||||
continue
|
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:
|
def _sanitize_sheet_name(self, name: str) -> str:
|
||||||
"""
|
"""
|
||||||
Sanitize a sheet name to be valid for Excel.
|
Sanitize a sheet name to be valid for Excel.
|
||||||
|
|||||||
@@ -169,7 +169,19 @@ def _shape_rtl_multiline(
|
|||||||
shaped_lines = []
|
shaped_lines = []
|
||||||
for l in lines:
|
for l in lines:
|
||||||
try:
|
try:
|
||||||
|
# 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)))
|
shaped_lines.append(get_display(arabic_reshaper.reshape(l)))
|
||||||
|
else:
|
||||||
|
shaped_lines.append(get_display(l))
|
||||||
except Exception:
|
except Exception:
|
||||||
shaped_lines.append(l)
|
shaped_lines.append(l)
|
||||||
shaped_result.append("\n".join(shaped_lines))
|
shaped_result.append("\n".join(shaped_lines))
|
||||||
@@ -1103,10 +1115,9 @@ class PDFTranslator:
|
|||||||
if not assigned:
|
if not assigned:
|
||||||
columns.append([block])
|
columns.append([block])
|
||||||
|
|
||||||
# For each column, set next_block_y by y-order within that column.
|
# 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
|
# and set next_block_x to prevent expanding across adjacent columns.
|
||||||
# y0 will be capped to that y0 - 2 (small visual gap).
|
for c_idx, column in enumerate(columns):
|
||||||
for column in columns:
|
|
||||||
column.sort(key=lambda b: b["bbox"][1])
|
column.sort(key=lambda b: b["bbox"][1])
|
||||||
for i, block in enumerate(column):
|
for i, block in enumerate(column):
|
||||||
if i + 1 < len(column):
|
if i + 1 < len(column):
|
||||||
@@ -1114,6 +1125,18 @@ class PDFTranslator:
|
|||||||
else:
|
else:
|
||||||
block["_next_block_y"] = page_bottom
|
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(
|
def _write_translated_block(
|
||||||
self,
|
self,
|
||||||
page,
|
page,
|
||||||
@@ -1177,10 +1200,9 @@ class PDFTranslator:
|
|||||||
page_rect = page.rect
|
page_rect = page.rect
|
||||||
margin = 18
|
margin = 18
|
||||||
|
|
||||||
# Track B3.6: try a wider bbox that respects the page margin.
|
# Track B3.6: try a wider bbox that respects the page margin and adjacent columns.
|
||||||
# For headings, also allow horizontal expansion because long
|
next_block_x = block.get("_next_block_x", page_rect.x1 - margin)
|
||||||
# translated titles often don't fit in the original width.
|
max_x1 = max(original_rect.x1, min(page_rect.x1 - margin, next_block_x))
|
||||||
max_x1 = page_rect.x1 - margin
|
|
||||||
expanded_h = fitz.Rect(
|
expanded_h = fitz.Rect(
|
||||||
max(original_rect.x0, page_rect.x0 + margin),
|
max(original_rect.x0, page_rect.x0 + margin),
|
||||||
original_rect.y0,
|
original_rect.y0,
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ def _apply_ltr_to_shape(shape) -> None:
|
|||||||
_unset_pptx_paragraph_rtl(paragraph)
|
_unset_pptx_paragraph_rtl(paragraph)
|
||||||
|
|
||||||
if shape.shape_type == MSO_SHAPE_TYPE.TABLE:
|
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 row in shape.table.rows:
|
||||||
for cell in row.cells:
|
for cell in row.cells:
|
||||||
for paragraph in cell.text_frame.paragraphs:
|
for paragraph in cell.text_frame.paragraphs:
|
||||||
@@ -168,6 +176,14 @@ def _apply_rtl_to_shape(shape) -> None:
|
|||||||
_set_pptx_paragraph_rtl(paragraph)
|
_set_pptx_paragraph_rtl(paragraph)
|
||||||
|
|
||||||
if shape.shape_type == MSO_SHAPE_TYPE.TABLE:
|
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 row in shape.table.rows:
|
||||||
for cell in row.cells:
|
for cell in row.cells:
|
||||||
for paragraph in cell.text_frame.paragraphs:
|
for paragraph in cell.text_frame.paragraphs:
|
||||||
@@ -978,6 +994,19 @@ class PowerPointTranslator:
|
|||||||
if not text_frame.text.strip():
|
if not text_frame.text.strip():
|
||||||
return
|
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_r = f"{{{_NS_A}}}r"
|
||||||
tag_t = f"{{{_NS_A}}}t"
|
tag_t = f"{{{_NS_A}}}t"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user