"""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)}