#!/usr/bin/env python3 """ PPT Master - Shared SVG Resource Helpers Centralizes project-relative resource lookup and nested-SVG closure validation used by the checker, finalizer, and SVG-to-PPTX exporter. Usage: Imported by scripts; not intended as a standalone CLI. Examples: from resource_paths import resolve_external_image_reference from resource_paths import svg_image_payload_error Dependencies: None """ from __future__ import annotations import base64 import binascii import re from pathlib import Path from urllib.parse import unquote, unquote_to_bytes, urlsplit from xml.etree import ElementTree as ET SVG_WORK_DIR_NAMES = frozenset({'svg_output', 'svg_final', 'svg-flat', 'svg_flat'}) SVG_FINAL_CANDIDATE_PREFIX = '.svg_final.candidate-' TEMPLATE_SOURCE_DIR_NAME = 'templates' TEMPLATE_SPEC_FILENAME = 'design_spec.md' _SVG_NAMESPACE = 'http://www.w3.org/2000/svg' _SVG_URL_REFERENCE_RE = re.compile(r'url\(\s*([^)]+?)\s*\)', re.IGNORECASE) _SVG_CSS_URL_ATTRIBUTES = frozenset({ 'background', 'background-image', 'clip-path', 'color-profile', 'cursor', 'fill', 'filter', 'marker', 'marker-end', 'marker-mid', 'marker-start', 'mask', 'stroke', 'style', }) _SVG_DIRECT_RESOURCE_ATTRIBUTES = frozenset({'poster', 'src'}) _SVG_DATA_URI_DEPTH_LIMIT = 8 _SVG_EXTERNAL_DOCTYPE_RE = re.compile( br']*\b(?:PUBLIC|SYSTEM)\b', re.IGNORECASE | re.DOTALL, ) _XML_STYLESHEET_HREF_RE = re.compile( r'\bhref\s*=\s*([\'"])(.*?)\1', re.IGNORECASE | re.DOTALL, ) def project_root_for_svg_path(svg_path: Path) -> Path: """Infer the project root from an SVG file path or SVG directory path.""" path = Path(svg_path) base = path if path.is_dir() else path.parent if ( base.name in SVG_WORK_DIR_NAMES or base.name.startswith(SVG_FINAL_CANDIDATE_PREFIX) ): return base.parent if ( base.name == TEMPLATE_SOURCE_DIR_NAME and (base / TEMPLATE_SPEC_FILENAME).is_file() ): return base.parent return base def global_icons_dir() -> Path: """Return the skill-level icon library directory.""" return Path(__file__).resolve().parent.parent / 'templates' / 'icons' def icon_search_dirs_for_project(project_path: Path) -> tuple[Path, Path | None]: """Return project-first icon dirs plus the global fallback when needed.""" global_dir = global_icons_dir() project_icons_dir = Path(project_path) / 'icons' if project_icons_dir.is_dir(): return project_icons_dir, global_dir return global_dir, None def icon_search_dirs_for_svg(svg_path: Path) -> tuple[Path, Path | None]: """Return icon dirs for an SVG file path or SVG directory path.""" return icon_search_dirs_for_project(project_root_for_svg_path(svg_path)) def _decode_svg_data_uri(raw: str) -> tuple[bytes | None, str | None]: """Decode an SVG data URI; return ``(None, None)`` for other media.""" value = raw.strip().strip('\'"') if not value.lower().startswith('data:'): return None, None header, separator, payload = value.partition(',') media_type = header[5:].split(';', 1)[0].strip().lower() if media_type != 'image/svg+xml': return None, None if not separator: return None, 'invalid embedded SVG data URI' is_base64 = any( token.strip().lower() == 'base64' for token in header.split(';')[1:] ) try: decoded = ( base64.b64decode(payload, validate=True) if is_base64 else unquote_to_bytes(payload) ) except (ValueError, binascii.Error): return None, 'invalid embedded SVG data URI' return decoded, None def _svg_reference_error(raw: str, depth: int) -> str | None: """Return an external or recursively embedded SVG resource error.""" value = raw.strip().strip('\'"') if not value: return 'empty resource reference' if value.startswith('#'): return None if not value.lower().startswith('data:'): return f'unpackaged external resource {value!r}' nested_svg, decode_error = _decode_svg_data_uri(value) if decode_error is not None: return decode_error if nested_svg is None: return None if depth >= _SVG_DATA_URI_DEPTH_LIMIT: return 'embedded SVG resource nesting exceeds the safety limit' nested_error = _svg_image_payload_error(nested_svg, depth + 1) if nested_error is None: return None return f'embedded SVG resource is not closed: {nested_error}' def _xml_stylesheet_error(raw_bytes: bytes, depth: int) -> str | None: """Return an external XML stylesheet processing-instruction error.""" parser = ET.XMLPullParser(events=('pi',)) try: parser.feed(raw_bytes) parser.close() except ET.ParseError: return None for _event, instruction in parser.read_events(): text = (instruction.text or '').strip() if not text.lower().startswith('xml-stylesheet'): continue match = _XML_STYLESHEET_HREF_RE.search(text) if match is None: return 'XML stylesheet processing instruction lacks href' reference_error = _svg_reference_error(match.group(2), depth) if reference_error is not None: return f'XML stylesheet is not closed: {reference_error}' return None def _svg_image_payload_error(raw_bytes: bytes, depth: int) -> str | None: """Return why one SVG image payload is not a closed packaged resource.""" if _SVG_EXTERNAL_DOCTYPE_RE.search(raw_bytes): return 'unpackaged external XML doctype' try: root = ET.fromstring(raw_bytes) except ET.ParseError as exc: return f'invalid SVG XML: {exc}' if root.tag != f'{{{_SVG_NAMESPACE}}}svg': return 'root must use the SVG namespace' stylesheet_error = _xml_stylesheet_error(raw_bytes, depth) if stylesheet_error is not None: return stylesheet_error for elem in root.iter(): tag = str(elem.tag).rsplit('}', 1)[-1] for raw_name, raw_value in elem.attrib.items(): name = raw_name.rsplit('}', 1)[-1] if name == 'href' and tag != 'a': reference_error = _svg_reference_error(raw_value, depth) if reference_error is not None: return reference_error elif name in _SVG_DIRECT_RESOURCE_ATTRIBUTES: reference_error = _svg_reference_error(raw_value, depth) if reference_error is not None: return reference_error elif name == 'data' and tag == 'object': reference_error = _svg_reference_error(raw_value, depth) if reference_error is not None: return reference_error elif name == 'srcset' and raw_value.strip(): return 'srcset resource lists are not closed SVG resources' if name not in _SVG_CSS_URL_ATTRIBUTES: continue for match in _SVG_URL_REFERENCE_RE.finditer(raw_value): reference_error = _svg_reference_error(match.group(1), depth) if reference_error is not None: return f'url() resource is not closed: {reference_error}' if tag != 'style': continue style_text = ''.join(elem.itertext()) if re.search(r'@import\b', style_text, flags=re.IGNORECASE): return 'unpackaged CSS import' for match in _SVG_URL_REFERENCE_RE.finditer(style_text): reference_error = _svg_reference_error(match.group(1), depth) if reference_error is not None: return f'url() resource is not closed: {reference_error}' return None def svg_image_payload_error(raw_bytes: bytes) -> str | None: """Return why a nested SVG image is not a closed packaged resource.""" return _svg_image_payload_error(raw_bytes, 0) def svg_data_uri_payload_error(raw: str) -> str | None: """Return why an inline SVG image data URI is not a closed resource.""" nested_svg, decode_error = _decode_svg_data_uri(raw) if decode_error is not None: return decode_error if nested_svg is None: return None nested_error = _svg_image_payload_error(nested_svg, 0) if nested_error is None: return None return f'inline SVG data URI is not closed: {nested_error}' def external_image_reference_candidates(svg_dir: Path, href: str) -> list[Path]: """Return candidate paths for a non-data-URI SVG image href.""" parsed = urlsplit(href) if parsed.scheme and parsed.scheme not in {'file'}: return [] decoded = unquote( parsed.path if parsed.scheme else href.split('?', 1)[0].split('#', 1)[0] ) svg_dir = Path(svg_dir) project_root = project_root_for_svg_path(svg_dir).resolve() candidates = [ svg_dir / decoded, project_root / decoded, project_root / 'images' / decoded, project_root / 'templates' / decoded, ] safe_candidates: list[Path] = [] for candidate in candidates: resolved = candidate.resolve() try: resolved.relative_to(project_root) except ValueError: continue if resolved not in safe_candidates: safe_candidates.append(resolved) return safe_candidates def resolve_external_image_reference(svg_dir: Path, href: str) -> Path | None: """Resolve an SVG image href to an existing file, or return None.""" for candidate in external_image_reference_candidates(svg_dir, href): if candidate.is_file(): return candidate return None def unresolved_external_image_reference_path(svg_dir: Path, href: str) -> Path: """Return the first candidate path for diagnostics when resolution fails.""" candidates = external_image_reference_candidates(svg_dir, href) if candidates: return candidates[0].resolve() return (Path(svg_dir) / href).resolve()