Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user