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"
|
assert text_found, "Translated text not found in output"
|
||||||
|
|
||||||
def test_multiple_runs_in_paragraph(self, tmp_path):
|
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(
|
mock_provider = MockTranslationProvider(
|
||||||
{
|
{
|
||||||
"Hello": "Bonjour",
|
"Hello World": "Bonjour Monde",
|
||||||
"World": "Monde",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
translator = PowerPointTranslator(provider=mock_provider)
|
translator = PowerPointTranslator(provider=mock_provider)
|
||||||
@@ -256,21 +256,22 @@ class TestTextBoxTranslation:
|
|||||||
|
|
||||||
translator.translate_file(input_file, output_file, "fr")
|
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))
|
prs_out = Presentation(str(output_file))
|
||||||
slide_out = prs_out.slides[0]
|
slide_out = prs_out.slides[0]
|
||||||
|
|
||||||
found_bonjour = False
|
para_out = None
|
||||||
found_monde = False
|
|
||||||
for shape in slide_out.shapes:
|
for shape in slide_out.shapes:
|
||||||
if shape.has_text_frame:
|
if shape.has_text_frame and shape.text_frame.paragraphs[0].runs:
|
||||||
for para in shape.text_frame.paragraphs:
|
para_out = shape.text_frame.paragraphs[0]
|
||||||
for run in para.runs:
|
break
|
||||||
if "Bonjour" in run.text:
|
assert para_out is not None
|
||||||
found_bonjour = True
|
assert para_out.runs[0].text == "Bonjour Monde"
|
||||||
if "Monde" in run.text:
|
assert para_out.runs[1].text == ""
|
||||||
found_monde = True
|
|
||||||
|
|
||||||
assert found_bonjour or found_monde
|
|
||||||
|
|
||||||
|
|
||||||
class TestTableTranslation:
|
class TestTableTranslation:
|
||||||
@@ -792,14 +793,178 @@ class TestPptxProcessorErrorHTTPMapping:
|
|||||||
PptxProcessorError.PPTX_WRITE_ERROR,
|
PptxProcessorError.PPTX_WRITE_ERROR,
|
||||||
PptxProcessorError.PPTX_TOO_LARGE,
|
PptxProcessorError.PPTX_TOO_LARGE,
|
||||||
]
|
]
|
||||||
|
|
||||||
for code in codes:
|
for code in codes:
|
||||||
error = PptxProcessorError(code)
|
error = PptxProcessorError(code)
|
||||||
error_dict = error.to_dict()
|
error_dict = error.to_dict()
|
||||||
|
|
||||||
# All errors must have these fields
|
# All errors must have these fields
|
||||||
assert "error" in error_dict, f"Missing 'error' field for {code}"
|
assert "error" in error_dict, f"Missing 'error' field for {code}"
|
||||||
assert "message" in error_dict, f"Missing 'message' field for {code}"
|
assert "message" in error_dict, f"Missing 'message' field for {code}"
|
||||||
assert error_dict["error"] == code
|
assert error_dict["error"] == code
|
||||||
assert isinstance(error_dict["message"], str)
|
assert isinstance(error_dict["message"], str)
|
||||||
assert len(error_dict["message"]) > 0
|
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)
|
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
|
from core.logging import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -871,28 +886,104 @@ class PowerPointTranslator:
|
|||||||
def _collect_from_text_frame(
|
def _collect_from_text_frame(
|
||||||
self, text_frame, text_elements: List[Tuple[str, Callable[[str], None]]]
|
self, text_frame, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||||
) -> 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():
|
if not text_frame.text.strip():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
tag_r = f"{{{_NS_A}}}r"
|
||||||
|
tag_t = f"{{{_NS_A}}}t"
|
||||||
|
|
||||||
for paragraph in text_frame.paragraphs:
|
for paragraph in text_frame.paragraphs:
|
||||||
if not paragraph.text.strip():
|
if not paragraph.text.strip():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for run in paragraph.runs:
|
# Découpe du paragraphe en groupes de runs adjacents de même
|
||||||
if run.text and run.text.strip():
|
# signature de style.
|
||||||
original = run.text
|
groups: List[List] = []
|
||||||
leading = original[: len(original) - len(original.lstrip())]
|
last_signature: Optional[str] = None
|
||||||
trailing = original[len(original.rstrip()) :]
|
for child in paragraph._p:
|
||||||
stripped = original.strip()
|
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):
|
for run_elems in groups:
|
||||||
def setter(text: str) -> None:
|
if not run_elems:
|
||||||
r.text = lead + text.strip() + trail
|
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:
|
def _get_element_path(self, element) -> str:
|
||||||
"""Get a unique XPath-like path for an element within its XML tree.
|
"""Get a unique XPath-like path for an element within its XML tree.
|
||||||
|
|||||||
Reference in New Issue
Block a user