feat(pptx): traduire les phrases entieres, pas les fragments de meme style

Les runs adjacents d'un paragraphe PowerPoint portant le meme style
(empreinte du a:rPr serialise, comme dans le traducteur Word) sont
fusionnes en une seule unite de traduction. Une phrase coupee en
plusieurs segments de meme style est desormais traduite entiere ; la
traduction va dans le premier run, les suivants sont vides (les
elements restent en place). Les sauts de ligne (a:br) et champs (a:fld)
ferment le groupe : jamais de fusion a travers un saut de ligne.
5 tests nouveaux (TestRunMerging).
This commit is contained in:
2026-09-01 20:54:45 +02:00
parent d92bbf0fa6
commit 6595b77761
2 changed files with 284 additions and 28 deletions

View File

@@ -44,6 +44,21 @@ def _insert_a_rpr_child(rPr, child) -> None:
rPr.append(child)
def _arpr_signature(run_element) -> str:
"""Signature de style d'un run DrawingML : XML sérialisé de son <a:rPr>.
Deux runs adjacents dont les signatures sont identiques portent
exactement le même style : les fusionner ne change rien au rendu.
Un run sans <a:rPr> a la signature vide — commune à tous les runs
sans style explicite. Même approche que `_rpr_signature` du
traducteur Word.
"""
rPr = run_element.find(f"{{{_NS_A}}}rPr")
if rPr is None:
return ""
return etree.tostring(rPr, encoding="unicode")
from core.logging import get_logger
logger = get_logger(__name__)
@@ -871,28 +886,104 @@ class PowerPointTranslator:
def _collect_from_text_frame(
self, text_frame, text_elements: List[Tuple[str, Callable[[str], None]]]
) -> None:
"""Collect text from a text frame, preserving leading/trailing whitespace."""
"""Collect text from a text frame, preserving leading/trailing whitespace.
Les runs ADJACENTS d'un même paragraphe portant le même style
(même <a:rPr> sérialisé — voir `_arpr_signature`) sont fusionnés
en UNE unité de traduction, comme le fait le traducteur Word :
une phrase coupée en plusieurs runs de même style est traduite
entière au lieu d'être traduite fragment par fragment. À
l'écriture, la traduction va dans le <a:t> du premier run du
groupe (entourée des espaces de bord du groupe) et les <a:t>
des runs suivants sont VIDÉS — les éléments <a:r> restent en
place : leur style sert de référence visuelle et python-pptx
doit continuer de fonctionner.
Le parcours suit les enfants de <a:p> dans l'ordre du document :
tout enfant autre que <a:r> — saut de ligne <a:br>, champ
<a:fld>… — ferme le groupe en cours, car une traduction
fusionnée doit rester sur une seule ligne. Les runs vides ou
tout blancs restent dans leur groupe : leur texte participe à
la concaténation (espace séparateur) et leur <a:t> sera vidé
comme les autres ; un groupe entièrement blanc n'est pas traduit.
"""
if not text_frame.text.strip():
return
tag_r = f"{{{_NS_A}}}r"
tag_t = f"{{{_NS_A}}}t"
for paragraph in text_frame.paragraphs:
if not paragraph.text.strip():
continue
for run in paragraph.runs:
if run.text and run.text.strip():
original = run.text
leading = original[: len(original) - len(original.lstrip())]
trailing = original[len(original.rstrip()) :]
stripped = original.strip()
# Découpe du paragraphe en groupes de runs adjacents de même
# signature de style.
groups: List[List] = []
last_signature: Optional[str] = None
for child in paragraph._p:
if child.tag != tag_r:
# a:br, a:fld, a:endParaRPr… : la fusion s'arrête.
# Le groupe vide sert de barrière : le run qui suit ne
# sera jamais rattaché au groupe d'avant.
groups.append([])
last_signature = None
continue
signature = _arpr_signature(child)
if groups and groups[-1] and signature == last_signature:
groups[-1].append(child)
else:
groups.append([child])
last_signature = signature
def make_setter(r, lead: str, trail: str):
def setter(text: str) -> None:
r.text = lead + text.strip() + trail
for run_elems in groups:
if not run_elems:
continue
return setter
combined = "".join(
(t.text or "")
for r in run_elems
for t in r.findall(tag_t)
)
stripped = combined.strip()
if not stripped:
# Groupe entièrement blanc : rien à traduire, on
# laisse le rendu tel quel.
continue
leading = combined[: len(combined) - len(combined.lstrip())]
trailing = combined[len(combined.rstrip()):]
text_elements.append((stripped, make_setter(run, leading, trailing)))
# Premier run portant un <a:t> (en pratique le premier du
# groupe) : c'est lui qui reçoit la traduction.
first_r = next(
(r for r in run_elems if r.find(tag_t) is not None),
run_elems[0],
)
siblings = [r for r in run_elems if r is not first_r]
def make_group_setter(first_r, siblings, lead: str, trail: str):
def setter(text: str) -> None:
# La traduction entière du groupe va dans le
# premier run, entourée des espaces de bord du
# groupe. L'accesseur text de l'élément a:r
# (CT_RegularTextRun) échappe les caractères de
# contrôle à notre place.
first_r.text = lead + text.strip() + trail
# Les runs fusionnés sont vidés, pas supprimés :
# leur style (a:rPr) reste une référence visuelle
# et python-pptx continue de fonctionner.
for sib in siblings:
for t_elem in sib.findall(tag_t):
t_elem.text = ""
return setter
text_elements.append(
(
stripped,
make_group_setter(first_r, siblings, leading, trailing),
)
)
def _get_element_path(self, element) -> str:
"""Get a unique XPath-like path for an element within its XML tree.