Add native dynamic slide numbers (0.9.2)

This commit is contained in:
2026-10-23 00:10:03 +08:00
parent f7a87b6c97
commit e858fa130d
6 changed files with 126 additions and 6 deletions
+8 -4
View File
@@ -134,15 +134,17 @@ def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="native-export-", dir=output.parent) as temporary:
temporary = Path(temporary)
definitions = []
definitions, definition_roots = [], {}
for layout_id, filename in roster.items():
source = boundary_file(workspace / "templates", filename, f"layout {layout_id}")
target = temporary / f"layout_{layout_id}.svg"
root = prepare_svg(source, target, template_root)
ref.require(root.get("data-pptx-layout") == layout_id, f"Roster/source layout identity mismatch: {layout_id}")
definitions.append(target)
definition_roots[layout_id] = root
from math_component import prepare_math, apply_math
public, expected_names, compiled_notes, indexed_notes, math_pages = [], [], {}, {}, []
from slide_number import prepare_slide_number, finalize_slide_numbers
public, expected_names, compiled_notes, indexed_notes, math_pages, number_pages = [], [], {}, {}, [], []
for index, source in enumerate(sources):
target = temporary / f"page_{index:04d}.svg"
allowed = source_root or (template_root if source.is_relative_to(template_root) else source.parent)
@@ -150,8 +152,9 @@ def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path
layout_id = root.get("data-pptx-layout")
ref.require(layout_id in layouts, f"Source references an undeclared layout: {layout_id}")
ref.require(root.get("viewBox") == style["canvas"]["viewbox"], "Source viewBox differs from declared template canvas")
number_pages.append(prepare_slide_number(root, definition_roots[layout_id], index + 1))
equations = prepare_math(root, style["canvas"])
if equations:
if equations or number_pages[-1]:
ET.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
math_pages.append(equations)
public.append(target)
@@ -172,6 +175,7 @@ def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path
ref.require(success, "Bundled structured compiler failed")
finalize(generated, theme, style)
math_facts = apply_math(generated, math_pages, style["canvas"])
number_facts = finalize_slide_numbers(generated, number_pages)
facts = verify(generated, layouts, expected_names, indexed_notes)
# Exclusive publication also protects against a writer racing the initial
# existence check. A failed compile never disturbs the previous output.
@@ -185,7 +189,7 @@ def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path
if created:
output.unlink(missing_ok=True)
raise
return {**facts, **math_facts, "template_id": manifest["template_id"], "template_version": manifest["version"],
return {**facts, **math_facts, **number_facts, "template_id": manifest["template_id"], "template_version": manifest["version"],
"theme": theme_id, "output": str(output)}
+1 -1
View File
@@ -51,7 +51,7 @@ def prepare(root: Path):
staging.mkdir()
shutil.copytree(skill / "runtime", staging / "runtime", ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"))
(staging / "scripts").mkdir()
for name in ("native_export.py", "finalize_theme.py", "template_ref.py", "math_component.py",
for name in ("native_export.py", "finalize_theme.py", "template_ref.py", "math_component.py", "slide_number.py",
"component_typography.py", "paragraph_layout.py", "text_block.py", "layout_spacing.py"):
shutil.copyfile(skill / "scripts" / name, staging / "scripts" / name)
# The snapshot is an explicit build input; no installed Skill is needed
+104
View File
@@ -0,0 +1,104 @@
"""Bind template-declared slide-number placeholders to native Office fields."""
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
import posixpath
import uuid
import zipfile
from lxml import etree as OXML
NS = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships"}
def prepare_slide_number(root, definition, number):
"""Use the Layout's fixed carrier, including for older authored page sources.
The runtime converts a marked number only when its cache equals its actual
initial slide position. The cache seeds the field; it is never its identity.
"""
if type(number) is not int or number < 1:
raise ValueError("Initial slide number must be a positive integer")
find = lambda tree: [node for node in tree.iter()
if node.get("data-pptx-placeholder") == "slide-number"]
declared, current = find(definition), find(root)
if len(declared) > 1 or len(current) > 1:
raise ValueError("Each Layout/page supports one slide-number placeholder")
if not declared:
if current:
raise ValueError("Page declares a slide number absent from its Layout")
return 0
replacement = deepcopy(declared[0])
carriers = [node for node in replacement.iter()
if node.tag.rsplit("}", 1)[-1] == "text"
and node.get("data-pptx-carrier") == "true"]
if len(carriers) != 1:
raise ValueError("Slide-number placeholder requires one explicit text carrier")
carrier = carriers[0]
for child in list(carrier):
carrier.remove(child)
carrier.text = str(number)
if current:
parent = next((node for node in root.iter() if current[0] in list(node)), None)
if parent is None:
raise ValueError("Slide-number placeholder must be a child of the page root")
index = list(parent).index(current[0])
parent.remove(current[0])
parent.insert(index, replacement)
else:
root.append(replacement)
return 1
def finalize_slide_numbers(path: Path, expected: list[int]):
"""Check Slide-local fields and give every page-number field a unique ID."""
with zipfile.ZipFile(path) as archive:
parts = {item.filename: archive.read(item) for item in archive.infolist()}
trees = {name: OXML.fromstring(value) for name, value in parts.items()
if name.startswith("ppt/") and name.endswith(".xml")}
presentation = trees["ppt/presentation.xml"]
if int(presentation.get("firstSlideNum", "1")) != 1:
raise ValueError("Native export expected the declared initial numbering origin 1")
rels = OXML.fromstring(parts["ppt/_rels/presentation.xml.rels"])
targets = {node.get("Id"): node.get("Target") for node in rels}
slides = [posixpath.normpath(posixpath.join("ppt", targets[node.get("{" + NS["r"] + "}id")])).lstrip("/")
for node in presentation.findall("p:sldIdLst/p:sldId", NS)]
if len(slides) != len(expected):
raise ValueError("Slide-number validation requires the actual presentation order")
fields = 0
for index, (name, count) in enumerate(zip(slides, expected), 1):
root = trees[name]
shapes = [shape for shape in root.findall(".//p:sp", NS)
if shape.find("p:nvSpPr/p:nvPr/p:ph[@type='sldNum']", NS) is not None]
if len(shapes) != count:
raise ValueError(f"Slide {index}: slide-number placeholder count differs from its Layout")
for shape in shapes:
actual = shape.findall("p:txBody/a:p/a:fld", NS)
if len(actual) != 1 or actual[0].get("type") != "slidenum":
raise ValueError(f"Slide {index}: page number must be a native slidenum field")
if [node.text for node in shape.findall(".//a:t", NS)] != [str(index)]:
raise ValueError(f"Slide {index}: field cache differs from its actual initial position")
fields += 1
# The frozen runtime reuses a GUID for slide-local numbers. Office requires
# distinct field identities; fix only these fields, leaving other fields alone.
changed = {}
for name, root in trees.items():
numbers = root.findall(".//a:fld[@type='slidenum']", NS)
if numbers:
for field in numbers:
field.set("id", "{" + str(uuid.uuid4()).upper() + "}")
changed[name] = OXML.tostring(root, encoding="utf-8", xml_declaration=True)
if changed:
temporary = path.with_suffix(".numbering.pptx")
try:
with zipfile.ZipFile(temporary, "x", zipfile.ZIP_DEFLATED) as archive:
for name, value in parts.items():
archive.writestr(name, changed.get(name, value))
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
return {"slide_number_fields": fields, "slide_numbers_dynamic": bool(fields)}