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