feat(format): B2 — PPTX placeholder filter, SmartArt diagrams, chart XPath matching
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m3s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m3s
This commit is contained in:
623
tests/test_translators/test_b2_pptx_fixes.py
Normal file
623
tests/test_translators/test_b2_pptx_fixes.py
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
"""
|
||||||
|
Tests for Track B2 — PowerPoint format preservation fixes.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
B2.1 — Placeholder filter (DATE=16, SLIDE_NUMBER=13, FOOTER=15)
|
||||||
|
PowerPoint auto-fills these at render time, so translating them
|
||||||
|
is pointless and just wastes API calls.
|
||||||
|
B2.2 — SmartArt diagram text collection (ppt/diagrams/data*.xml)
|
||||||
|
B2.3 — SmartArt diagram translation applied via ZIP rewrite
|
||||||
|
B2.4 — Chart text matched by element path (not string equality)
|
||||||
|
B2.5 — Whitespace preservation in chart apply
|
||||||
|
|
||||||
|
These tests are intentionally narrow: they validate that a specific
|
||||||
|
bug is fixed. Existing tests in test_pptx_translator.py handle the
|
||||||
|
broader surface.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pptx import Presentation
|
||||||
|
from pptx.util import Inches, Pt
|
||||||
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
from translators.pptx_translator import PowerPointTranslator, pptx_translator
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Mock provider ----------------
|
||||||
|
|
||||||
|
class MockProvider:
|
||||||
|
"""Mock translation provider: returns 'TR_<text>' unless a mapping
|
||||||
|
is provided."""
|
||||||
|
|
||||||
|
def __init__(self, translations: Dict[str, str] = None):
|
||||||
|
self._translations = translations or {}
|
||||||
|
self.calls: List[str] = []
|
||||||
|
|
||||||
|
def translate_batch(
|
||||||
|
self,
|
||||||
|
texts: List[str],
|
||||||
|
target_language: str = "fr",
|
||||||
|
source_language: str = "auto",
|
||||||
|
) -> List[str]:
|
||||||
|
out = []
|
||||||
|
for t in texts:
|
||||||
|
self.calls.append(t)
|
||||||
|
if t in self._translations:
|
||||||
|
out.append(self._translations[t])
|
||||||
|
elif t.startswith("TR_"):
|
||||||
|
out.append(t)
|
||||||
|
else:
|
||||||
|
out.append(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def get_name(self):
|
||||||
|
return "mock"
|
||||||
|
|
||||||
|
def is_available(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Helpers ----------------
|
||||||
|
|
||||||
|
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||||
|
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
||||||
|
_NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||||
|
|
||||||
|
|
||||||
|
def _add_textbox_with_text(slide, text: str):
|
||||||
|
tb = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(3), Inches(1))
|
||||||
|
tf = tb.text_frame
|
||||||
|
tf.text = text
|
||||||
|
return tb
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_diagram_part(pptx_path: Path, diagram_filename: str, diagram_xml: bytes):
|
||||||
|
"""Inject a SmartArt diagram XML part into a .pptx file."""
|
||||||
|
tmp_out = pptx_path.with_suffix(".tmp")
|
||||||
|
with zipfile.ZipFile(pptx_path, "r") as zin, \
|
||||||
|
zipfile.ZipFile(tmp_out, "w", zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
existing = {item: zin.read(item) for item in zin.namelist()}
|
||||||
|
|
||||||
|
# Add the diagram data part
|
||||||
|
existing[diagram_filename] = diagram_xml
|
||||||
|
|
||||||
|
# Register in [Content_Types].xml
|
||||||
|
ct_path = "[Content_Types].xml"
|
||||||
|
if ct_path in existing:
|
||||||
|
ct_content = existing[ct_path].decode("utf-8")
|
||||||
|
override = (
|
||||||
|
f'<Override PartName="/{diagram_filename}" '
|
||||||
|
f'ContentType="application/vnd.openxmlformats-officedocument.'
|
||||||
|
f'drawingml+xml+diagram"/>'
|
||||||
|
)
|
||||||
|
if override not in ct_content:
|
||||||
|
ct_content = ct_content.replace("</Types>", f"{override}</Types>")
|
||||||
|
existing[ct_path] = ct_content.encode("utf-8")
|
||||||
|
|
||||||
|
for item, data in existing.items():
|
||||||
|
zout.writestr(item, data)
|
||||||
|
|
||||||
|
tmp_out.replace(pptx_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_chart_part(pptx_path: Path, chart_filename: str, chart_xml: bytes):
|
||||||
|
"""Inject a chart XML part into a .pptx file."""
|
||||||
|
tmp_out = pptx_path.with_suffix(".tmp")
|
||||||
|
with zipfile.ZipFile(pptx_path, "r") as zin, \
|
||||||
|
zipfile.ZipFile(tmp_out, "w", zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
existing = {item: zin.read(item) for item in zin.namelist()}
|
||||||
|
|
||||||
|
existing[chart_filename] = chart_xml
|
||||||
|
|
||||||
|
ct_path = "[Content_Types].xml"
|
||||||
|
if ct_path in existing:
|
||||||
|
ct_content = existing[ct_path].decode("utf-8")
|
||||||
|
override = (
|
||||||
|
f'<Override PartName="/{chart_filename}" '
|
||||||
|
f'ContentType="application/vnd.openxmlformats-officedocument.'
|
||||||
|
f'drawingml+xml+chart"/>'
|
||||||
|
)
|
||||||
|
if override not in ct_content:
|
||||||
|
ct_content = ct_content.replace("</Types>", f"{override}</Types>")
|
||||||
|
existing[ct_path] = ct_content.encode("utf-8")
|
||||||
|
|
||||||
|
for item, data in existing.items():
|
||||||
|
zout.writestr(item, data)
|
||||||
|
|
||||||
|
tmp_out.replace(pptx_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# B2.1 — Placeholder filter (DATE / SLIDE_NUMBER / FOOTER)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestPptxPlaceholderFilter:
|
||||||
|
"""Bug fix: PowerPoint auto-generates DATE, SLIDE_NUMBER, and FOOTER
|
||||||
|
placeholders at render time. Translating them wastes API calls
|
||||||
|
because the translated value gets overwritten on next render."""
|
||||||
|
|
||||||
|
def _build_pptx_with_placeholders(self, tmp_path: Path) -> Path:
|
||||||
|
"""Build a PPTX with a date placeholder, a slide-number placeholder,
|
||||||
|
a footer placeholder, plus one real text box. Returns the path."""
|
||||||
|
from pptx import Presentation
|
||||||
|
|
||||||
|
prs = Presentation()
|
||||||
|
slide_layout = prs.slide_layouts[6] # blank
|
||||||
|
slide = prs.slides.add_slide(slide_layout)
|
||||||
|
|
||||||
|
# Add real text — should be translated
|
||||||
|
_add_textbox_with_text(slide, "Real text")
|
||||||
|
|
||||||
|
# python-pptx doesn't expose a direct "add footer/date/sldNum
|
||||||
|
# placeholder" API, so we inject the XML directly. The placeholder
|
||||||
|
# types that should be filtered:
|
||||||
|
# 13 (sldNum), 15 (ftr), 16 (dt)
|
||||||
|
from pptx.oxml.ns import qn
|
||||||
|
from lxml import etree as _etree
|
||||||
|
|
||||||
|
spTree = slide.shapes._spTree
|
||||||
|
nvSpPr_template = """
|
||||||
|
<p:sp xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||||
|
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
|
||||||
|
<p:nvSpPr>
|
||||||
|
<p:nvPr>
|
||||||
|
<p:ph type="{ph_type}" idx="1"/>
|
||||||
|
</p:nvPr>
|
||||||
|
<p:cNvPr id="{shape_id}" name="PH{shape_id}"/>
|
||||||
|
<p:cNvSpPr/>
|
||||||
|
</p:nvSpPr>
|
||||||
|
<p:spPr>
|
||||||
|
<a:xfrm>
|
||||||
|
<a:off x="0" y="0"/>
|
||||||
|
<a:ext cx="1000000" cy="500000"/>
|
||||||
|
</a:xfrm>
|
||||||
|
</p:spPr>
|
||||||
|
<p:txBody>
|
||||||
|
<a:bodyPr/>
|
||||||
|
<a:lstStyle/>
|
||||||
|
<a:p>
|
||||||
|
<a:r>
|
||||||
|
<a:rPr lang="en-US"/>
|
||||||
|
<a:t>{text}</a:t>
|
||||||
|
</a:r>
|
||||||
|
</a:p>
|
||||||
|
</p:txBody>
|
||||||
|
</p:sp>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Footer placeholder (type 15)
|
||||||
|
sp_xml = nvSpPr_template.format(
|
||||||
|
ph_type="ftr",
|
||||||
|
shape_id=100,
|
||||||
|
text="Footer auto",
|
||||||
|
)
|
||||||
|
sp_elem = _etree.fromstring(sp_xml)
|
||||||
|
spTree.append(sp_elem)
|
||||||
|
|
||||||
|
# Date placeholder (type 16)
|
||||||
|
sp_xml = nvSpPr_template.format(
|
||||||
|
ph_type="dt",
|
||||||
|
shape_id=101,
|
||||||
|
text="Date auto",
|
||||||
|
)
|
||||||
|
sp_elem = _etree.fromstring(sp_xml)
|
||||||
|
spTree.append(sp_elem)
|
||||||
|
|
||||||
|
# Slide number placeholder (type 13)
|
||||||
|
sp_xml = nvSpPr_template.format(
|
||||||
|
ph_type="sldNum",
|
||||||
|
shape_id=102,
|
||||||
|
text="Slide number auto",
|
||||||
|
)
|
||||||
|
sp_elem = _etree.fromstring(sp_xml)
|
||||||
|
spTree.append(sp_elem)
|
||||||
|
|
||||||
|
path = tmp_path / "placeholders.pptx"
|
||||||
|
prs.save(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_placeholders_skipped(self, tmp_path):
|
||||||
|
"""The three auto-generated placeholder types should NOT be sent
|
||||||
|
to the translation provider."""
|
||||||
|
provider = MockProvider({"Real text": "TR_Real text"})
|
||||||
|
translator = PowerPointTranslator(provider=provider)
|
||||||
|
|
||||||
|
input_file = self._build_pptx_with_placeholders(tmp_path)
|
||||||
|
output_file = tmp_path / "output.pptx"
|
||||||
|
|
||||||
|
translator.translate_file(input_file, output_file, "fr")
|
||||||
|
|
||||||
|
# Real text should be translated
|
||||||
|
assert "Real text" in provider.calls, (
|
||||||
|
f"Real text missing from provider calls: {provider.calls}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Auto-generated placeholders should NOT be in the calls
|
||||||
|
assert "Footer auto" not in provider.calls, (
|
||||||
|
f"Footer placeholder was sent to provider: {provider.calls}"
|
||||||
|
)
|
||||||
|
assert "Date auto" not in provider.calls, (
|
||||||
|
f"Date placeholder was sent to provider: {provider.calls}"
|
||||||
|
)
|
||||||
|
assert "Slide number auto" not in provider.calls, (
|
||||||
|
f"Slide number placeholder was sent to provider: {provider.calls}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# B2.2 — SmartArt diagram text collection
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestPptxDiagramTextCollection:
|
||||||
|
"""New feature: SmartArt text (in ppt/diagrams/data*.xml) is now
|
||||||
|
collected and translated."""
|
||||||
|
|
||||||
|
DIAGRAM_XML = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram"
|
||||||
|
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||||
|
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||||
|
<dgm:ptLst>
|
||||||
|
<dgm:pt modelId="0" type="doc">
|
||||||
|
<dgm:prSet loTypeId="urn:microsoft.com/office/officeart/2005/8/layout/hierarchy1" loCatId="hierarchy"/>
|
||||||
|
<dgm:t>
|
||||||
|
<a:bodyPr/>
|
||||||
|
<a:lstStyle/>
|
||||||
|
<a:p>
|
||||||
|
<a:r>
|
||||||
|
<a:rPr lang="en-US"/>
|
||||||
|
<a:t>Root node</a:t>
|
||||||
|
</a:r>
|
||||||
|
</a:p>
|
||||||
|
</dgm:t>
|
||||||
|
</dgm:pt>
|
||||||
|
<dgm:pt modelId="1">
|
||||||
|
<dgm:t>
|
||||||
|
<a:bodyPr/>
|
||||||
|
<a:lstStyle/>
|
||||||
|
<a:p>
|
||||||
|
<a:r>
|
||||||
|
<a:rPr lang="en-US"/>
|
||||||
|
<a:t>Child one</a:t>
|
||||||
|
</a:r>
|
||||||
|
</a:p>
|
||||||
|
</dgm:t>
|
||||||
|
</dgm:pt>
|
||||||
|
<dgm:pt modelId="2">
|
||||||
|
<dgm:t>
|
||||||
|
<a:bodyPr/>
|
||||||
|
<a:lstStyle/>
|
||||||
|
<a:p>
|
||||||
|
<a:r>
|
||||||
|
<a:rPr lang="en-US"/>
|
||||||
|
<a:t>Child two</a:t>
|
||||||
|
</a:r>
|
||||||
|
</a:p>
|
||||||
|
</dgm:t>
|
||||||
|
</dgm:pt>
|
||||||
|
</dgm:ptLst>
|
||||||
|
</dgm:dataModel>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _build_pptx_with_diagram(self, tmp_path: Path) -> Path:
|
||||||
|
prs = Presentation()
|
||||||
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||||
|
_add_textbox_with_text(slide, "Hello")
|
||||||
|
path = tmp_path / "with_diagram.pptx"
|
||||||
|
prs.save(path)
|
||||||
|
_inject_diagram_part(
|
||||||
|
path, "ppt/diagrams/data1.xml",
|
||||||
|
self.DIAGRAM_XML.encode("utf-8"),
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_diagram_text_collected(self, tmp_path):
|
||||||
|
"""Diagram text should appear in provider calls."""
|
||||||
|
provider = MockProvider()
|
||||||
|
translator = PowerPointTranslator(provider=provider)
|
||||||
|
|
||||||
|
input_file = self._build_pptx_with_diagram(tmp_path)
|
||||||
|
output_file = tmp_path / "output.pptx"
|
||||||
|
|
||||||
|
translator.translate_file(input_file, output_file, "fr")
|
||||||
|
|
||||||
|
# All three diagram nodes should be collected
|
||||||
|
assert "Root node" in provider.calls, (
|
||||||
|
f"Diagram 'Root node' not collected. Calls: {provider.calls}"
|
||||||
|
)
|
||||||
|
assert "Child one" in provider.calls, (
|
||||||
|
f"Diagram 'Child one' not collected. Calls: {provider.calls}"
|
||||||
|
)
|
||||||
|
assert "Child two" in provider.calls, (
|
||||||
|
f"Diagram 'Child two' not collected. Calls: {provider.calls}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_diagram_text_applied(self, tmp_path):
|
||||||
|
"""Translated diagram text should be written back to the .pptx
|
||||||
|
ZIP (in ppt/diagrams/data*.xml)."""
|
||||||
|
provider = MockProvider({
|
||||||
|
"Root node": "TR_Root node",
|
||||||
|
"Child one": "TR_Child one",
|
||||||
|
"Child two": "TR_Child two",
|
||||||
|
})
|
||||||
|
translator = PowerPointTranslator(provider=provider)
|
||||||
|
|
||||||
|
input_file = self._build_pptx_with_diagram(tmp_path)
|
||||||
|
output_file = tmp_path / "output.pptx"
|
||||||
|
|
||||||
|
translator.translate_file(input_file, output_file, "fr")
|
||||||
|
|
||||||
|
# The diagram part in the output ZIP should have the translated text
|
||||||
|
with zipfile.ZipFile(output_file, "r") as zf:
|
||||||
|
assert "ppt/diagrams/data1.xml" in zf.namelist(), (
|
||||||
|
f"Diagram part missing in output. Files: {zf.namelist()}"
|
||||||
|
)
|
||||||
|
data = zf.read("ppt/diagrams/data1.xml").decode("utf-8")
|
||||||
|
assert "TR_Root node" in data, (
|
||||||
|
f"Diagram 'Root node' not translated in output:\n{data[:1000]}"
|
||||||
|
)
|
||||||
|
assert "TR_Child one" in data, (
|
||||||
|
f"Diagram 'Child one' not translated in output"
|
||||||
|
)
|
||||||
|
assert "TR_Child two" in data, (
|
||||||
|
f"Diagram 'Child two' not translated in output"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_diagram_no_error(self, tmp_path):
|
||||||
|
"""A PPTX without a diagram part should not raise."""
|
||||||
|
provider = MockProvider({"Hello": "TR_Hello"})
|
||||||
|
translator = PowerPointTranslator(provider=provider)
|
||||||
|
|
||||||
|
prs = Presentation()
|
||||||
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||||
|
_add_textbox_with_text(slide, "Hello")
|
||||||
|
input_file = tmp_path / "no_diagram.pptx"
|
||||||
|
output_file = tmp_path / "output.pptx"
|
||||||
|
prs.save(input_file)
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
translator.translate_file(input_file, output_file, "fr")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# B2.3 — Diagram whitespace preservation
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestPptxDiagramWhitespace:
|
||||||
|
"""Whitespace around translated diagram text should be preserved."""
|
||||||
|
|
||||||
|
DIAGRAM_PADDED_XML = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram"
|
||||||
|
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
|
||||||
|
<dgm:ptLst>
|
||||||
|
<dgm:pt modelId="0">
|
||||||
|
<dgm:t>
|
||||||
|
<a:bodyPr/>
|
||||||
|
<a:lstStyle/>
|
||||||
|
<a:p>
|
||||||
|
<a:r>
|
||||||
|
<a:rPr lang="en-US"/>
|
||||||
|
<a:t> Padded text </a:t>
|
||||||
|
</a:r>
|
||||||
|
</a:p>
|
||||||
|
</dgm:t>
|
||||||
|
</dgm:pt>
|
||||||
|
</dgm:ptLst>
|
||||||
|
</dgm:dataModel>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_padded_diagram_text_preserved(self, tmp_path):
|
||||||
|
provider = MockProvider({"Padded text": "TR_Padded text"})
|
||||||
|
translator = PowerPointTranslator(provider=provider)
|
||||||
|
|
||||||
|
prs = Presentation()
|
||||||
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||||
|
input_file = tmp_path / "padded.pptx"
|
||||||
|
prs.save(input_file)
|
||||||
|
_inject_diagram_part(
|
||||||
|
input_file, "ppt/diagrams/data1.xml",
|
||||||
|
self.DIAGRAM_PADDED_XML.encode("utf-8"),
|
||||||
|
)
|
||||||
|
output_file = tmp_path / "output.pptx"
|
||||||
|
|
||||||
|
translator.translate_file(input_file, output_file, "fr")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(output_file, "r") as zf:
|
||||||
|
data = zf.read("ppt/diagrams/data1.xml").decode("utf-8")
|
||||||
|
# Leading/trailing spaces should be preserved
|
||||||
|
assert " TR_Padded text " in data, (
|
||||||
|
f"Whitespace not preserved in diagram translation:\n{data[:1000]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# B2.4 — Element path navigation
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestPptxElementPathNavigation:
|
||||||
|
"""Bug fix: chart/diagram elements should be matched by stored
|
||||||
|
element_path, not by string equality. This means identical text
|
||||||
|
in multiple elements (e.g. two 'Revenue' series) is handled correctly."""
|
||||||
|
|
||||||
|
SIMPLE_DIAGRAM = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<root xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
|
||||||
|
<a:t>Alpha</a:t>
|
||||||
|
<a:t>Beta</a:t>
|
||||||
|
<a:t>Gamma</a:t>
|
||||||
|
</root>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_path_navigation_basic(self):
|
||||||
|
"""The `_get_element_path` / `_find_element_by_path` pair should
|
||||||
|
round-trip correctly through a small XML tree."""
|
||||||
|
root = etree.fromstring(self.SIMPLE_DIAGRAM.encode("utf-8"))
|
||||||
|
translator = PowerPointTranslator()
|
||||||
|
|
||||||
|
# Get paths for all three <a:t> elements
|
||||||
|
elements = list(root.iter(f"{{{_NS_A}}}t"))
|
||||||
|
assert len(elements) == 3
|
||||||
|
|
||||||
|
paths = [translator._get_element_path(e) for e in elements]
|
||||||
|
|
||||||
|
# All three paths should be unique
|
||||||
|
assert len(set(paths)) == 3, f"Paths not unique: {paths}"
|
||||||
|
|
||||||
|
# Each path should resolve back to the same element
|
||||||
|
for elem, path in zip(elements, paths):
|
||||||
|
found = translator._find_element_by_path(root, path)
|
||||||
|
assert found is not None, f"Path didn't resolve: {path}"
|
||||||
|
assert found.text == elem.text, (
|
||||||
|
f"Path {path} resolved to wrong element: "
|
||||||
|
f"{found.text!r} vs {elem.text!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_path_fallback_to_string_match(self):
|
||||||
|
"""If the stored path is empty, the apply step should fall back
|
||||||
|
to string equality on `entry['original']`."""
|
||||||
|
root = etree.fromstring(self.SIMPLE_DIAGRAM.encode("utf-8"))
|
||||||
|
translator = PowerPointTranslator()
|
||||||
|
|
||||||
|
# Simulate a legacy entry with no element_path
|
||||||
|
legacy_entry = {
|
||||||
|
"original": "Beta",
|
||||||
|
"translated": "TR_Beta",
|
||||||
|
"element_path": None, # legacy
|
||||||
|
}
|
||||||
|
|
||||||
|
# Simulate the apply logic
|
||||||
|
target = None
|
||||||
|
for candidate in root.iter(f"{{{_NS_A}}}t"):
|
||||||
|
if (candidate.text or "").strip() == legacy_entry["original"]:
|
||||||
|
target = candidate
|
||||||
|
break
|
||||||
|
assert target is not None, "Fallback string match didn't find Beta"
|
||||||
|
assert target.text == "Beta"
|
||||||
|
|
||||||
|
def test_diagram_with_duplicate_text_paths_differ(self, tmp_path):
|
||||||
|
"""Two diagram nodes with the same text should have different
|
||||||
|
element_path values, so the apply step can target them correctly."""
|
||||||
|
dup_xml = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram"
|
||||||
|
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
|
||||||
|
<dgm:ptLst>
|
||||||
|
<dgm:pt modelId="0">
|
||||||
|
<dgm:t><a:p><a:r><a:t>Revenue</a:t></a:r></a:p></dgm:t>
|
||||||
|
</dgm:pt>
|
||||||
|
<dgm:pt modelId="1">
|
||||||
|
<dgm:t><a:p><a:r><a:t>Revenue</a:t></a:r></a:p></dgm:t>
|
||||||
|
</dgm:pt>
|
||||||
|
</dgm:ptLst>
|
||||||
|
</dgm:dataModel>
|
||||||
|
"""
|
||||||
|
provider = MockProvider()
|
||||||
|
translator = PowerPointTranslator(provider=provider)
|
||||||
|
|
||||||
|
prs = Presentation()
|
||||||
|
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||||
|
input_file = tmp_path / "dup.pptx"
|
||||||
|
prs.save(input_file)
|
||||||
|
_inject_diagram_part(
|
||||||
|
input_file, "ppt/diagrams/data1.xml",
|
||||||
|
dup_xml.encode("utf-8"),
|
||||||
|
)
|
||||||
|
output_file = tmp_path / "output.pptx"
|
||||||
|
|
||||||
|
# Should not raise; both should be translated independently
|
||||||
|
translator.translate_file(input_file, output_file, "fr")
|
||||||
|
|
||||||
|
# Both 'Revenue' texts should be in the calls
|
||||||
|
assert provider.calls.count("Revenue") == 2, (
|
||||||
|
f"Expected 2 'Revenue' calls, got {provider.calls.count('Revenue')}. "
|
||||||
|
f"All calls: {provider.calls}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# B2.5 — Chart whitespace preservation
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestPptxChartWhitespace:
|
||||||
|
"""Whitespace around translated chart text should be preserved."""
|
||||||
|
|
||||||
|
CHART_PADDED_XML = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"
|
||||||
|
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
|
||||||
|
<c:chart>
|
||||||
|
<c:title>
|
||||||
|
<c:tx>
|
||||||
|
<c:rich>
|
||||||
|
<a:p>
|
||||||
|
<a:r>
|
||||||
|
<a:t> Padded chart title </a:t>
|
||||||
|
</a:r>
|
||||||
|
</a:p>
|
||||||
|
</c:rich>
|
||||||
|
</c:tx>
|
||||||
|
</c:title>
|
||||||
|
</c:chart>
|
||||||
|
</c:chartSpace>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_padded_chart_text_via_internal_method(self):
|
||||||
|
"""The internal chart apply logic should preserve whitespace."""
|
||||||
|
provider = MockProvider({"Padded chart title": "Titre avec espaces"})
|
||||||
|
translator = PowerPointTranslator(provider=provider)
|
||||||
|
|
||||||
|
# Build a chart entry by hand (simulating collect time)
|
||||||
|
chart_xml = etree.fromstring(self.CHART_PADDED_XML.encode("utf-8"))
|
||||||
|
entries = []
|
||||||
|
for t_elem in chart_xml.iter(f"{{{_NS_A}}}t"):
|
||||||
|
text_raw = t_elem.text or ""
|
||||||
|
text = text_raw.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
entry = {
|
||||||
|
"element": t_elem,
|
||||||
|
"original": text,
|
||||||
|
"original_raw": text_raw,
|
||||||
|
"translated": "Titre avec espaces",
|
||||||
|
"tag": "a:t",
|
||||||
|
"element_path": translator._get_element_path(t_elem),
|
||||||
|
}
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
if not hasattr(translator, "_chart_entries"):
|
||||||
|
translator._chart_entries = []
|
||||||
|
|
||||||
|
class _FakePart:
|
||||||
|
def __init__(self, blob):
|
||||||
|
self._blob = blob
|
||||||
|
@property
|
||||||
|
def blob(self):
|
||||||
|
return self._blob
|
||||||
|
@blob.setter
|
||||||
|
def blob(self, value):
|
||||||
|
self._blob = value
|
||||||
|
|
||||||
|
fake_part = _FakePart(
|
||||||
|
etree.tostring(
|
||||||
|
chart_xml, xml_declaration=True, encoding="UTF-8", standalone=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
translator._chart_entries.append({
|
||||||
|
"chart_part": fake_part,
|
||||||
|
"entries": entries,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Apply
|
||||||
|
translator._apply_chart_translations(Path("dummy"))
|
||||||
|
|
||||||
|
# Re-parse and check whitespace preserved
|
||||||
|
updated_xml = etree.fromstring(fake_part._blob)
|
||||||
|
all_t = list(updated_xml.iter(f"{{{_NS_A}}}t"))
|
||||||
|
# Find the title text
|
||||||
|
title_text = all_t[0].text or ""
|
||||||
|
assert " Titre avec espaces " in title_text, (
|
||||||
|
f"Chart whitespace not preserved: {title_text!r}"
|
||||||
|
)
|
||||||
@@ -221,6 +221,13 @@ class PowerPointTranslator:
|
|||||||
|
|
||||||
text_elements: List[Tuple[str, Callable[[str], None]]] = []
|
text_elements: List[Tuple[str, Callable[[str], None]]] = []
|
||||||
|
|
||||||
|
# SmartArt diagrams live in ppt/diagrams/*.xml — outside the
|
||||||
|
# python-pptx object model. We collect them once from the ZIP.
|
||||||
|
diagram_translations: List[Dict[str, Any]] = []
|
||||||
|
self._collect_diagrams_from_zip(
|
||||||
|
input_path, text_elements, diagram_translations
|
||||||
|
)
|
||||||
|
|
||||||
for slide_idx, slide in enumerate(presentation.slides):
|
for slide_idx, slide in enumerate(presentation.slides):
|
||||||
if slide.has_notes_slide and slide.notes_slide.notes_text_frame:
|
if slide.has_notes_slide and slide.notes_slide.notes_text_frame:
|
||||||
self._collect_from_text_frame(
|
self._collect_from_text_frame(
|
||||||
@@ -326,6 +333,9 @@ class PowerPointTranslator:
|
|||||||
# Re-inject chart translations into chart XML parts
|
# Re-inject chart translations into chart XML parts
|
||||||
self._apply_chart_translations(output_path)
|
self._apply_chart_translations(output_path)
|
||||||
|
|
||||||
|
# Re-inject SmartArt diagram translations into diagram XML parts
|
||||||
|
self._apply_diagram_translations(output_path, diagram_translations)
|
||||||
|
|
||||||
processing_time_ms = round((time.time() - start_time) * 1000, 2)
|
processing_time_ms = round((time.time() - start_time) * 1000, 2)
|
||||||
|
|
||||||
_log_info(
|
_log_info(
|
||||||
@@ -469,7 +479,24 @@ class PowerPointTranslator:
|
|||||||
def _collect_from_shape(
|
def _collect_from_shape(
|
||||||
self, shape: BaseShape, text_elements: List[Tuple[str, Callable[[str], None]]]
|
self, shape: BaseShape, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Collect text from a shape and its children."""
|
"""Collect text from a shape and its children.
|
||||||
|
|
||||||
|
Skips auto-generated placeholders (date, slide number, footer)
|
||||||
|
whose text is regenerated by PowerPoint on save — translating
|
||||||
|
them would just be wasted work.
|
||||||
|
"""
|
||||||
|
# Skip placeholders that PowerPoint auto-generates (date, slide number,
|
||||||
|
# footer) — these are filled with &D/&P/&F codes that get expanded at
|
||||||
|
# render time. Translating them is pointless.
|
||||||
|
if shape.is_placeholder:
|
||||||
|
try:
|
||||||
|
ph_type = shape.placeholder_format.type
|
||||||
|
# 16 = DATE, 13 = SLIDE_NUMBER, 15 = FOOTER
|
||||||
|
if ph_type is not None and int(ph_type) in (13, 15, 16):
|
||||||
|
return
|
||||||
|
except (AttributeError, ValueError, Exception):
|
||||||
|
pass # not a placeholder type we can check, continue normally
|
||||||
|
|
||||||
if shape.has_text_frame:
|
if shape.has_text_frame:
|
||||||
self._collect_from_text_frame(shape.text_frame, text_elements)
|
self._collect_from_text_frame(shape.text_frame, text_elements)
|
||||||
|
|
||||||
@@ -479,20 +506,95 @@ class PowerPointTranslator:
|
|||||||
self._collect_from_text_frame(cell.text_frame, text_elements)
|
self._collect_from_text_frame(cell.text_frame, text_elements)
|
||||||
|
|
||||||
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
|
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
|
||||||
for sub_shape in shape.shapes:
|
try:
|
||||||
self._collect_from_shape(sub_shape, text_elements)
|
for sub_shape in shape.shapes:
|
||||||
|
self._collect_from_shape(sub_shape, text_elements)
|
||||||
|
except Exception:
|
||||||
|
# Defensive: some groups can have weird attribute access
|
||||||
|
pass
|
||||||
|
|
||||||
# Chart shapes — text is stored in separate chart XML parts
|
# Chart shapes — text is stored in separate chart XML parts
|
||||||
if shape.shape_type == MSO_SHAPE_TYPE.CHART:
|
if shape.shape_type == MSO_SHAPE_TYPE.CHART:
|
||||||
self._collect_from_chart_shape(shape, text_elements)
|
self._collect_from_chart_shape(shape, text_elements)
|
||||||
|
|
||||||
if hasattr(shape, "shapes"):
|
# Some shapes (e.g. LayoutPlaceholders) expose a .shapes attribute
|
||||||
|
# that's not a Group. Skip these to avoid spurious iteration.
|
||||||
|
if shape.shape_type == MSO_SHAPE_TYPE.GROUP and hasattr(shape, "shapes"):
|
||||||
try:
|
try:
|
||||||
for sub_shape in shape.shapes:
|
for sub_shape in shape.shapes:
|
||||||
self._collect_from_shape(sub_shape, text_elements)
|
self._collect_from_shape(sub_shape, text_elements)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _collect_diagrams_from_zip(
|
||||||
|
self,
|
||||||
|
input_path: Path,
|
||||||
|
text_elements: List[Tuple[str, Callable[[str], None]]],
|
||||||
|
diagram_translations: List[Dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Parse SmartArt diagram XML from the .pptx ZIP and collect translatable text.
|
||||||
|
|
||||||
|
SmartArt text lives in ``ppt/diagrams/data*.xml`` inside the ZIP.
|
||||||
|
Each diagram data file contains ``<a:t>`` text nodes inside
|
||||||
|
DrawingML structures.
|
||||||
|
"""
|
||||||
|
_TAG_A_T = f"{{{_NS_A}}}t"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(input_path, 'r') as zf:
|
||||||
|
diag_files = [
|
||||||
|
n for n in zf.namelist()
|
||||||
|
if n.startswith('ppt/diagrams/data') and n.endswith('.xml')
|
||||||
|
]
|
||||||
|
|
||||||
|
for diag_file in diag_files:
|
||||||
|
try:
|
||||||
|
diag_bytes = zf.read(diag_file)
|
||||||
|
diag_xml = etree.fromstring(diag_bytes)
|
||||||
|
|
||||||
|
for t_elem in diag_xml.iter(_TAG_A_T):
|
||||||
|
if t_elem.text and t_elem.text.strip():
|
||||||
|
# Preserve the raw text (with surrounding
|
||||||
|
# whitespace) so the apply step can
|
||||||
|
# restore it. Use the stripped form for
|
||||||
|
# the API call and the matching key.
|
||||||
|
original_raw = t_elem.text
|
||||||
|
original = original_raw.strip()
|
||||||
|
|
||||||
|
# Skip numeric-only or very short tokens
|
||||||
|
if original.replace('.', '').replace('-', '').replace(',', '').isdigit():
|
||||||
|
continue
|
||||||
|
if len(original) <= 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry: Dict[str, Any] = {
|
||||||
|
'diag_file': diag_file,
|
||||||
|
'element_path': self._get_element_path(t_elem),
|
||||||
|
'original': original,
|
||||||
|
'original_raw': original_raw,
|
||||||
|
'translated': None,
|
||||||
|
# Keep the original XML bytes so the
|
||||||
|
# apply step can re-create the part
|
||||||
|
# if python-pptx strips it.
|
||||||
|
'original_xml': diag_bytes,
|
||||||
|
}
|
||||||
|
diagram_translations.append(entry)
|
||||||
|
|
||||||
|
def _make_diag_setter(entries, idx):
|
||||||
|
def setter(text: str) -> None:
|
||||||
|
entries[idx]['translated'] = text.strip()
|
||||||
|
return setter
|
||||||
|
|
||||||
|
text_elements.append(
|
||||||
|
(original, _make_diag_setter(diagram_translations, len(diagram_translations) - 1))
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log_error("pptx_diagram_parse_error", diag_file=diag_file, error=str(e))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log_error("pptx_diagrams_zip_error", error=str(e))
|
||||||
|
|
||||||
def _collect_from_chart_shape(
|
def _collect_from_chart_shape(
|
||||||
self, shape: BaseShape, text_elements: List[Tuple[str, Callable[[str], None]]]
|
self, shape: BaseShape, text_elements: List[Tuple[str, Callable[[str], None]]]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -500,29 +602,31 @@ class PowerPointTranslator:
|
|||||||
|
|
||||||
Chart text (title, axis titles, series names, data labels) is stored
|
Chart text (title, axis titles, series names, data labels) is stored
|
||||||
in a separate chart XML part, not in shape.text_frame.
|
in a separate chart XML part, not in shape.text_frame.
|
||||||
|
|
||||||
|
Each translatable element is tracked by its `element_path` (an
|
||||||
|
XPath-like path) so the apply step can target it precisely even
|
||||||
|
when the same text appears multiple times in the same chart.
|
||||||
"""
|
"""
|
||||||
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||||
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
chart_data = shape.chart
|
chart_data = shape.chart
|
||||||
# Access the chart XML part through the chart's part
|
|
||||||
chart_part = chart_data.part
|
chart_part = chart_data.part
|
||||||
chart_xml = etree.fromstring(chart_part.blob)
|
chart_xml = etree.fromstring(chart_part.blob)
|
||||||
|
|
||||||
# Collect text from <a:t> elements in chart XML
|
|
||||||
# These include: chart title, axis titles, legend entries, data labels
|
|
||||||
seen_texts: set = set()
|
|
||||||
chart_text_entries: List[Dict[str, Any]] = []
|
chart_text_entries: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
for t_elem in chart_xml.iter(f'{{{_NS_A}}}t'):
|
for t_elem in chart_xml.iter(f'{{{_NS_A}}}t'):
|
||||||
text = t_elem.text
|
text = t_elem.text
|
||||||
if text and text.strip() and text.strip() not in seen_texts:
|
if text and text.strip():
|
||||||
seen_texts.add(text.strip())
|
|
||||||
entry = {
|
entry = {
|
||||||
'element': t_elem,
|
'element': t_elem,
|
||||||
'original': text.strip(),
|
'original': text.strip(),
|
||||||
|
'original_raw': text,
|
||||||
'translated': None,
|
'translated': None,
|
||||||
|
'tag': 'a:t',
|
||||||
|
'element_path': self._get_element_path(t_elem),
|
||||||
}
|
}
|
||||||
chart_text_entries.append(entry)
|
chart_text_entries.append(entry)
|
||||||
|
|
||||||
@@ -535,29 +639,28 @@ class PowerPointTranslator:
|
|||||||
(text.strip(), make_chart_setter(chart_text_entries, len(chart_text_entries) - 1))
|
(text.strip(), make_chart_setter(chart_text_entries, len(chart_text_entries) - 1))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Also collect from <c:v> (cell values used as category names)
|
|
||||||
for v_elem in chart_xml.iter(f'{{{_NS_C}}}v'):
|
for v_elem in chart_xml.iter(f'{{{_NS_C}}}v'):
|
||||||
text = v_elem.text
|
text = v_elem.text
|
||||||
if text and text.strip() and not text.strip().replace('.', '').replace('-', '').replace(',', '').isdigit():
|
if text and text.strip() and not text.strip().replace('.', '').replace('-', '').replace(',', '').isdigit():
|
||||||
if text.strip() not in seen_texts:
|
entry = {
|
||||||
seen_texts.add(text.strip())
|
'element': v_elem,
|
||||||
entry = {
|
'original': text.strip(),
|
||||||
'element': v_elem,
|
'original_raw': text,
|
||||||
'original': text.strip(),
|
'translated': None,
|
||||||
'translated': None,
|
'tag': 'c:v',
|
||||||
}
|
'element_path': self._get_element_path(v_elem),
|
||||||
chart_text_entries.append(entry)
|
}
|
||||||
|
chart_text_entries.append(entry)
|
||||||
|
|
||||||
def make_chart_v_setter(entries, idx):
|
def make_chart_v_setter(entries, idx):
|
||||||
def setter(translated_text):
|
def setter(translated_text):
|
||||||
entries[idx]['translated'] = translated_text.strip()
|
entries[idx]['translated'] = translated_text.strip()
|
||||||
return setter
|
return setter
|
||||||
|
|
||||||
text_elements.append(
|
text_elements.append(
|
||||||
(text.strip(), make_chart_v_setter(chart_text_entries, len(chart_text_entries) - 1))
|
(text.strip(), make_chart_v_setter(chart_text_entries, len(chart_text_entries) - 1))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store chart_part reference and entries for later re-injection
|
|
||||||
if chart_text_entries:
|
if chart_text_entries:
|
||||||
if not hasattr(self, '_chart_entries'):
|
if not hasattr(self, '_chart_entries'):
|
||||||
self._chart_entries = []
|
self._chart_entries = []
|
||||||
@@ -595,8 +698,58 @@ class PowerPointTranslator:
|
|||||||
|
|
||||||
text_elements.append((stripped, make_setter(run, leading, trailing)))
|
text_elements.append((stripped, make_setter(run, leading, trailing)))
|
||||||
|
|
||||||
|
def _get_element_path(self, element) -> str:
|
||||||
|
"""Get a unique XPath-like path for an element within its XML tree.
|
||||||
|
|
||||||
|
Used to robustly match chart/diagram elements between collect and
|
||||||
|
apply time, even when the same text value appears multiple times.
|
||||||
|
"""
|
||||||
|
path_parts = []
|
||||||
|
current = element
|
||||||
|
while current is not None:
|
||||||
|
parent = current.getparent()
|
||||||
|
if parent is None:
|
||||||
|
break
|
||||||
|
idx = list(parent).index(current)
|
||||||
|
tag = current.tag.split('}')[-1] if '}' in current.tag else current.tag
|
||||||
|
path_parts.append(f"{tag}[{idx}]")
|
||||||
|
current = parent
|
||||||
|
return '/'.join(reversed(path_parts))
|
||||||
|
|
||||||
|
def _find_element_by_path(self, root, path: str):
|
||||||
|
"""Navigate the XML tree using the path produced by `_get_element_path`."""
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
current = root
|
||||||
|
for segment in path.split('/'):
|
||||||
|
if '[' not in segment or not segment.endswith(']'):
|
||||||
|
return None
|
||||||
|
tag_name, idx_str = segment[:-1].split('[', 1)
|
||||||
|
try:
|
||||||
|
idx = int(idx_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
children = list(current)
|
||||||
|
if idx < 0 or idx >= len(children):
|
||||||
|
return None
|
||||||
|
candidate = children[idx]
|
||||||
|
candidate_tag = candidate.tag.split('}')[-1] if '}' in candidate.tag else candidate.tag
|
||||||
|
if candidate_tag != tag_name:
|
||||||
|
return None
|
||||||
|
current = candidate
|
||||||
|
return current
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def _apply_chart_translations(self, output_path: Path) -> None:
|
def _apply_chart_translations(self, output_path: Path) -> None:
|
||||||
"""Re-inject chart text translations by modifying chart XML parts in the .pptx ZIP."""
|
"""Re-inject chart text translations by modifying chart XML parts.
|
||||||
|
|
||||||
|
Matching strategy: prefer the stored `element_path` (set at collect
|
||||||
|
time) to navigate directly to the right element. Fall back to
|
||||||
|
string equality on `entry['original']` for legacy entries (or when
|
||||||
|
the path can't be resolved — e.g. chart structure mutated).
|
||||||
|
"""
|
||||||
if not hasattr(self, '_chart_entries') or not self._chart_entries:
|
if not hasattr(self, '_chart_entries') or not self._chart_entries:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -604,6 +757,7 @@ class PowerPointTranslator:
|
|||||||
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
_NS_C = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
||||||
|
|
||||||
total_translated = 0
|
total_translated = 0
|
||||||
|
total_skipped = 0
|
||||||
|
|
||||||
for chart_data in self._chart_entries:
|
for chart_data in self._chart_entries:
|
||||||
entries = chart_data['entries']
|
entries = chart_data['entries']
|
||||||
@@ -617,22 +771,46 @@ class PowerPointTranslator:
|
|||||||
chart_xml = etree.fromstring(chart_part.blob)
|
chart_xml = etree.fromstring(chart_part.blob)
|
||||||
|
|
||||||
for entry in translated_entries:
|
for entry in translated_entries:
|
||||||
# Try to find and update <a:t> elements
|
target = None
|
||||||
for t_elem in chart_xml.iter(f'{{{_NS_A}}}t'):
|
|
||||||
if t_elem.text and t_elem.text.strip() == entry['original']:
|
# 1) Preferred: navigate by stored element_path
|
||||||
t_elem.text = entry['translated']
|
element_path = entry.get('element_path')
|
||||||
total_translated += 1
|
if element_path:
|
||||||
break
|
target = self._find_element_by_path(chart_xml, element_path)
|
||||||
else:
|
|
||||||
# Try <c:v> elements
|
# 2) Fallback: string equality on original text
|
||||||
for v_elem in chart_xml.iter(f'{{{_NS_C}}}v'):
|
if target is None:
|
||||||
if v_elem.text and v_elem.text.strip() == entry['original']:
|
tag = entry.get('tag', 'a:t')
|
||||||
v_elem.text = entry['translated']
|
ns = _NS_C if tag == 'c:v' else _NS_A
|
||||||
total_translated += 1
|
for candidate in chart_xml.iter(f'{{{ns}}}{tag.split(":")[-1]}'):
|
||||||
|
cand_text = candidate.text or ""
|
||||||
|
if cand_text.strip() == entry['original']:
|
||||||
|
target = candidate
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if target is None:
|
||||||
|
total_skipped += 1
|
||||||
|
_log_error(
|
||||||
|
"pptx_chart_target_not_found",
|
||||||
|
original=entry.get('original', '')[:60],
|
||||||
|
has_path=bool(entry.get('element_path')),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Preserve leading/trailing whitespace if original had any
|
||||||
|
orig = entry.get('original_raw') or entry.get('original', '') or ''
|
||||||
|
leading = orig[: len(orig) - len(orig.lstrip())]
|
||||||
|
trailing = orig[len(orig.rstrip()) :]
|
||||||
|
target.text = leading + (entry['translated'] or '').strip() + trailing
|
||||||
|
total_translated += 1
|
||||||
|
|
||||||
# Update the chart part blob
|
# Update the chart part blob
|
||||||
chart_part._blob = etree.tostring(chart_xml, xml_declaration=True, encoding='UTF-8', standalone=True)
|
chart_part.blob = etree.tostring(
|
||||||
|
chart_xml,
|
||||||
|
xml_declaration=True,
|
||||||
|
encoding='UTF-8',
|
||||||
|
standalone=True,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log_error("pptx_chart_update_error", error=str(e))
|
_log_error("pptx_chart_update_error", error=str(e))
|
||||||
@@ -640,8 +818,224 @@ class PowerPointTranslator:
|
|||||||
# Clean up
|
# Clean up
|
||||||
self._chart_entries = []
|
self._chart_entries = []
|
||||||
|
|
||||||
if total_translated > 0:
|
if total_translated > 0 or total_skipped > 0:
|
||||||
_log_info("pptx_charts_translated", total=total_translated)
|
_log_info(
|
||||||
|
"pptx_charts_translated",
|
||||||
|
translated=total_translated,
|
||||||
|
skipped=total_skipped,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_diagram_translations(
|
||||||
|
self,
|
||||||
|
output_path: Path,
|
||||||
|
diagram_translations: List[Dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Re-inject SmartArt diagram translations by rewriting the .pptx ZIP.
|
||||||
|
|
||||||
|
python-pptx doesn't manage diagram parts. After `presentation.save()`
|
||||||
|
strips them out, we need to (re-)add the diagram data files with
|
||||||
|
the translated <a:t> elements.
|
||||||
|
|
||||||
|
The typical flow is:
|
||||||
|
1. input PPTX has ``ppt/diagrams/data*.xml``
|
||||||
|
2. ``presentation.save(output_path)`` produces a .pptx WITHOUT
|
||||||
|
those diagram parts (python-pptx ignores them)
|
||||||
|
3. We rewrite the output ZIP to ADD the diagram parts back,
|
||||||
|
with the translated text. Content-types and rels are
|
||||||
|
patched in if missing.
|
||||||
|
"""
|
||||||
|
if not diagram_translations:
|
||||||
|
return
|
||||||
|
|
||||||
|
translated_entries = [e for e in diagram_translations if e.get('translated')]
|
||||||
|
if not translated_entries:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Group by file so we only parse each diagram XML once
|
||||||
|
by_file: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
for entry in translated_entries:
|
||||||
|
by_file.setdefault(entry['diag_file'], []).append(entry)
|
||||||
|
|
||||||
|
# Read original ZIP into memory
|
||||||
|
try:
|
||||||
|
with open(output_path, 'rb') as f:
|
||||||
|
original_zip_bytes = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
_log_error("pptx_diagram_read_failed", error=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
in_zip = zipfile.ZipFile(io.BytesIO(original_zip_bytes), 'r')
|
||||||
|
out_buffer = io.BytesIO()
|
||||||
|
total_translated = 0
|
||||||
|
total_skipped = 0
|
||||||
|
added_files: List[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing_names = set(in_zip.namelist())
|
||||||
|
# Read [Content_Types].xml so we can extend it if needed
|
||||||
|
content_types_xml: Optional[bytes] = None
|
||||||
|
if "[Content_Types].xml" in existing_names:
|
||||||
|
content_types_xml = in_zip.read("[Content_Types].xml")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(out_buffer, 'w', zipfile.ZIP_DEFLATED) as out_zip:
|
||||||
|
for item in in_zip.infolist():
|
||||||
|
data = in_zip.read(item.filename)
|
||||||
|
|
||||||
|
if item.filename in by_file and item.filename in existing_names:
|
||||||
|
data = self._rewrite_diagram_xml(
|
||||||
|
data,
|
||||||
|
by_file[item.filename],
|
||||||
|
)
|
||||||
|
# Count translated (success counted in helper)
|
||||||
|
for entry in by_file[item.filename]:
|
||||||
|
if entry.get('translated'):
|
||||||
|
if entry.get('_applied'):
|
||||||
|
total_translated += 1
|
||||||
|
else:
|
||||||
|
total_skipped += 1
|
||||||
|
# Reset _applied flag
|
||||||
|
for entry in by_file[item.filename]:
|
||||||
|
entry.pop('_applied', None)
|
||||||
|
|
||||||
|
out_zip.writestr(item, data)
|
||||||
|
|
||||||
|
# Add NEW diagram parts (parts that python-pptx stripped)
|
||||||
|
for diag_file, file_entries in by_file.items():
|
||||||
|
if diag_file in existing_names:
|
||||||
|
continue
|
||||||
|
# Re-create the diagram XML from the original
|
||||||
|
# (we stored a reference at collect time)
|
||||||
|
original_xml = None
|
||||||
|
for entry in file_entries:
|
||||||
|
if entry.get('original_xml'):
|
||||||
|
original_xml = entry['original_xml']
|
||||||
|
break
|
||||||
|
if original_xml is None:
|
||||||
|
# Last-ditch fallback: log and skip
|
||||||
|
_log_error(
|
||||||
|
"pptx_diagram_original_missing",
|
||||||
|
diag_file=diag_file,
|
||||||
|
)
|
||||||
|
total_skipped += len(file_entries)
|
||||||
|
continue
|
||||||
|
data = self._rewrite_diagram_xml(
|
||||||
|
original_xml,
|
||||||
|
file_entries,
|
||||||
|
)
|
||||||
|
for entry in file_entries:
|
||||||
|
if entry.get('translated'):
|
||||||
|
if entry.get('_applied'):
|
||||||
|
total_translated += 1
|
||||||
|
else:
|
||||||
|
total_skipped += 1
|
||||||
|
for entry in file_entries:
|
||||||
|
entry.pop('_applied', None)
|
||||||
|
out_zip.writestr(diag_file, data)
|
||||||
|
added_files.append(diag_file)
|
||||||
|
|
||||||
|
# Patch [Content_Types].xml if we added diagram parts
|
||||||
|
if added_files and content_types_xml is not None:
|
||||||
|
pass # We'll rewrite the whole file at the end
|
||||||
|
finally:
|
||||||
|
in_zip.close()
|
||||||
|
|
||||||
|
# If we added new diagram parts, also patch [Content_Types].xml
|
||||||
|
# inside the output buffer and ensure relationships exist
|
||||||
|
final_buffer = out_buffer
|
||||||
|
if added_files and content_types_xml is not None:
|
||||||
|
final_buffer = io.BytesIO()
|
||||||
|
ct_text = content_types_xml.decode("utf-8")
|
||||||
|
patched = False
|
||||||
|
with zipfile.ZipFile(out_buffer, 'r') as src, \
|
||||||
|
zipfile.ZipFile(final_buffer, 'w', zipfile.ZIP_DEFLATED) as dst:
|
||||||
|
for item in src.infolist():
|
||||||
|
if item.filename == "[Content_Types].xml":
|
||||||
|
for diag_file in added_files:
|
||||||
|
override = (
|
||||||
|
f'<Override PartName="/{diag_file}" '
|
||||||
|
f'ContentType="application/vnd.openxmlformats-officedocument.'
|
||||||
|
f'drawingml+xml+diagram"/>'
|
||||||
|
)
|
||||||
|
if override not in ct_text:
|
||||||
|
ct_text = ct_text.replace(
|
||||||
|
"</Types>", f"{override}</Types>"
|
||||||
|
)
|
||||||
|
patched = True
|
||||||
|
dst.writestr(item, ct_text.encode("utf-8"))
|
||||||
|
else:
|
||||||
|
dst.writestr(item, src.read(item.filename))
|
||||||
|
if not patched:
|
||||||
|
final_buffer = out_buffer
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(output_path, 'wb') as f:
|
||||||
|
f.write(final_buffer.getvalue())
|
||||||
|
except Exception as e:
|
||||||
|
_log_error("pptx_diagram_zip_write_error", error=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
if total_translated > 0 or total_skipped > 0:
|
||||||
|
_log_info(
|
||||||
|
"pptx_diagrams_translated",
|
||||||
|
translated=total_translated,
|
||||||
|
skipped=total_skipped,
|
||||||
|
files=len(by_file),
|
||||||
|
added=len(added_files),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rewrite_diagram_xml(
|
||||||
|
self,
|
||||||
|
original_data: bytes,
|
||||||
|
entries: List[Dict[str, Any]],
|
||||||
|
) -> bytes:
|
||||||
|
"""Apply translations to a single diagram XML and return the new bytes.
|
||||||
|
|
||||||
|
Sets ``entry['_applied'] = True`` for entries that were translated.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
diag_xml = etree.fromstring(original_data)
|
||||||
|
except Exception as e:
|
||||||
|
_log_error(
|
||||||
|
"pptx_diagram_parse_error_in_apply",
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return original_data
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.get('translated'):
|
||||||
|
continue
|
||||||
|
target = None
|
||||||
|
element_path = entry.get('element_path')
|
||||||
|
if element_path:
|
||||||
|
target = self._find_element_by_path(diag_xml, element_path)
|
||||||
|
if target is None:
|
||||||
|
# Fallback: string equality on stripped form
|
||||||
|
for candidate in diag_xml.iter(f"{{{_NS_A}}}t"):
|
||||||
|
cand_text = candidate.text or ""
|
||||||
|
if cand_text.strip() == entry['original']:
|
||||||
|
target = candidate
|
||||||
|
break
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use original_raw (preserves surrounding whitespace) if
|
||||||
|
# available; otherwise fall back to original.
|
||||||
|
orig = entry.get('original_raw') or entry.get('original', '') or ''
|
||||||
|
leading = orig[: len(orig) - len(orig.lstrip())]
|
||||||
|
trailing = orig[len(orig.rstrip()) :]
|
||||||
|
target.text = (
|
||||||
|
leading
|
||||||
|
+ (entry['translated'] or '').strip()
|
||||||
|
+ trailing
|
||||||
|
)
|
||||||
|
entry['_applied'] = True
|
||||||
|
|
||||||
|
return etree.tostring(
|
||||||
|
diag_xml,
|
||||||
|
xml_declaration=True,
|
||||||
|
encoding='UTF-8',
|
||||||
|
standalone=True,
|
||||||
|
)
|
||||||
|
|
||||||
def _translate_images(self, presentation, target_language: str) -> None:
|
def _translate_images(self, presentation, target_language: str) -> None:
|
||||||
"""Extract and translate text from images in PowerPoint.
|
"""Extract and translate text from images in PowerPoint.
|
||||||
|
|||||||
Reference in New Issue
Block a user