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

- 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:
2026-09-02 21:35:31 +02:00
parent cae09fc512
commit 6f5b6acbdc
6 changed files with 257 additions and 16 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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"