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
+1 -1
View File
@@ -2,7 +2,7 @@
name: ppt-template-workflow
description: Create and revise template-based editable PPTX decks, design page mockups and materials, reconstruct visual pages into native objects, and maintain portable source projects. Select one external template and theme at a time; includes its own native compiler and workflows without requiring another installed Skill.
metadata:
version: "0.9.1"
version: "0.9.2"
---
# PPT Template Workflow
+6
View File
@@ -1,5 +1,11 @@
# 版本变化
## 0.9.2
本地源码修订。对外部模板声明的 `slide-number` 占位符,按真实导出顺序初始化缓存,并验证每页均为原生 `slidenum` 字段、不是静态数字;复制版式的固定字段载体以兼容旧完整页面源,补齐字段唯一ID。可移动工程准备器同步携带该包装模块;上游280份编译器源保持。
目录行为与交接说明沿用0.9.1,不增加入口接口或改变展开逻辑。模板素材、数值与布局仍由外部模板维护,未修改结构Skill。发布与受管安装另行进行。
## 0.9.1
本地源码配套版,在制作交接中明确消费外部模板的章/小节目录接口:唯一目录全部展开或全部收起,逐节目录覆盖章与小节入口、仅展开当前章,以稳定父子ID高亮目标。沿用模板固定几何及容量错误,不隐式缩字、丢小节或分页;重合入口在报告真实页序中合并。具体组织素材、字号与渲染器保留在独立模板。
+6
View File
@@ -40,3 +40,9 @@ Keep proposed styles and mockups outside the final template workspace until the
The bundled runtime also carries the upstream preview compiler at `runtime/ppt-master/scripts/template_preview_pptx.py` for author review workspaces. It is source tooling, not a second Skill. Read its command help for a new workspace. Its platform font mapping is general-purpose: a template review wrapper should import the compiler and use `scripts/finalize_theme.py`'s `preserve_declared_font_families(style)` context around the serial compile, then `finalize(...)`. This preserves the declared family before information can be lost to platform fallback. Do not globally replace a fallback font in finished XML: the author may have intentionally used that same font.
For native tables/charts and equations, read [specialized objects](native-objects.md) and its examples. For placeholder metadata, inspect the corresponding bundled compiler module and local CLI help. Do not claim specialized objects from a screenshot or from a successful ZIP export alone. Finish with [render and application checks](powerpoint-checks.md).
## Actual slide numbers
A template can declare one `slide-number` text-carrier placeholder in each applicable Layout. Its fixed geometry, typography and palette belong to that template. The native wrapper copies the Layout carrier into older page sources when needed, seeds its initial cache from the actual export order, and requires a Slide-local `a:fld type="slidenum"` rather than a literal text number. It also preserves the native `sldNum` placeholder binding and gives page-number fields distinct IDs. Templates without this placeholder retain their existing behavior.
Source previews may show a placeholder such as ‹#› because they have no deck position. Never replace it with a chapter number or a hand-maintained page counter. The exported field cache is an initial display value; PowerPoint controls its later value after insertion, deletion or rearrangement. Verify those operations in an independent PowerPoint copy before claiming application update behavior. The navigation header's upper-line text keeps its established report meaning and remains separate.
+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)}