Files
Templated-PPT-Master/runtime/ppt-master/scripts/svg_to_pptx/canvas_contract.py
T

227 lines
7.3 KiB
Python

"""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