返回 ppt-master
theme_fonts.py
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
331 lines PYTHON