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