283 lines
9.8 KiB
Python
283 lines
9.8 KiB
Python
#!/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'<!DOCTYPE\b[^>]*\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()
|