feat(rtl): rendu droite-a-gauche complet pour Word, PowerPoint, Excel et PDF
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m37s
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
This commit is contained in:
@@ -20,16 +20,30 @@ from pptx.shapes.group import GroupShape
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
|
||||
from services.providers.base import TranslationProvider
|
||||
from core.languages import is_rtl
|
||||
|
||||
# DrawingML namespace used by pptx XML
|
||||
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
|
||||
# Languages written right-to-left
|
||||
RTL_LANGUAGES: frozenset = frozenset(
|
||||
{"ar", "he", "fa", "ur", "ku", "ps", "ug", "sd", "yi", "dv", "ckb"}
|
||||
# a:rPr children that must come AFTER <a:latin>/<a:ea>/<a:cs> per the
|
||||
# DrawingML CT_TextCharacterProperties sequence. Inserting <a:ea>/<a:cs>
|
||||
# before them keeps the XML schema-valid (PowerPoint repairs otherwise).
|
||||
_A_RPR_HINT_SUCCESSORS = tuple(
|
||||
f"{{{_NS_A}}}{tag}"
|
||||
for tag in ("sym", "hlinkClick", "hlinkMouseOver", "rtl", "extLst")
|
||||
)
|
||||
|
||||
|
||||
def _insert_a_rpr_child(rPr, child) -> None:
|
||||
"""Insert child into a:rPr at its schema position (before sym,
|
||||
hlink*, rtl and extLst — or appended at the end when none exist)."""
|
||||
for existing in rPr:
|
||||
if existing.tag in _A_RPR_HINT_SUCCESSORS:
|
||||
existing.addprevious(child)
|
||||
return
|
||||
rPr.append(child)
|
||||
|
||||
|
||||
from core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -58,8 +72,10 @@ def _set_pptx_paragraph_rtl(paragraph) -> None:
|
||||
"""
|
||||
Enable RTL mode on a PowerPoint paragraph.
|
||||
|
||||
Sets rtl="1" and algn="r" on the <a:pPr> element, which controls
|
||||
both text direction and horizontal alignment in DrawingML.
|
||||
Sets rtl="1" on the <a:pPr> element, which controls the paragraph
|
||||
text direction. The horizontal alignment (algn) is only forced to
|
||||
"r" when the paragraph has no explicit alignment or is aligned
|
||||
"l" (left) — a centered or justified title keeps its alignment.
|
||||
"""
|
||||
p_elem = paragraph._p
|
||||
tag_pPr = f"{{{_NS_A}}}pPr"
|
||||
@@ -68,14 +84,20 @@ def _set_pptx_paragraph_rtl(paragraph) -> None:
|
||||
pPr = etree.Element(tag_pPr)
|
||||
p_elem.insert(0, pPr)
|
||||
pPr.set("rtl", "1")
|
||||
pPr.set("algn", "r")
|
||||
if pPr.get("algn") in (None, "", "l"):
|
||||
pPr.set("algn", "r")
|
||||
|
||||
|
||||
def _apply_rtl_to_presentation(presentation: Presentation) -> None:
|
||||
"""Apply RTL direction to every paragraph in all slides."""
|
||||
"""Apply RTL direction to every paragraph in all slides and notes."""
|
||||
for slide in presentation.slides:
|
||||
for shape in slide.shapes:
|
||||
_apply_rtl_to_shape(shape)
|
||||
# Speaker notes are translated like any other text — they read
|
||||
# right-to-left too when the target language does.
|
||||
if slide.has_notes_slide and slide.notes_slide.notes_text_frame is not None:
|
||||
for paragraph in slide.notes_slide.notes_text_frame.paragraphs:
|
||||
_set_pptx_paragraph_rtl(paragraph)
|
||||
|
||||
|
||||
def _apply_rtl_to_shape(shape) -> None:
|
||||
@@ -111,11 +133,10 @@ def _ea_typeface_for_target(target_language: str):
|
||||
return _EA_TYPEFACES.get(code) or _EA_TYPEFACES.get(base)
|
||||
|
||||
|
||||
def _apply_ea_font_hints(presentation: Presentation, target_language: str) -> None:
|
||||
"""Set the <a:ea> (east-asian) typeface on every run for CJK targets.
|
||||
|
||||
Blanket application is safe — the hint only affects CJK glyphs.
|
||||
"""
|
||||
def _apply_run_typeface_hint(
|
||||
presentation: Presentation, tag: str, typeface: str, log_event: str
|
||||
) -> None:
|
||||
"""Set the <a:{tag}> typeface hint on every run of every slide shape."""
|
||||
|
||||
def _hint_shape(shape) -> int:
|
||||
hinted = 0
|
||||
@@ -133,29 +154,59 @@ def _apply_ea_font_hints(presentation: Presentation, target_language: str) -> No
|
||||
def _hint_text_frame(text_frame) -> int:
|
||||
hinted = 0
|
||||
tag_rPr = f"{{{_NS_A}}}rPr"
|
||||
tag_ea = f"{{{_NS_A}}}ea"
|
||||
tag_hint = f"{{{_NS_A}}}{tag}"
|
||||
for paragraph in text_frame.paragraphs:
|
||||
for run in paragraph.runs:
|
||||
rPr = run._r.find(tag_rPr)
|
||||
if rPr is None:
|
||||
rPr = etree.SubElement(run._r, tag_rPr)
|
||||
ea = rPr.find(tag_ea)
|
||||
if ea is None:
|
||||
ea = etree.SubElement(rPr, tag_ea)
|
||||
if not ea.get("typeface"):
|
||||
ea.set("typeface", typeface)
|
||||
# a:rPr must be the FIRST child of a:r (before a:t).
|
||||
rPr = etree.Element(tag_rPr)
|
||||
run._r.insert(0, rPr)
|
||||
hint = rPr.find(tag_hint)
|
||||
if hint is None:
|
||||
hint = etree.Element(tag_hint)
|
||||
_insert_a_rpr_child(rPr, hint)
|
||||
if not hint.get("typeface"):
|
||||
hint.set("typeface", typeface)
|
||||
hinted += 1
|
||||
return hinted
|
||||
|
||||
typeface = _ea_typeface_for_target(target_language)
|
||||
if not typeface:
|
||||
return
|
||||
total = 0
|
||||
for slide in presentation.slides:
|
||||
for shape in slide.shapes:
|
||||
total += _hint_shape(shape)
|
||||
# Speaker notes get the RTL direction — they need the typeface
|
||||
# hint too.
|
||||
if slide.has_notes_slide and slide.notes_slide.notes_text_frame is not None:
|
||||
total += _hint_text_frame(slide.notes_slide.notes_text_frame)
|
||||
if total:
|
||||
_log_info("pptx_ea_font_hints_applied", runs=total, typeface=typeface)
|
||||
_log_info(log_event, runs=total, typeface=typeface)
|
||||
|
||||
|
||||
def _apply_ea_font_hints(presentation: Presentation, target_language: str) -> None:
|
||||
"""Set the <a:ea> (east-asian) typeface on every run for CJK targets.
|
||||
|
||||
Blanket application is safe — the hint only affects CJK glyphs.
|
||||
"""
|
||||
typeface = _ea_typeface_for_target(target_language)
|
||||
if not typeface:
|
||||
return
|
||||
_apply_run_typeface_hint(
|
||||
presentation, "ea", typeface, "pptx_ea_font_hints_applied"
|
||||
)
|
||||
|
||||
|
||||
def _apply_cs_font_hints(presentation: Presentation, target_language: str) -> None:
|
||||
"""Set the <a:cs> (complex-script) typeface on every run for RTL targets.
|
||||
|
||||
Blanket application is safe — the hint only affects Arabic-script
|
||||
glyphs, and Arial covers the whole Arabic script on every platform.
|
||||
"""
|
||||
if not is_rtl(target_language):
|
||||
return
|
||||
_apply_run_typeface_hint(
|
||||
presentation, "cs", "Arial", "pptx_cs_font_hints_applied"
|
||||
)
|
||||
|
||||
|
||||
class PptxProcessorError(Exception):
|
||||
@@ -397,13 +448,17 @@ class PowerPointTranslator:
|
||||
)
|
||||
|
||||
# Apply RTL layout when the target language is written right-to-left.
|
||||
if target_language.lower() in RTL_LANGUAGES:
|
||||
if is_rtl(target_language):
|
||||
_apply_rtl_to_presentation(presentation)
|
||||
|
||||
# CJK font hint so the target script renders with a proper
|
||||
# typeface instead of shape-dependent fallbacks.
|
||||
_apply_ea_font_hints(presentation, target_language)
|
||||
|
||||
# Arabic-script font hint for RTL targets (same idea as the
|
||||
# CJK hint, via the <a:cs> typeface).
|
||||
_apply_cs_font_hints(presentation, target_language)
|
||||
|
||||
if translate_images:
|
||||
try:
|
||||
self._translate_images(presentation, target_language)
|
||||
|
||||
Reference in New Issue
Block a user