Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""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
|
||||
# Weight is role metadata, not an inference that all titles bold.
|
||||
weights = {entry.get("weight_number") for entry in entries}
|
||||
if 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"}
|
||||
Reference in New Issue
Block a user