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