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

@@ -230,11 +230,11 @@ class TestTextBoxTranslation:
assert text_found, "Translated text not found in output"
def test_multiple_runs_in_paragraph(self, tmp_path):
"""Test that multiple runs in a paragraph are translated."""
"""Test that adjacent same-style runs in a paragraph are merged
into ONE translation unit (whole-sentence translation)."""
mock_provider = MockTranslationProvider(
{
"Hello": "Bonjour",
"World": "Monde",
"Hello World": "Bonjour Monde",
}
)
translator = PowerPointTranslator(provider=mock_provider)
@@ -256,21 +256,22 @@ class TestTextBoxTranslation:
translator.translate_file(input_file, output_file, "fr")
# The two same-style runs were sent as ONE merged unit
sent = [req.text for req in mock_provider._requests_received]
assert "Hello World" in sent
assert "Hello" not in sent and "World" not in sent
prs_out = Presentation(str(output_file))
slide_out = prs_out.slides[0]
found_bonjour = False
found_monde = False
para_out = None
for shape in slide_out.shapes:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
for run in para.runs:
if "Bonjour" in run.text:
found_bonjour = True
if "Monde" in run.text:
found_monde = True
assert found_bonjour or found_monde
if shape.has_text_frame and shape.text_frame.paragraphs[0].runs:
para_out = shape.text_frame.paragraphs[0]
break
assert para_out is not None
assert para_out.runs[0].text == "Bonjour Monde"
assert para_out.runs[1].text == ""
class TestTableTranslation:
@@ -792,14 +793,178 @@ class TestPptxProcessorErrorHTTPMapping:
PptxProcessorError.PPTX_WRITE_ERROR,
PptxProcessorError.PPTX_TOO_LARGE,
]
for code in codes:
error = PptxProcessorError(code)
error_dict = error.to_dict()
# All errors must have these fields
assert "error" in error_dict, f"Missing 'error' field for {code}"
assert "message" in error_dict, f"Missing 'message' field for {code}"
assert error_dict["error"] == code
assert isinstance(error_dict["message"], str)
assert len(error_dict["message"]) > 0
# Espace de noms DrawingML pour construire des éléments XML dans les tests
_A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
class TestRunMerging:
"""Fusion des runs adjacents de même style en une seule unité.
Une phrase coupée en plusieurs runs de même style doit partir au
provider en UN morceau ; la traduction revient dans le premier run
et les autres sont vidés. Approche identique au traducteur Word.
"""
def _build_pptx(self, tmp_path, populate):
"""Crée un .pptx avec une zone de texte remplie par populate(paragraph)."""
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6]) # page vierge
textbox = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(6), Inches(1))
populate(textbox.text_frame.paragraphs[0])
input_file = tmp_path / "input.pptx"
output_file = tmp_path / "output.pptx"
prs.save(str(input_file))
return input_file, output_file
def _first_text_paragraph(self, path):
"""Relit le fichier traduit et renvoie le premier paragraphe non vide."""
prs_out = Presentation(str(path))
for shape in prs_out.slides[0].shapes:
if shape.has_text_frame and shape.text_frame.text.strip():
return shape.text_frame.paragraphs[0]
raise AssertionError("aucun paragraphe de texte trouvé dans la sortie")
def test_three_same_style_runs_merged_into_one_unit(self, tmp_path):
"""3 runs de même style formant une phrase → UNE seule unité envoyée,
la traduction dans le premier run, les suivants vidés."""
provider = MockTranslationProvider(
{"This is a very nice day.": "C'est un tres beau jour."}
)
translator = PowerPointTranslator(provider=provider)
def populate(p):
for chunk in ("This is ", "a very ", "nice day."):
run = p.add_run()
run.text = chunk
input_file, output_file = self._build_pptx(tmp_path, populate)
translator.translate_file(input_file, output_file, "fr")
sent = [req.text for req in provider._requests_received]
assert sent == ["This is a very nice day."], (
f"attendu une seule unité fusionnée, reçu: {sent}"
)
para = self._first_text_paragraph(output_file)
assert len(para.runs) == 3, "les éléments a:r ne doivent pas être supprimés"
assert para.runs[0].text == "C'est un tres beau jour."
assert para.runs[1].text == ""
assert para.runs[2].text == ""
def test_two_styles_stay_separate_units(self, tmp_path):
"""Runs gras + normal (signatures différentes) → 2 unités distinctes,
chaque traduction reste dans son run d'origine."""
provider = MockTranslationProvider({"Hello": "Bonjour", "World": "Monde"})
translator = PowerPointTranslator(provider=provider)
def populate(p):
bold = p.add_run()
bold.text = "Hello "
bold.font.bold = True
normal = p.add_run()
normal.text = "World"
input_file, output_file = self._build_pptx(tmp_path, populate)
translator.translate_file(input_file, output_file, "fr")
sent = [req.text for req in provider._requests_received]
assert sorted(sent) == ["Hello", "World"], (
f"attendu 2 unités distinctes, reçu: {sent}"
)
para = self._first_text_paragraph(output_file)
# chaque traduction dans son run d'origine, styles intacts
assert para.runs[0].text == "Bonjour " # espace de fin conservé
assert para.runs[0].font.bold is True
assert para.runs[1].text == "Monde"
assert para.runs[1].font.bold is None
def test_line_break_prevents_merging(self, tmp_path):
"""Deux runs de même style séparés par un a:br ne sont JAMAIS fusionnés."""
provider = MockTranslationProvider(
{"First line": "Premiere ligne", "Second line": "Seconde ligne"}
)
translator = PowerPointTranslator(provider=provider)
def populate(p):
r1 = p.add_run()
r1.text = "First line"
r2 = p.add_run()
r2.text = "Second line"
# saut de ligne DrawingML entre les deux runs
br = p._p.makeelement(f"{{{_A_NS}}}br", {})
r2._r.addprevious(br)
input_file, output_file = self._build_pptx(tmp_path, populate)
translator.translate_file(input_file, output_file, "fr")
sent = [req.text for req in provider._requests_received]
assert sorted(sent) == ["First line", "Second line"], (
f"les runs séparés par un a:br ne doivent pas être fusionnés: {sent}"
)
para = self._first_text_paragraph(output_file)
assert para.runs[0].text == "Premiere ligne"
assert para.runs[1].text == "Seconde ligne"
# le a:br est toujours là, entre les deux runs
child_tags = [child.tag.split("}")[-1] for child in para._p]
assert child_tags == ["r", "br", "r"], (
f"structure du paragraphe modifiée: {child_tags}"
)
def test_group_edge_whitespace_preserved(self, tmp_path):
"""Les espaces de début/fin du groupe sont conservés (lead/trail)."""
provider = MockTranslationProvider({"Leading text": "Texte d'introduction"})
translator = PowerPointTranslator(provider=provider)
def populate(p):
r1 = p.add_run()
r1.text = " Leading"
r2 = p.add_run()
r2.text = " text "
input_file, output_file = self._build_pptx(tmp_path, populate)
translator.translate_file(input_file, output_file, "fr")
# le provider reçoit le texte nu, sans les espaces de bord
sent = [req.text for req in provider._requests_received]
assert sent == ["Leading text"]
para = self._first_text_paragraph(output_file)
assert para.runs[0].text == " Texte d'introduction "
assert para.runs[1].text == ""
def test_newline_in_translation_survives_write(self, tmp_path):
"""Un saut de ligne dans la traduction ne casse pas l'écriture :
\n est un caractère XML valide, l'accesseur text de a:r
(CT_RegularTextRun) ne l'échappe pas — il atterrit tel quel dans
le <a:t> et ressort intact à la relecture."""
provider = MockTranslationProvider({"One line": "Ligne un\nLigne deux"})
translator = PowerPointTranslator(provider=provider)
def populate(p):
run = p.add_run()
run.text = "One line"
input_file, output_file = self._build_pptx(tmp_path, populate)
# ne doit pas lever d'exception
translator.translate_file(input_file, output_file, "fr")
para = self._first_text_paragraph(output_file)
assert "Ligne un" in para.runs[0].text
assert "Ligne deux" in para.runs[0].text
assert "\n" in para.runs[0].text

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.