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}"
|
||||
)
|
||||
Reference in New Issue
Block a user