Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""Project root-SVG canvas parsing and cross-file validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, localcontext
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
class CanvasContractError(ValueError):
|
||||
"""Raised when a root SVG canvas cannot map faithfully to one slide."""
|
||||
|
||||
|
||||
PPTX_SLIDE_EMU_MIN = 914400
|
||||
PPTX_SLIDE_EMU_MAX = 51206400
|
||||
|
||||
|
||||
_SVG_NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
|
||||
_VIEWBOX_RE = re.compile(
|
||||
rf"^\s*({_SVG_NUMBER})(?:\s*,\s*|\s+)"
|
||||
rf"({_SVG_NUMBER})(?:\s*,\s*|\s+)"
|
||||
rf"({_SVG_NUMBER})(?:\s*,\s*|\s+)"
|
||||
rf"({_SVG_NUMBER})\s*$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectViewBox:
|
||||
"""One validated zero-origin project canvas."""
|
||||
|
||||
width: Decimal
|
||||
height: Decimal
|
||||
|
||||
@property
|
||||
def values(self) -> tuple[Decimal, Decimal, Decimal, Decimal]:
|
||||
return Decimal(0), Decimal(0), self.width, self.height
|
||||
|
||||
@property
|
||||
def pixel_dimensions(self) -> tuple[float, float]:
|
||||
return float(self.width), float(self.height)
|
||||
|
||||
@property
|
||||
def emu_dimensions(self) -> tuple[int, int]:
|
||||
"""Quantize the SVG canvas to DrawingML's integer EMU dimensions."""
|
||||
width_emu, height_emu = self._scaled_emu_values()
|
||||
width = int(width_emu.to_integral_value(ROUND_HALF_UP))
|
||||
height = int(height_emu.to_integral_value(ROUND_HALF_UP))
|
||||
return width, height
|
||||
|
||||
def _scaled_emu_values(self) -> tuple[Decimal, Decimal]:
|
||||
precision = max(
|
||||
28,
|
||||
len(self.width.as_tuple().digits) + 8,
|
||||
len(self.height.as_tuple().digits) + 8,
|
||||
)
|
||||
with localcontext() as context:
|
||||
context.prec = precision
|
||||
return self.width * Decimal(9525), self.height * Decimal(9525)
|
||||
|
||||
@property
|
||||
def canonical(self) -> str:
|
||||
return f"0 0 {_format_decimal(self.width)} {_format_decimal(self.height)}"
|
||||
|
||||
@property
|
||||
def has_integer_dimensions(self) -> bool:
|
||||
return (
|
||||
self.width == self.width.to_integral_value()
|
||||
and self.height == self.height.to_integral_value()
|
||||
)
|
||||
|
||||
|
||||
def _format_decimal(value: Decimal) -> str:
|
||||
token = format(value, "f")
|
||||
if "." in token:
|
||||
token = token.rstrip("0").rstrip(".")
|
||||
return token or "0"
|
||||
|
||||
|
||||
def parse_project_viewbox(
|
||||
raw: str | None,
|
||||
*,
|
||||
context: str = "root viewBox",
|
||||
) -> ProjectViewBox:
|
||||
"""Parse the registered project root-viewBox subset.
|
||||
|
||||
Equivalent SVG numeric spellings are accepted so callers can distinguish
|
||||
compatible input from canonical authoring. Semantic values stay closed:
|
||||
the origin is exactly zero and dimensions map to positive DrawingML sizes.
|
||||
Fractional dimensions remain read-compatible because imported custom PPTX
|
||||
slide sizes are not necessarily whole CSS pixels.
|
||||
"""
|
||||
if raw is None or not raw.strip():
|
||||
raise CanvasContractError(f"{context} is required")
|
||||
|
||||
match = _VIEWBOX_RE.fullmatch(raw)
|
||||
if match is None:
|
||||
raise CanvasContractError(
|
||||
f"{context} must contain exactly four SVG numbers; got {raw!r}"
|
||||
)
|
||||
|
||||
try:
|
||||
values = tuple(Decimal(token) for token in match.groups())
|
||||
except InvalidOperation as exc:
|
||||
raise CanvasContractError(
|
||||
f"{context} contains an invalid numeric value; got {raw!r}"
|
||||
) from exc
|
||||
if not all(value.is_finite() for value in values):
|
||||
raise CanvasContractError(
|
||||
f"{context} values must be finite; got {raw!r}"
|
||||
)
|
||||
|
||||
x, y, width, height = values
|
||||
if x != 0 or y != 0:
|
||||
raise CanvasContractError(
|
||||
f'{context} origin must be "0 0"; got {raw!r}'
|
||||
)
|
||||
if width <= 0 or height <= 0:
|
||||
raise CanvasContractError(
|
||||
f"{context} width and height must be positive; got {raw!r}"
|
||||
)
|
||||
viewbox = ProjectViewBox(width=width, height=height)
|
||||
require_powerpoint_slide_size(viewbox, context=context)
|
||||
return viewbox
|
||||
|
||||
|
||||
def read_project_viewbox(svg_path: str | Path) -> ProjectViewBox:
|
||||
"""Read and validate one file's root SVG viewBox."""
|
||||
path = Path(svg_path)
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
except (OSError, ET.ParseError) as exc:
|
||||
raise CanvasContractError(f"{path.name}: unable to parse root SVG: {exc}") from exc
|
||||
return parse_project_svg_root(
|
||||
root,
|
||||
context=path.name,
|
||||
)
|
||||
|
||||
|
||||
def parse_project_svg_root(
|
||||
root: ET.Element,
|
||||
*,
|
||||
context: str = "document",
|
||||
) -> ProjectViewBox:
|
||||
"""Validate one page root element and its project viewBox."""
|
||||
if root.tag.rsplit("}", 1)[-1] != "svg":
|
||||
raise CanvasContractError(f"{context}: root element must be <svg>")
|
||||
return parse_project_viewbox(
|
||||
root.get("viewBox"),
|
||||
context=f"{context} root viewBox",
|
||||
)
|
||||
|
||||
|
||||
def require_powerpoint_slide_size(
|
||||
viewbox: ProjectViewBox,
|
||||
*,
|
||||
context: str = "SVG canvas",
|
||||
) -> tuple[int, int]:
|
||||
"""Return EMU dimensions or reject values outside PowerPoint's range."""
|
||||
# Reject before converting to int so an adversarial exponent cannot force
|
||||
# construction of an enormous Python integer or fixed-point string.
|
||||
values = viewbox.width, viewbox.height
|
||||
outside_coarse_bound = any(
|
||||
value > Decimal(PPTX_SLIDE_EMU_MAX) for value in values
|
||||
)
|
||||
scaled = () if outside_coarse_bound else viewbox._scaled_emu_values()
|
||||
outside_emu_bound = outside_coarse_bound or not all(
|
||||
Decimal(PPTX_SLIDE_EMU_MIN) - Decimal("0.5")
|
||||
<= value
|
||||
< Decimal(PPTX_SLIDE_EMU_MAX) + Decimal("0.5")
|
||||
for value in scaled
|
||||
)
|
||||
if outside_emu_bound:
|
||||
dimensions = f"{viewbox.width} x {viewbox.height} px"
|
||||
raise CanvasContractError(
|
||||
f"{context} must map to PowerPoint's supported slide range "
|
||||
f"({PPTX_SLIDE_EMU_MIN}..{PPTX_SLIDE_EMU_MAX} EMU per side); "
|
||||
f"got {dimensions}"
|
||||
)
|
||||
return viewbox.emu_dimensions
|
||||
|
||||
|
||||
def require_consistent_project_viewboxes(
|
||||
svg_paths: Iterable[str | Path],
|
||||
*,
|
||||
expected_viewbox: str | None = None,
|
||||
expected_label: str = "expected canvas",
|
||||
) -> ProjectViewBox:
|
||||
"""Validate every public/internal SVG and return their shared canvas."""
|
||||
paths = [Path(path) for path in svg_paths]
|
||||
if not paths:
|
||||
raise CanvasContractError("at least one SVG is required to resolve the canvas")
|
||||
|
||||
errors: list[str] = []
|
||||
parsed: list[tuple[Path, ProjectViewBox]] = []
|
||||
for path in paths:
|
||||
try:
|
||||
parsed.append((path, read_project_viewbox(path)))
|
||||
except CanvasContractError as exc:
|
||||
errors.append(str(exc))
|
||||
if errors:
|
||||
details = "\n".join(f" - {error}" for error in errors)
|
||||
raise CanvasContractError("SVG canvas validation failed:\n" + details)
|
||||
|
||||
if expected_viewbox is not None:
|
||||
reference = parse_project_viewbox(
|
||||
expected_viewbox,
|
||||
context=f"{expected_label} viewBox",
|
||||
)
|
||||
else:
|
||||
reference = parsed[0][1]
|
||||
|
||||
mismatches = [
|
||||
f"{path.name}: expected {reference.canonical}, got {viewbox.canonical}"
|
||||
for path, viewbox in parsed
|
||||
if viewbox != reference
|
||||
]
|
||||
if mismatches:
|
||||
details = "\n".join(f" - {message}" for message in mismatches)
|
||||
raise CanvasContractError(
|
||||
f"SVG canvases must all match {expected_label} "
|
||||
f"({reference.canonical}):\n{details}"
|
||||
)
|
||||
return reference
|
||||
Reference in New Issue
Block a user