diff --git a/tests/test_translators/test_pptx_translator.py b/tests/test_translators/test_pptx_translator.py index b86b72b..cef88e9 100644 --- a/tests/test_translators/test_pptx_translator.py +++ b/tests/test_translators/test_pptx_translator.py @@ -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 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 diff --git a/translators/pptx_translator.py b/translators/pptx_translator.py index 23bd36e..8af4a73 100644 --- a/translators/pptx_translator.py +++ b/translators/pptx_translator.py @@ -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 . + + 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 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 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 du premier run du + groupe (entourée des espaces de bord du groupe) et les + des runs suivants sont VIDÉS — les éléments restent en + place : leur style sert de référence visuelle et python-pptx + doit continuer de fonctionner. + + Le parcours suit les enfants de dans l'ordre du document : + tout enfant autre que — saut de ligne , champ + … — 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 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 (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.