"""Apply only the external template's declared theme and typography to PPTX.""" from __future__ import annotations from contextlib import contextmanager from pathlib import Path import re import tempfile import zipfile from lxml import etree as ET COLORS = ("dk1", "lt1", "dk2", "lt2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "hlink", "folHlink") NS = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main", "p": "http://schemas.openxmlformats.org/presentationml/2006/main"} @contextmanager def preserve_declared_font_families(style: dict): """Keep declared families during one serial compiler call, then restore it. The upstream platform map can erase the source font identity before OOXML exists. An in-memory identity override avoids that lossy step for declared template families only. The bundled source files are never modified. This context is serial-process scoped; do not run other compiler calls in threads while it is active. Unlisted source fonts retain upstream behavior. """ from svg_to_pptx.drawingml.utils import FONT_FALLBACK_WIN families, *_ = typography(style) names = {entry["family"] for entry in families.values()} names.update(entry["cjk_fallback"] for entry in families.values() if entry.get("cjk_fallback")) absent = object() previous = {name: FONT_FALLBACK_WIN.get(name, absent) for name in names} try: for name in names: FONT_FALLBACK_WIN[name] = name yield finally: for name, value in previous.items(): if value is absent: FONT_FALLBACK_WIN.pop(name, None) else: FONT_FALLBACK_WIN[name] = value def typography(style): """Require author-declared families/sizes rather than supply default style.""" try: families = style["typography"]["families"] roles = style["typography"]["roles"] title_role, body_role = roles["page_title"], roles["body"] title = families[title_role["family_role"]]["family"] body = families[body_role["family_role"]]["family"] sizes = [float(title_role["size_pt"]), float(body_role["size_pt"])] if not all(1 <= size <= 4000 for size in sizes): raise ValueError("size outside PowerPoint range") if not all(isinstance(value, str) and value.strip() for value in (title, body)): raise ValueError("empty family") except (KeyError, TypeError, ValueError) as error: raise ValueError("Native export requires declared page_title/body family_role and size_pt") from error return families, title, body, *(round(size * 100) for size in sizes) def finalize(path: Path, theme: dict, style: dict): from svg_to_pptx.drawingml.theme_fonts import ( ThemeFontFace, ThemeFontSpec, MasterTextStyleSpec, apply_theme_font_spec, apply_master_text_style_spec, ) families, title, body, title_hpt, body_hpt = typography(style) palette = theme["palette"] if not all(re.fullmatch(r"#[0-9a-fA-F]{6}", palette.get(key, "")) for key in COLORS): raise ValueError("Native export requires the 12 declared PowerPoint palette entries") face = lambda family: ThemeFontFace(latin=family, ea=family, cs=family) declared = {} for entry in families.values(): declared.setdefault(entry["family"], []).append(entry) with tempfile.TemporaryDirectory(prefix="declared-theme-", dir=path.parent) as temporary: root = Path(temporary) with zipfile.ZipFile(path) as archive: archive.extractall(root) for part in (root / "ppt/theme").glob("theme*.xml"): tree = ET.parse(str(part)) scheme = tree.find(".//a:clrScheme", NS) if scheme is None: raise ValueError("Compiler omitted the theme color scheme") scheme.set("name", theme["theme_id"]) for key in COLORS: parent = scheme.find("a:" + key, NS) if parent is None: raise ValueError(f"Compiler omitted palette entry: {key}") for child in list(parent): parent.remove(child) ET.SubElement(parent, "{" + NS["a"] + "}srgbClr", val=palette[key][1:]) tree.write(str(part), encoding="utf-8", xml_declaration=True) apply_theme_font_spec(root, ThemeFontSpec(major=face(title), minor=face(body), major_family=title, minor_family=body)) apply_master_text_style_spec(root, MasterTextStyleSpec(title_hpt=title_hpt, body_hpt=body_hpt)) # Correct the compiler's generic East Asian fallback for runs whose Latin # family identifies a declared role. Never replace arbitrary source fonts. for part in (root / "ppt").rglob("*.xml"): tree = ET.parse(str(part)) changed = False for run in tree.findall(".//a:rPr", NS) + tree.findall(".//a:defRPr", NS): latin = run.find("a:latin", NS) if latin is None or latin.get("typeface") not in declared: continue family = latin.get("typeface") entries = declared[family] fallbacks = {entry.get("cjk_fallback", family) for entry in entries} ea = run.find("a:ea", NS) if ea is not None and len(fallbacks) == 1: ea.set("typeface", next(iter(fallbacks))) changed = True # The compiler resolves source weight for every actual run; # it emits b="1" for bold and omits b for regular. Materialize # regular explicitly so a bold family/master cannot override it. weights = {entry.get("weight_number") for entry in entries} if run.get("b") is None: if run.tag == "{" + NS["a"] + "}rPr": run.set("b", "0") changed = True elif len(weights) == 1 and None not in weights: run.set("b", "1" if next(iter(weights)) >= 600 else "0") changed = True if changed: tree.write(str(part), encoding="utf-8", xml_declaration=True) # A template's body size is its default at each nesting level. The author # can explicitly assign other declared sizes to individual objects. for part in (root / "ppt/slideMasters").glob("slideMaster*.xml"): tree = ET.parse(str(part)) for kind in ("bodyStyle", "otherStyle"): for run in tree.findall(".//p:" + kind + "//a:defRPr", NS): run.set("sz", str(body_hpt)) tree.write(str(part), encoding="utf-8", xml_declaration=True) target = root / "finalized.pptx" with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as archive: for part in sorted(root.rglob("*")): if part.is_file() and part != target: archive.write(part, part.relative_to(root).as_posix()) target.replace(path) def verify(path: Path, expected_layouts: dict, expected_names: list[str], notes: dict): from pptx import Presentation deck = Presentation(path) masters = list(deck.slide_masters) layouts = [layout for master in masters for layout in master.slide_layouts] if len(masters) != 1 or len(layouts) != len(expected_layouts): raise ValueError("Native Master/Layout counts differ from the template roster") if {layout.name for layout in layouts} != set(expected_layouts.values()): raise ValueError("Native Layout names differ from the manifest") visible = [slide.slide_layout.name for slide in deck.slides] if visible != expected_names: raise ValueError("Visible slides bind to unexpected native Layouts") placeholders = 0 for slide in deck.slides: for shape in slide.placeholders: matching = slide.slide_layout.placeholders.get(idx=shape.placeholder_format.idx) if matching is None or shape.placeholder_format.type != matching.placeholder_format.type: raise ValueError("Slide placeholder does not bind to its Layout") placeholders += 1 for index, text in notes.items(): actual = deck.slides[index].notes_slide.notes_text_frame.text if actual.strip() != text.strip(): raise ValueError("Speaker notes differ from the declared source") font_faces = set() with zipfile.ZipFile(path) as archive: for part in archive.namelist(): if re.fullmatch(r"ppt/slides/slide\d+\.xml", part): tree = ET.fromstring(archive.read(part)) for run in tree.findall(".//a:r", NS): if not (run.findtext("a:t", namespaces=NS) or "").strip(): continue properties = run.find("a:rPr", NS) if properties is not None: for key in ("latin", "ea", "cs"): face = properties.find("a:" + key, NS) if face is not None: font_faces.add(face.get("typeface")) return {"slides": len(deck.slides), "masters": len(masters), "layouts": len(layouts), "visible_layouts": visible, "placeholders": placeholders, "notes_verified": len(notes), "text_font_faces": sorted(font_faces), "verification_scope": "native structure, selected layout links and supplied notes; rendering and editing unverified"}