返回 ppt-master
theme_colors.py
根目录 / skills / ppt-master / scripts / svg_to_pptx / drawingml / theme_colors.py
1 """Theme-color contracts shared by SVG conversion and PPTX package 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 parse_hex_color
11
12
13 DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
14 _LOCK_ROW_RE = re.compile(r"^-\s+([A-Za-z0-9_]+)\s*:\s*(.+?)\s*$")
15 _SRGB_PAIR_RE = re.compile(
16 r"<a:srgbClr\b(?P<attrs>[^>]*?)(?<!/)>(?P<body>.*?)</a:srgbClr>",
17 re.DOTALL,
18 )
19 _SRGB_EMPTY_RE = re.compile(r"<a:srgbClr\b(?P<attrs>[^>]*)/>")
20 _VAL_RE = re.compile(r'\bval="(?P<color>[0-9A-Fa-f]{6})"')
21
22 _OFFICE_DEFAULTS = {
23 "dk1": "000000",
24 "lt1": "FFFFFF",
25 "dk2": "1F497D",
26 "lt2": "EEECE1",
27 "accent1": "4F81BD",
28 "accent2": "C0504D",
29 "accent3": "9BBB59",
30 "accent4": "8064A2",
31 "accent5": "4BACC6",
32 "accent6": "F79646",
33 "hlink": "0000FF",
34 "folHlink": "800080",
35 }
36 _ROLE_SLOTS = {
37 "bg": "lt1",
38 "background": "lt1",
39 "master_bg": "lt1",
40 "secondary_bg": "lt2",
41 "bg_secondary": "lt2",
42 "text": "dk1",
43 "body_text": "dk1",
44 "text_secondary": "dk2",
45 "primary": "accent1",
46 "accent": "accent2",
47 "secondary_accent": "accent3",
48 "border": "accent4",
49 }
50 _USAGE_ROLE_ORDER = {
51 "background": (
52 "bg", "background", "master_bg", "secondary_bg", "bg_secondary",
53 "primary", "accent", "secondary_accent",
54 ),
55 "fill": (
56 "primary", "accent", "secondary_accent", "bg", "background",
57 "master_bg", "secondary_bg", "bg_secondary", "text", "body_text",
58 "text_secondary", "border",
59 ),
60 "text": (
61 "text", "body_text", "text_secondary", "primary", "accent",
62 "secondary_accent",
63 ),
64 "stroke": (
65 "border", "primary", "accent", "secondary_accent", "text",
66 "body_text", "text_secondary",
67 ),
68 "chart": ("primary", "accent", "secondary_accent"),
69 }
70 _BACKGROUND_ROLE_TOKENS = (
71 "bg", "background", "surface", "paper", "card", "panel", "tint",
72 )
73 _TEXT_ROLE_TOKENS = ("text", "ink", "muted")
74 _STROKE_ROLE_TOKENS = ("border", "grid", "line", "stroke")
75 _BACKGROUND_USAGES = frozenset({"background", "fill"})
76 _TEXT_USAGES = frozenset({"text"})
77 _STROKE_USAGES = frozenset({"stroke", "fill"})
78 _SEMANTIC_USAGES = frozenset({"fill", "text", "stroke"})
79
80
81 class ThemeColorError(RuntimeError):
82 """Raised when a project theme-color contract cannot be loaded or applied."""
83
84
85 @dataclass(frozen=True)
86 class ThemeColorSpec:
87 """PowerPoint color scheme derived from one project's color lock."""
88
89 slots: dict[str, str]
90 roles: dict[str, str]
91 role_slots: dict[str, str]
92 extra_roles: tuple[str, ...] = ()
93
94 def scheme_for(self, color: str, usage: str) -> str | None:
95 """Resolve one concrete color to a safe scheme slot for its usage."""
96 normalized = parse_hex_color(color)
97 if normalized is None:
98 return None
99 for role in _USAGE_ROLE_ORDER.get(usage, ()):
100 if self.roles.get(role) == normalized:
101 slot = self.role_slots[role]
102 if self.slots.get(slot) == normalized:
103 return slot
104 for role in self.extra_roles:
105 if usage in _extra_role_usages(role) and self.roles.get(role) == normalized:
106 slot = self.role_slots[role]
107 if self.slots.get(slot) == normalized:
108 return slot
109 return None
110
111
112 def _extra_role_usages(role: str) -> frozenset[str]:
113 """Limit extra theme slots to contexts implied by their lock role name."""
114 normalized = role.lower()
115 if any(token in normalized for token in _BACKGROUND_ROLE_TOKENS):
116 return _BACKGROUND_USAGES
117 if any(token in normalized for token in _TEXT_ROLE_TOKENS):
118 return _TEXT_USAGES
119 if any(token in normalized for token in _STROKE_ROLE_TOKENS):
120 return _STROKE_USAGES
121 return _SEMANTIC_USAGES
122
123
124 def _color_rows(lock_path: Path) -> dict[str, str]:
125 rows: dict[str, str] = {}
126 current_section: str | None = None
127 try:
128 lines = lock_path.read_text(encoding="utf-8").splitlines()
129 except OSError as exc:
130 raise ThemeColorError(f"Cannot read {lock_path}: {exc}") from exc
131 for raw_line in lines:
132 line = raw_line.strip()
133 if line.startswith("## "):
134 current_section = line[3:].strip()
135 continue
136 if current_section != "colors":
137 continue
138 match = _LOCK_ROW_RE.fullmatch(line)
139 if not match:
140 continue
141 raw_color = match.group(2).strip()
142 if (
143 len(raw_color) >= 2
144 and raw_color[0] == raw_color[-1]
145 and raw_color[0] in {'"', "'"}
146 ):
147 raw_color = raw_color[1:-1].strip()
148 color = parse_hex_color(raw_color)
149 if color is not None:
150 rows[match.group(1)] = color
151 return rows
152
153
154 def _first_color(roles: dict[str, str], *keys: str, default: str) -> str:
155 return next((roles[key] for key in keys if key in roles), default)
156
157
158 def load_theme_color_spec(project_path: Path) -> ThemeColorSpec | None:
159 """Load a PowerPoint color scheme from ``spec_lock.md`` color rows."""
160 lock_path = project_path / "spec_lock.md"
161 if not lock_path.is_file():
162 return None
163 roles = _color_rows(lock_path)
164 if not roles:
165 return None
166
167 extra_roles = tuple(
168 key
169 for key, color in roles.items()
170 if key not in _ROLE_SLOTS and color not in {"000000", "FFFFFF"}
171 )[:2]
172 extra_slots = dict(zip(extra_roles, ("accent5", "accent6")))
173 role_slots = {**_ROLE_SLOTS, **extra_slots}
174
175 slots = dict(_OFFICE_DEFAULTS)
176 slots.update({
177 "lt1": _first_color(
178 roles,
179 "bg", "background", "master_bg",
180 default=slots["lt1"],
181 ),
182 "dk1": _first_color(
183 roles,
184 "text", "body_text", "primary",
185 default=slots["dk1"],
186 ),
187 "lt2": _first_color(
188 roles,
189 "secondary_bg", "bg_secondary", "bg", "background", "master_bg",
190 default=slots["lt2"],
191 ),
192 "dk2": _first_color(roles, "text_secondary", "text", default=slots["dk2"]),
193 "accent1": _first_color(roles, "primary", "accent", default=slots["accent1"]),
194 "accent2": _first_color(
195 roles,
196 "accent", "secondary_accent", "primary",
197 default=slots["accent2"],
198 ),
199 "accent3": _first_color(roles, "secondary_accent", "accent", default=slots["accent3"]),
200 "accent4": _first_color(roles, "border", "primary", default=slots["accent4"]),
201 "hlink": _first_color(roles, "accent", "primary", default=slots["hlink"]),
202 "folHlink": _first_color(roles, "secondary_accent", "accent", default=slots["folHlink"]),
203 })
204 for role, slot in extra_slots.items():
205 slots[slot] = roles[role]
206 return ThemeColorSpec(
207 slots=slots,
208 roles=roles,
209 role_slots=role_slots,
210 extra_roles=extra_roles,
211 )
212
213
214 def color_node_xml(
215 color: str,
216 spec: ThemeColorSpec | None,
217 usage: str,
218 inner_xml: str = "",
219 ) -> str:
220 """Build an srgbClr or schemeClr node while preserving child transforms."""
221 normalized = parse_hex_color(color) or color.strip().lstrip("#").upper()
222 scheme = spec.scheme_for(normalized, usage) if spec is not None else None
223 if scheme:
224 return f'<a:schemeClr val="{scheme}">{inner_xml}</a:schemeClr>'
225 return f'<a:srgbClr val="{normalized}">{inner_xml}</a:srgbClr>'
226
227
228 def _set_scheme_color(parent: ET.Element, slot: str, color: str) -> None:
229 target = parent.find(f"{{{DML_NS}}}{slot}")
230 if target is None:
231 target = ET.SubElement(parent, f"{{{DML_NS}}}{slot}")
232 for child in list(target):
233 target.remove(child)
234 ET.SubElement(target, f"{{{DML_NS}}}srgbClr", {"val": color})
235
236
237 def apply_theme_color_spec(extract_dir: Path, spec: ThemeColorSpec) -> None:
238 """Install the locked color scheme into every existing PPTX theme part."""
239 theme_dir = extract_dir / "ppt" / "theme"
240 theme_paths = sorted(theme_dir.glob("theme*.xml"))
241 if not theme_paths:
242 raise ThemeColorError(f"PPTX package has no theme part under {theme_dir}")
243
244 ET.register_namespace("a", DML_NS)
245 for theme_path in theme_paths:
246 try:
247 tree = ET.parse(theme_path)
248 except (OSError, ET.ParseError) as exc:
249 raise ThemeColorError(f"Cannot parse {theme_path}: {exc}") from exc
250 color_scheme = tree.getroot().find(f".//{{{DML_NS}}}clrScheme")
251 if color_scheme is None:
252 raise ThemeColorError(f"Theme has no clrScheme: {theme_path}")
253 color_scheme.set("name", "PPT Master")
254 for slot, color in spec.slots.items():
255 _set_scheme_color(color_scheme, slot, color)
256 tree.write(theme_path, encoding="utf-8", xml_declaration=True)
257
258
259 def rewrite_chart_accent_colors(data: bytes, spec: ThemeColorSpec | None) -> bytes:
260 """Promote exact locked accent colors inside native chart XML."""
261 if spec is None or b"<a:srgbClr" not in data:
262 return data
263 try:
264 text = data.decode("utf-8")
265 except UnicodeDecodeError:
266 return data
267
268 def replacement(match: re.Match[str], *, paired: bool) -> str:
269 attrs = match.group("attrs")
270 value_match = _VAL_RE.search(attrs)
271 if value_match is None:
272 return match.group(0)
273 scheme = spec.scheme_for(value_match.group("color"), "chart")
274 if scheme is None:
275 return match.group(0)
276 new_attrs = _VAL_RE.sub(f'val="{scheme}"', attrs, count=1)
277 if paired:
278 return f'<a:schemeClr{new_attrs}>{match.group("body")}</a:schemeClr>'
279 return f'<a:schemeClr{new_attrs}/>'
280
281 text = _SRGB_PAIR_RE.sub(lambda match: replacement(match, paired=True), text)
282 text = _SRGB_EMPTY_RE.sub(lambda match: replacement(match, paired=False), text)
283 return text.encode("utf-8")
284
284 lines PYTHON