返回 ppt-master
resource_paths.py
根目录 / skills / ppt-master / scripts / resource_paths.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Shared SVG Resource Helpers
4
5 Centralizes project-relative resource lookup and nested-SVG closure validation
6 used by the checker, finalizer, and SVG-to-PPTX exporter.
7
8 Usage:
9 Imported by scripts; not intended as a standalone CLI.
10
11 Examples:
12 from resource_paths import resolve_external_image_reference
13 from resource_paths import svg_image_payload_error
14
15 Dependencies:
16 None
17 """
18
19 from __future__ import annotations
20
21 import base64
22 import binascii
23 import re
24 from pathlib import Path
25 from urllib.parse import unquote, unquote_to_bytes, urlsplit
26 from xml.etree import ElementTree as ET
27
28
29 SVG_WORK_DIR_NAMES = frozenset({'svg_output', 'svg_final', 'svg-flat', 'svg_flat'})
30 SVG_FINAL_CANDIDATE_PREFIX = '.svg_final.candidate-'
31 TEMPLATE_SOURCE_DIR_NAME = 'templates'
32 TEMPLATE_SPEC_FILENAME = 'design_spec.md'
33 _SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
34 _SVG_URL_REFERENCE_RE = re.compile(r'url\(\s*([^)]+?)\s*\)', re.IGNORECASE)
35 _SVG_CSS_URL_ATTRIBUTES = frozenset({
36 'background',
37 'background-image',
38 'clip-path',
39 'color-profile',
40 'cursor',
41 'fill',
42 'filter',
43 'marker',
44 'marker-end',
45 'marker-mid',
46 'marker-start',
47 'mask',
48 'stroke',
49 'style',
50 })
51 _SVG_DIRECT_RESOURCE_ATTRIBUTES = frozenset({'poster', 'src'})
52 _SVG_DATA_URI_DEPTH_LIMIT = 8
53 _SVG_EXTERNAL_DOCTYPE_RE = re.compile(
54 br'<!DOCTYPE\b[^>]*\b(?:PUBLIC|SYSTEM)\b',
55 re.IGNORECASE | re.DOTALL,
56 )
57 _XML_STYLESHEET_HREF_RE = re.compile(
58 r'\bhref\s*=\s*([\'"])(.*?)\1',
59 re.IGNORECASE | re.DOTALL,
60 )
61
62
63 def project_root_for_svg_path(svg_path: Path) -> Path:
64 """Infer the project root from an SVG file path or SVG directory path."""
65 path = Path(svg_path)
66 base = path if path.is_dir() else path.parent
67 if (
68 base.name in SVG_WORK_DIR_NAMES
69 or base.name.startswith(SVG_FINAL_CANDIDATE_PREFIX)
70 ):
71 return base.parent
72 if (
73 base.name == TEMPLATE_SOURCE_DIR_NAME
74 and (base / TEMPLATE_SPEC_FILENAME).is_file()
75 ):
76 return base.parent
77 return base
78
79
80 def global_icons_dir() -> Path:
81 """Return the skill-level icon library directory."""
82 return Path(__file__).resolve().parent.parent / 'templates' / 'icons'
83
84
85 def icon_search_dirs_for_project(project_path: Path) -> tuple[Path, Path | None]:
86 """Return project-first icon dirs plus the global fallback when needed."""
87 global_dir = global_icons_dir()
88 project_icons_dir = Path(project_path) / 'icons'
89 if project_icons_dir.is_dir():
90 return project_icons_dir, global_dir
91 return global_dir, None
92
93
94 def icon_search_dirs_for_svg(svg_path: Path) -> tuple[Path, Path | None]:
95 """Return icon dirs for an SVG file path or SVG directory path."""
96 return icon_search_dirs_for_project(project_root_for_svg_path(svg_path))
97
98
99 def _decode_svg_data_uri(raw: str) -> tuple[bytes | None, str | None]:
100 """Decode an SVG data URI; return ``(None, None)`` for other media."""
101 value = raw.strip().strip('\'"')
102 if not value.lower().startswith('data:'):
103 return None, None
104 header, separator, payload = value.partition(',')
105 media_type = header[5:].split(';', 1)[0].strip().lower()
106 if media_type != 'image/svg+xml':
107 return None, None
108 if not separator:
109 return None, 'invalid embedded SVG data URI'
110 is_base64 = any(
111 token.strip().lower() == 'base64'
112 for token in header.split(';')[1:]
113 )
114 try:
115 decoded = (
116 base64.b64decode(payload, validate=True)
117 if is_base64
118 else unquote_to_bytes(payload)
119 )
120 except (ValueError, binascii.Error):
121 return None, 'invalid embedded SVG data URI'
122 return decoded, None
123
124
125 def _svg_reference_error(raw: str, depth: int) -> str | None:
126 """Return an external or recursively embedded SVG resource error."""
127 value = raw.strip().strip('\'"')
128 if not value:
129 return 'empty resource reference'
130 if value.startswith('#'):
131 return None
132 if not value.lower().startswith('data:'):
133 return f'unpackaged external resource {value!r}'
134
135 nested_svg, decode_error = _decode_svg_data_uri(value)
136 if decode_error is not None:
137 return decode_error
138 if nested_svg is None:
139 return None
140 if depth >= _SVG_DATA_URI_DEPTH_LIMIT:
141 return 'embedded SVG resource nesting exceeds the safety limit'
142 nested_error = _svg_image_payload_error(nested_svg, depth + 1)
143 if nested_error is None:
144 return None
145 return f'embedded SVG resource is not closed: {nested_error}'
146
147
148 def _xml_stylesheet_error(raw_bytes: bytes, depth: int) -> str | None:
149 """Return an external XML stylesheet processing-instruction error."""
150 parser = ET.XMLPullParser(events=('pi',))
151 try:
152 parser.feed(raw_bytes)
153 parser.close()
154 except ET.ParseError:
155 return None
156 for _event, instruction in parser.read_events():
157 text = (instruction.text or '').strip()
158 if not text.lower().startswith('xml-stylesheet'):
159 continue
160 match = _XML_STYLESHEET_HREF_RE.search(text)
161 if match is None:
162 return 'XML stylesheet processing instruction lacks href'
163 reference_error = _svg_reference_error(match.group(2), depth)
164 if reference_error is not None:
165 return f'XML stylesheet is not closed: {reference_error}'
166 return None
167
168
169 def _svg_image_payload_error(raw_bytes: bytes, depth: int) -> str | None:
170 """Return why one SVG image payload is not a closed packaged resource."""
171 if _SVG_EXTERNAL_DOCTYPE_RE.search(raw_bytes):
172 return 'unpackaged external XML doctype'
173 try:
174 root = ET.fromstring(raw_bytes)
175 except ET.ParseError as exc:
176 return f'invalid SVG XML: {exc}'
177 if root.tag != f'{{{_SVG_NAMESPACE}}}svg':
178 return 'root must use the SVG namespace'
179
180 stylesheet_error = _xml_stylesheet_error(raw_bytes, depth)
181 if stylesheet_error is not None:
182 return stylesheet_error
183 for elem in root.iter():
184 tag = str(elem.tag).rsplit('}', 1)[-1]
185 for raw_name, raw_value in elem.attrib.items():
186 name = raw_name.rsplit('}', 1)[-1]
187 if name == 'href' and tag != 'a':
188 reference_error = _svg_reference_error(raw_value, depth)
189 if reference_error is not None:
190 return reference_error
191 elif name in _SVG_DIRECT_RESOURCE_ATTRIBUTES:
192 reference_error = _svg_reference_error(raw_value, depth)
193 if reference_error is not None:
194 return reference_error
195 elif name == 'data' and tag == 'object':
196 reference_error = _svg_reference_error(raw_value, depth)
197 if reference_error is not None:
198 return reference_error
199 elif name == 'srcset' and raw_value.strip():
200 return 'srcset resource lists are not closed SVG resources'
201
202 if name not in _SVG_CSS_URL_ATTRIBUTES:
203 continue
204 for match in _SVG_URL_REFERENCE_RE.finditer(raw_value):
205 reference_error = _svg_reference_error(match.group(1), depth)
206 if reference_error is not None:
207 return f'url() resource is not closed: {reference_error}'
208
209 if tag != 'style':
210 continue
211 style_text = ''.join(elem.itertext())
212 if re.search(r'@import\b', style_text, flags=re.IGNORECASE):
213 return 'unpackaged CSS import'
214 for match in _SVG_URL_REFERENCE_RE.finditer(style_text):
215 reference_error = _svg_reference_error(match.group(1), depth)
216 if reference_error is not None:
217 return f'url() resource is not closed: {reference_error}'
218 return None
219
220
221 def svg_image_payload_error(raw_bytes: bytes) -> str | None:
222 """Return why a nested SVG image is not a closed packaged resource."""
223 return _svg_image_payload_error(raw_bytes, 0)
224
225
226 def svg_data_uri_payload_error(raw: str) -> str | None:
227 """Return why an inline SVG image data URI is not a closed resource."""
228 nested_svg, decode_error = _decode_svg_data_uri(raw)
229 if decode_error is not None:
230 return decode_error
231 if nested_svg is None:
232 return None
233 nested_error = _svg_image_payload_error(nested_svg, 0)
234 if nested_error is None:
235 return None
236 return f'inline SVG data URI is not closed: {nested_error}'
237
238
239 def external_image_reference_candidates(svg_dir: Path, href: str) -> list[Path]:
240 """Return candidate paths for a non-data-URI SVG image href."""
241 parsed = urlsplit(href)
242 if parsed.scheme and parsed.scheme not in {'file'}:
243 return []
244 decoded = unquote(
245 parsed.path
246 if parsed.scheme
247 else href.split('?', 1)[0].split('#', 1)[0]
248 )
249 svg_dir = Path(svg_dir)
250 project_root = project_root_for_svg_path(svg_dir).resolve()
251 candidates = [
252 svg_dir / decoded,
253 project_root / decoded,
254 project_root / 'images' / decoded,
255 project_root / 'templates' / decoded,
256 ]
257 safe_candidates: list[Path] = []
258 for candidate in candidates:
259 resolved = candidate.resolve()
260 try:
261 resolved.relative_to(project_root)
262 except ValueError:
263 continue
264 if resolved not in safe_candidates:
265 safe_candidates.append(resolved)
266 return safe_candidates
267
268
269 def resolve_external_image_reference(svg_dir: Path, href: str) -> Path | None:
270 """Resolve an SVG image href to an existing file, or return None."""
271 for candidate in external_image_reference_candidates(svg_dir, href):
272 if candidate.is_file():
273 return candidate
274 return None
275
276
277 def unresolved_external_image_reference_path(svg_dir: Path, href: str) -> Path:
278 """Return the first candidate path for diagnostics when resolution fails."""
279 candidates = external_image_reference_candidates(svg_dir, href)
280 if candidates:
281 return candidates[0].resolve()
282 return (Path(svg_dir) / href).resolve()
283
283 lines PYTHON