"""Theme typography contracts shared by SVG conversion and PPTX assembly.""" from __future__ import annotations import re from dataclasses import dataclass from pathlib import Path from xml.etree import ElementTree as ET from .utils import font_px_to_hpt, parse_font_family DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" _LOCK_ROW_RE = re.compile(r"^-\s+([A-Za-z0-9_]+)\s*:\s*(.+?)\s*$") _CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"}) class ThemeFontError(RuntimeError): """Raised when a project theme-font contract cannot be loaded or applied.""" @dataclass(frozen=True) class ThemeFontFace: """Concrete Latin, East Asian, and complex-script theme faces.""" latin: str ea: str cs: str def matches(self, fonts: dict[str, str]) -> bool: """Return whether resolved SVG fonts represent this theme face.""" return fonts.get("latin") == self.latin and fonts.get("ea") == self.ea @dataclass(frozen=True) class ThemeFontSpec: """Major/minor theme fonts derived from one project's typography lock.""" major: ThemeFontFace minor: ThemeFontFace major_family: str minor_family: str @dataclass(frozen=True) class MasterTextStyleSpec: """Title/body defaults written to one generated slide master's txStyles.""" title_hpt: int body_hpt: int @property def body_levels_hpt(self) -> tuple[int, ...]: """Return a deterministic nine-level PowerPoint body-size hierarchy.""" factors = (16, 15, 14, 13, 12, 11, 10, 9, 8) minimum = min(self.body_hpt, 800) sizes = [] for factor in factors: scaled = round((self.body_hpt * factor / 16) / 50) * 50 sizes.append(max(minimum, scaled)) return tuple(sizes) def _font_face(font_family: str) -> ThemeFontFace: fonts = parse_font_family(font_family) return ThemeFontFace( latin=fonts["latin"], ea=fonts["ea"], cs=fonts["latin"], ) def _typography_rows(lock_path: Path) -> dict[str, str]: rows: dict[str, str] = {} current_section: str | None = None try: lines = lock_path.read_text(encoding="utf-8").splitlines() except OSError as exc: raise ThemeFontError(f"Cannot read {lock_path}: {exc}") from exc for raw_line in lines: line = raw_line.strip() if line.startswith("## "): current_section = line[3:].strip() continue if current_section != "typography": continue match = _LOCK_ROW_RE.fullmatch(line) if match: rows[match.group(1)] = match.group(2) return rows def load_theme_font_spec(project_path: Path) -> ThemeFontSpec | None: """Load major/minor theme fonts from ``spec_lock.md`` typography rows.""" lock_path = project_path / "spec_lock.md" if not lock_path.is_file(): return None rows = _typography_rows(lock_path) default_family = rows.get("font_family") major_family = rows.get("title_family") or default_family minor_family = rows.get("body_family") or default_family if not major_family or not minor_family: return None return ThemeFontSpec( major=_font_face(major_family), minor=_font_face(minor_family), major_family=major_family, minor_family=minor_family, ) def _font_size_hpt(raw: str, field: str) -> int: try: px = float(raw) except (TypeError, ValueError, OverflowError) as exc: raise ThemeFontError( f"spec_lock.md typography {field} must be a numeric px value: {raw!r}" ) from exc try: size = font_px_to_hpt(px) except ValueError as exc: raise ThemeFontError( f"spec_lock.md typography {field} is outside the PowerPoint " f"font-size range: {raw!r}" ) from exc return size def load_master_text_style_spec(project_path: Path) -> MasterTextStyleSpec: """Load required title/body defaults for generated Master text styles.""" lock_path = project_path / "spec_lock.md" if not lock_path.is_file(): raise ThemeFontError( "Master export requires spec_lock.md typography title and body rows" ) rows = _typography_rows(lock_path) missing = [field for field in ("title", "body") if field not in rows] if missing: raise ThemeFontError( "Master export requires spec_lock.md typography rows: " + ", ".join(missing) ) return MasterTextStyleSpec( title_hpt=_font_size_hpt(rows["title"], "title"), body_hpt=_font_size_hpt(rows["body"], "body"), ) def theme_font_tokens( fonts: dict[str, str], spec: ThemeFontSpec | None, ) -> dict[str, str] | None: """Return DrawingML major/minor tokens for a locked SVG font face.""" if spec is None: return None major_match = spec.major.matches(fonts) minor_match = spec.minor.matches(fonts) if major_match and not minor_match: prefix = "+mj" elif minor_match: # When title/body use the same family, minor is the least surprising # default for ordinary text boxes. Template assembly forces semantic # title placeholders to the major role after SVG conversion. prefix = "+mn" else: return None return { "latin": f"{prefix}-lt", "ea": f"{prefix}-ea", "cs": f"{prefix}-cs", } def _patch_font_collection(collection: ET.Element, face: ThemeFontFace) -> None: for tag, value in (("latin", face.latin), ("ea", face.ea), ("cs", face.cs)): elem = collection.find(f"{{{DML_NS}}}{tag}") if elem is None: elem = ET.SubElement(collection, f"{{{DML_NS}}}{tag}") elem.set("typeface", value) for supplemental in collection.findall(f"{{{DML_NS}}}font"): if supplemental.get("script") in _CJK_THEME_SCRIPTS: supplemental.set("typeface", face.ea) def apply_theme_font_spec(extract_dir: Path, spec: ThemeFontSpec) -> None: """Install locked major/minor fonts into every existing PPTX theme part.""" theme_dir = extract_dir / "ppt" / "theme" theme_paths = sorted(theme_dir.glob("theme*.xml")) if not theme_paths: raise ThemeFontError(f"PPTX package has no theme part under {theme_dir}") ET.register_namespace("a", DML_NS) for theme_path in theme_paths: try: tree = ET.parse(theme_path) except (OSError, ET.ParseError) as exc: raise ThemeFontError(f"Cannot parse {theme_path}: {exc}") from exc font_scheme = tree.getroot().find(f".//{{{DML_NS}}}fontScheme") if font_scheme is None: raise ThemeFontError(f"Theme has no fontScheme: {theme_path}") major = font_scheme.find(f"{{{DML_NS}}}majorFont") minor = font_scheme.find(f"{{{DML_NS}}}minorFont") if major is None or minor is None: raise ThemeFontError(f"Theme has no major/minor font collection: {theme_path}") font_scheme.set("name", "PPT Master") _patch_font_collection(major, spec.major) _patch_font_collection(minor, spec.minor) tree.write(theme_path, encoding="utf-8", xml_declaration=True) def _style_run_properties(style: ET.Element, label: str) -> list[ET.Element]: run_properties = list(style.iter(f"{{{DML_NS}}}defRPr")) if not run_properties: raise ThemeFontError(f"slide master {label} has no a:defRPr entries") return run_properties def _style_level_run_properties( style: ET.Element, label: str, ) -> tuple[ET.Element, ...]: """Return direct level 1-9 defaults from one Master text style.""" levels: list[ET.Element] = [] for level in range(1, 10): run_properties = style.find( f"{{{DML_NS}}}lvl{level}pPr/{{{DML_NS}}}defRPr" ) if run_properties is None: raise ThemeFontError( f"slide master {label} has no level-{level} a:defRPr" ) levels.append(run_properties) return tuple(levels) def apply_master_text_style_spec( extract_dir: Path, spec: MasterTextStyleSpec, ) -> int: """Install declared title/body anchors into generated slide-master txStyles.""" master_dir = extract_dir / "ppt" / "slideMasters" master_paths = sorted(master_dir.glob("slideMaster*.xml")) if not master_paths: raise ThemeFontError(f"PPTX package has no slide master under {master_dir}") ET.register_namespace("a", DML_NS) ET.register_namespace("p", PML_NS) for master_path in master_paths: try: tree = ET.parse(master_path) except (OSError, ET.ParseError) as exc: raise ThemeFontError(f"Cannot parse {master_path}: {exc}") from exc text_styles = tree.getroot().find(f"{{{PML_NS}}}txStyles") if text_styles is None: raise ThemeFontError(f"Slide master has no p:txStyles: {master_path}") title_style = text_styles.find(f"{{{PML_NS}}}titleStyle") if title_style is None: raise ThemeFontError( f"Slide master has no p:titleStyle: {master_path}" ) for run_properties in _style_run_properties( title_style, "p:titleStyle", ): run_properties.set("sz", str(spec.title_hpt)) body_levels = spec.body_levels_hpt for style_name in ("bodyStyle", "otherStyle"): style = text_styles.find(f"{{{PML_NS}}}{style_name}") if style is None: raise ThemeFontError( f"Slide master has no p:{style_name}: {master_path}" ) for run_properties, size in zip( _style_level_run_properties( style, f"p:{style_name}", ), body_levels, ): run_properties.set("sz", str(size)) style_sizes = ( ("titleStyle", (spec.title_hpt,)), ("bodyStyle", body_levels), ("otherStyle", body_levels), ) tree.write(master_path, encoding="utf-8", xml_declaration=True) try: read_back = ET.parse(master_path).getroot() except (OSError, ET.ParseError) as exc: raise ThemeFontError( f"Cannot read back slide master {master_path}: {exc}" ) from exc read_back_styles = read_back.find(f"{{{PML_NS}}}txStyles") if read_back_styles is None: raise ThemeFontError( f"Slide master lost p:txStyles after update: {master_path}" ) for style_name, expected_sizes in style_sizes: style = read_back_styles.find(f"{{{PML_NS}}}{style_name}") if style is None: actual_sizes: tuple[str | None, ...] = () elif style_name == "titleStyle": actual_sizes = tuple( item.get("sz") for item in _style_run_properties( style, f"p:{style_name}", ) ) else: actual_sizes = tuple( item.get("sz") for item in _style_level_run_properties( style, f"p:{style_name}", ) ) if actual_sizes != tuple(str(size) for size in expected_sizes): raise ThemeFontError( f"Slide master p:{style_name} size read-back failed: " f"{master_path}" ) return len(master_paths)