Add native dynamic slide numbers (0.9.2)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 版本变化
|
||||
|
||||
## 0.9.2
|
||||
|
||||
本地源码修订。对外部模板声明的 `slide-number` 占位符,按真实导出顺序初始化缓存,并验证每页均为原生 `slidenum` 字段、不是静态数字;复制版式的固定字段载体以兼容旧完整页面源,补齐字段唯一ID。可移动工程准备器同步携带该包装模块;上游280份编译器源保持。
|
||||
|
||||
目录行为与交接说明沿用0.9.1,不增加入口接口或改变展开逻辑。模板素材、数值与布局仍由外部模板维护,未修改结构Skill。发布与受管安装另行进行。
|
||||
|
||||
## 0.9.1
|
||||
|
||||
本地源码配套版,在制作交接中明确消费外部模板的章/小节目录接口:唯一目录全部展开或全部收起,逐节目录覆盖章与小节入口、仅展开当前章,以稳定父子ID高亮目标。沿用模板固定几何及容量错误,不隐式缩字、丢小节或分页;重合入口在报告真实页序中合并。具体组织素材、字号与渲染器保留在独立模板。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
Reference in New Issue
Block a user