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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user