Files

185 lines
9.4 KiB
Python

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