| 1 | """Theme typography contracts shared by SVG conversion and PPTX assembly.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import re |
| 6 | from dataclasses import dataclass |
| 7 | from pathlib import Path |
| 8 | from xml.etree import ElementTree as ET |
| 9 | |
| 10 | from .utils import font_px_to_hpt, parse_font_family |
| 11 | |
| 12 | |
| 13 | DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" |
| 14 | PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" |
| 15 | _LOCK_ROW_RE = re.compile(r"^-\s+([A-Za-z0-9_]+)\s*:\s*(.+?)\s*$") |
| 16 | _CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"}) |
| 17 | |
| 18 | |
| 19 | class ThemeFontError(RuntimeError): |
| 20 | """Raised when a project theme-font contract cannot be loaded or applied.""" |
| 21 | |
| 22 | |
| 23 | @dataclass(frozen=True) |
| 24 | class ThemeFontFace: |
| 25 | """Concrete Latin, East Asian, and complex-script theme faces.""" |
| 26 | |
| 27 | latin: str |
| 28 | ea: str |
| 29 | cs: str |
| 30 | |
| 31 | def matches(self, fonts: dict[str, str]) -> bool: |
| 32 | """Return whether resolved SVG fonts represent this theme face.""" |
| 33 | return fonts.get("latin") == self.latin and fonts.get("ea") == self.ea |
| 34 | |
| 35 | |
| 36 | @dataclass(frozen=True) |
| 37 | class ThemeFontSpec: |
| 38 | """Major/minor theme fonts derived from one project's typography lock.""" |
| 39 | |
| 40 | major: ThemeFontFace |
| 41 | minor: ThemeFontFace |
| 42 | major_family: str |
| 43 | minor_family: str |
| 44 | |
| 45 | |
| 46 | @dataclass(frozen=True) |
| 47 | class MasterTextStyleSpec: |
| 48 | """Title/body defaults written to one generated slide master's txStyles.""" |
| 49 | |
| 50 | title_hpt: int |
| 51 | body_hpt: int |
| 52 | |
| 53 | @property |
| 54 | def body_levels_hpt(self) -> tuple[int, ...]: |
| 55 | """Return a deterministic nine-level PowerPoint body-size hierarchy.""" |
| 56 | factors = (16, 15, 14, 13, 12, 11, 10, 9, 8) |
| 57 | minimum = min(self.body_hpt, 800) |
| 58 | sizes = [] |
| 59 | for factor in factors: |
| 60 | scaled = round((self.body_hpt * factor / 16) / 50) * 50 |
| 61 | sizes.append(max(minimum, scaled)) |
| 62 | return tuple(sizes) |
| 63 | |
| 64 | |
| 65 | def _font_face(font_family: str) -> ThemeFontFace: |
| 66 | fonts = parse_font_family(font_family) |
| 67 | return ThemeFontFace( |
| 68 | latin=fonts["latin"], |
| 69 | ea=fonts["ea"], |
| 70 | cs=fonts["latin"], |
| 71 | ) |
| 72 | |
| 73 | |
| 74 | def _typography_rows(lock_path: Path) -> dict[str, str]: |
| 75 | rows: dict[str, str] = {} |
| 76 | current_section: str | None = None |
| 77 | try: |
| 78 | lines = lock_path.read_text(encoding="utf-8").splitlines() |
| 79 | except OSError as exc: |
| 80 | raise ThemeFontError(f"Cannot read {lock_path}: {exc}") from exc |
| 81 | |
| 82 | for raw_line in lines: |
| 83 | line = raw_line.strip() |
| 84 | if line.startswith("## "): |
| 85 | current_section = line[3:].strip() |
| 86 | continue |
| 87 | if current_section != "typography": |
| 88 | continue |
| 89 | match = _LOCK_ROW_RE.fullmatch(line) |
| 90 | if match: |
| 91 | rows[match.group(1)] = match.group(2) |
| 92 | return rows |
| 93 | |
| 94 | |
| 95 | def load_theme_font_spec(project_path: Path) -> ThemeFontSpec | None: |
| 96 | """Load major/minor theme fonts from ``spec_lock.md`` typography rows.""" |
| 97 | lock_path = project_path / "spec_lock.md" |
| 98 | if not lock_path.is_file(): |
| 99 | return None |
| 100 | rows = _typography_rows(lock_path) |
| 101 | default_family = rows.get("font_family") |
| 102 | major_family = rows.get("title_family") or default_family |
| 103 | minor_family = rows.get("body_family") or default_family |
| 104 | if not major_family or not minor_family: |
| 105 | return None |
| 106 | return ThemeFontSpec( |
| 107 | major=_font_face(major_family), |
| 108 | minor=_font_face(minor_family), |
| 109 | major_family=major_family, |
| 110 | minor_family=minor_family, |
| 111 | ) |
| 112 | |
| 113 | |
| 114 | def _font_size_hpt(raw: str, field: str) -> int: |
| 115 | try: |
| 116 | px = float(raw) |
| 117 | except (TypeError, ValueError, OverflowError) as exc: |
| 118 | raise ThemeFontError( |
| 119 | f"spec_lock.md typography {field} must be a numeric px value: {raw!r}" |
| 120 | ) from exc |
| 121 | try: |
| 122 | size = font_px_to_hpt(px) |
| 123 | except ValueError as exc: |
| 124 | raise ThemeFontError( |
| 125 | f"spec_lock.md typography {field} is outside the PowerPoint " |
| 126 | f"font-size range: {raw!r}" |
| 127 | ) from exc |
| 128 | return size |
| 129 | |
| 130 | |
| 131 | def load_master_text_style_spec(project_path: Path) -> MasterTextStyleSpec: |
| 132 | """Load required title/body defaults for generated Master text styles.""" |
| 133 | lock_path = project_path / "spec_lock.md" |
| 134 | if not lock_path.is_file(): |
| 135 | raise ThemeFontError( |
| 136 | "Master export requires spec_lock.md typography title and body rows" |
| 137 | ) |
| 138 | rows = _typography_rows(lock_path) |
| 139 | missing = [field for field in ("title", "body") if field not in rows] |
| 140 | if missing: |
| 141 | raise ThemeFontError( |
| 142 | "Master export requires spec_lock.md typography rows: " |
| 143 | + ", ".join(missing) |
| 144 | ) |
| 145 | return MasterTextStyleSpec( |
| 146 | title_hpt=_font_size_hpt(rows["title"], "title"), |
| 147 | body_hpt=_font_size_hpt(rows["body"], "body"), |
| 148 | ) |
| 149 | |
| 150 | |
| 151 | def theme_font_tokens( |
| 152 | fonts: dict[str, str], |
| 153 | spec: ThemeFontSpec | None, |
| 154 | ) -> dict[str, str] | None: |
| 155 | """Return DrawingML major/minor tokens for a locked SVG font face.""" |
| 156 | if spec is None: |
| 157 | return None |
| 158 | major_match = spec.major.matches(fonts) |
| 159 | minor_match = spec.minor.matches(fonts) |
| 160 | if major_match and not minor_match: |
| 161 | prefix = "+mj" |
| 162 | elif minor_match: |
| 163 | # When title/body use the same family, minor is the least surprising |
| 164 | # default for ordinary text boxes. Template assembly forces semantic |
| 165 | # title placeholders to the major role after SVG conversion. |
| 166 | prefix = "+mn" |
| 167 | else: |
| 168 | return None |
| 169 | return { |
| 170 | "latin": f"{prefix}-lt", |
| 171 | "ea": f"{prefix}-ea", |
| 172 | "cs": f"{prefix}-cs", |
| 173 | } |
| 174 | |
| 175 | |
| 176 | def _patch_font_collection(collection: ET.Element, face: ThemeFontFace) -> None: |
| 177 | for tag, value in (("latin", face.latin), ("ea", face.ea), ("cs", face.cs)): |
| 178 | elem = collection.find(f"{{{DML_NS}}}{tag}") |
| 179 | if elem is None: |
| 180 | elem = ET.SubElement(collection, f"{{{DML_NS}}}{tag}") |
| 181 | elem.set("typeface", value) |
| 182 | for supplemental in collection.findall(f"{{{DML_NS}}}font"): |
| 183 | if supplemental.get("script") in _CJK_THEME_SCRIPTS: |
| 184 | supplemental.set("typeface", face.ea) |
| 185 | |
| 186 | |
| 187 | def apply_theme_font_spec(extract_dir: Path, spec: ThemeFontSpec) -> None: |
| 188 | """Install locked major/minor fonts into every existing PPTX theme part.""" |
| 189 | theme_dir = extract_dir / "ppt" / "theme" |
| 190 | theme_paths = sorted(theme_dir.glob("theme*.xml")) |
| 191 | if not theme_paths: |
| 192 | raise ThemeFontError(f"PPTX package has no theme part under {theme_dir}") |
| 193 | |
| 194 | ET.register_namespace("a", DML_NS) |
| 195 | for theme_path in theme_paths: |
| 196 | try: |
| 197 | tree = ET.parse(theme_path) |
| 198 | except (OSError, ET.ParseError) as exc: |
| 199 | raise ThemeFontError(f"Cannot parse {theme_path}: {exc}") from exc |
| 200 | font_scheme = tree.getroot().find(f".//{{{DML_NS}}}fontScheme") |
| 201 | if font_scheme is None: |
| 202 | raise ThemeFontError(f"Theme has no fontScheme: {theme_path}") |
| 203 | major = font_scheme.find(f"{{{DML_NS}}}majorFont") |
| 204 | minor = font_scheme.find(f"{{{DML_NS}}}minorFont") |
| 205 | if major is None or minor is None: |
| 206 | raise ThemeFontError(f"Theme has no major/minor font collection: {theme_path}") |
| 207 | font_scheme.set("name", "PPT Master") |
| 208 | _patch_font_collection(major, spec.major) |
| 209 | _patch_font_collection(minor, spec.minor) |
| 210 | tree.write(theme_path, encoding="utf-8", xml_declaration=True) |
| 211 | |
| 212 | |
| 213 | def _style_run_properties(style: ET.Element, label: str) -> list[ET.Element]: |
| 214 | run_properties = list(style.iter(f"{{{DML_NS}}}defRPr")) |
| 215 | if not run_properties: |
| 216 | raise ThemeFontError(f"slide master {label} has no a:defRPr entries") |
| 217 | return run_properties |
| 218 | |
| 219 | |
| 220 | def _style_level_run_properties( |
| 221 | style: ET.Element, |
| 222 | label: str, |
| 223 | ) -> tuple[ET.Element, ...]: |
| 224 | """Return direct level 1-9 defaults from one Master text style.""" |
| 225 | levels: list[ET.Element] = [] |
| 226 | for level in range(1, 10): |
| 227 | run_properties = style.find( |
| 228 | f"{{{DML_NS}}}lvl{level}pPr/{{{DML_NS}}}defRPr" |
| 229 | ) |
| 230 | if run_properties is None: |
| 231 | raise ThemeFontError( |
| 232 | f"slide master {label} has no level-{level} a:defRPr" |
| 233 | ) |
| 234 | levels.append(run_properties) |
| 235 | return tuple(levels) |
| 236 | |
| 237 | |
| 238 | def apply_master_text_style_spec( |
| 239 | extract_dir: Path, |
| 240 | spec: MasterTextStyleSpec, |
| 241 | ) -> int: |
| 242 | """Install declared title/body anchors into generated slide-master txStyles.""" |
| 243 | master_dir = extract_dir / "ppt" / "slideMasters" |
| 244 | master_paths = sorted(master_dir.glob("slideMaster*.xml")) |
| 245 | if not master_paths: |
| 246 | raise ThemeFontError(f"PPTX package has no slide master under {master_dir}") |
| 247 | |
| 248 | ET.register_namespace("a", DML_NS) |
| 249 | ET.register_namespace("p", PML_NS) |
| 250 | for master_path in master_paths: |
| 251 | try: |
| 252 | tree = ET.parse(master_path) |
| 253 | except (OSError, ET.ParseError) as exc: |
| 254 | raise ThemeFontError(f"Cannot parse {master_path}: {exc}") from exc |
| 255 | text_styles = tree.getroot().find(f"{{{PML_NS}}}txStyles") |
| 256 | if text_styles is None: |
| 257 | raise ThemeFontError(f"Slide master has no p:txStyles: {master_path}") |
| 258 | |
| 259 | title_style = text_styles.find(f"{{{PML_NS}}}titleStyle") |
| 260 | if title_style is None: |
| 261 | raise ThemeFontError( |
| 262 | f"Slide master has no p:titleStyle: {master_path}" |
| 263 | ) |
| 264 | for run_properties in _style_run_properties( |
| 265 | title_style, |
| 266 | "p:titleStyle", |
| 267 | ): |
| 268 | run_properties.set("sz", str(spec.title_hpt)) |
| 269 | |
| 270 | body_levels = spec.body_levels_hpt |
| 271 | for style_name in ("bodyStyle", "otherStyle"): |
| 272 | style = text_styles.find(f"{{{PML_NS}}}{style_name}") |
| 273 | if style is None: |
| 274 | raise ThemeFontError( |
| 275 | f"Slide master has no p:{style_name}: {master_path}" |
| 276 | ) |
| 277 | for run_properties, size in zip( |
| 278 | _style_level_run_properties( |
| 279 | style, |
| 280 | f"p:{style_name}", |
| 281 | ), |
| 282 | body_levels, |
| 283 | ): |
| 284 | run_properties.set("sz", str(size)) |
| 285 | |
| 286 | style_sizes = ( |
| 287 | ("titleStyle", (spec.title_hpt,)), |
| 288 | ("bodyStyle", body_levels), |
| 289 | ("otherStyle", body_levels), |
| 290 | ) |
| 291 | |
| 292 | tree.write(master_path, encoding="utf-8", xml_declaration=True) |
| 293 | |
| 294 | try: |
| 295 | read_back = ET.parse(master_path).getroot() |
| 296 | except (OSError, ET.ParseError) as exc: |
| 297 | raise ThemeFontError( |
| 298 | f"Cannot read back slide master {master_path}: {exc}" |
| 299 | ) from exc |
| 300 | read_back_styles = read_back.find(f"{{{PML_NS}}}txStyles") |
| 301 | if read_back_styles is None: |
| 302 | raise ThemeFontError( |
| 303 | f"Slide master lost p:txStyles after update: {master_path}" |
| 304 | ) |
| 305 | for style_name, expected_sizes in style_sizes: |
| 306 | style = read_back_styles.find(f"{{{PML_NS}}}{style_name}") |
| 307 | if style is None: |
| 308 | actual_sizes: tuple[str | None, ...] = () |
| 309 | elif style_name == "titleStyle": |
| 310 | actual_sizes = tuple( |
| 311 | item.get("sz") |
| 312 | for item in _style_run_properties( |
| 313 | style, |
| 314 | f"p:{style_name}", |
| 315 | ) |
| 316 | ) |
| 317 | else: |
| 318 | actual_sizes = tuple( |
| 319 | item.get("sz") |
| 320 | for item in _style_level_run_properties( |
| 321 | style, |
| 322 | f"p:{style_name}", |
| 323 | ) |
| 324 | ) |
| 325 | if actual_sizes != tuple(str(size) for size in expected_sizes): |
| 326 | raise ThemeFontError( |
| 327 | f"Slide master p:{style_name} size read-back failed: " |
| 328 | f"{master_path}" |
| 329 | ) |
| 330 | return len(master_paths) |
| 331 |