| 1 | """Project root-SVG canvas parsing and cross-file validation.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import re |
| 6 | from dataclasses import dataclass |
| 7 | from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, localcontext |
| 8 | from pathlib import Path |
| 9 | from typing import Iterable |
| 10 | from xml.etree import ElementTree as ET |
| 11 | |
| 12 | |
| 13 | class CanvasContractError(ValueError): |
| 14 | """Raised when a root SVG canvas cannot map faithfully to one slide.""" |
| 15 | |
| 16 | |
| 17 | PPTX_SLIDE_EMU_MIN = 914400 |
| 18 | PPTX_SLIDE_EMU_MAX = 51206400 |
| 19 | |
| 20 | |
| 21 | _SVG_NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?" |
| 22 | _VIEWBOX_RE = re.compile( |
| 23 | rf"^\s*({_SVG_NUMBER})(?:\s*,\s*|\s+)" |
| 24 | rf"({_SVG_NUMBER})(?:\s*,\s*|\s+)" |
| 25 | rf"({_SVG_NUMBER})(?:\s*,\s*|\s+)" |
| 26 | rf"({_SVG_NUMBER})\s*$" |
| 27 | ) |
| 28 | |
| 29 | |
| 30 | @dataclass(frozen=True) |
| 31 | class ProjectViewBox: |
| 32 | """One validated zero-origin project canvas.""" |
| 33 | |
| 34 | width: Decimal |
| 35 | height: Decimal |
| 36 | |
| 37 | @property |
| 38 | def values(self) -> tuple[Decimal, Decimal, Decimal, Decimal]: |
| 39 | return Decimal(0), Decimal(0), self.width, self.height |
| 40 | |
| 41 | @property |
| 42 | def pixel_dimensions(self) -> tuple[float, float]: |
| 43 | return float(self.width), float(self.height) |
| 44 | |
| 45 | @property |
| 46 | def emu_dimensions(self) -> tuple[int, int]: |
| 47 | """Quantize the SVG canvas to DrawingML's integer EMU dimensions.""" |
| 48 | width_emu, height_emu = self._scaled_emu_values() |
| 49 | width = int(width_emu.to_integral_value(ROUND_HALF_UP)) |
| 50 | height = int(height_emu.to_integral_value(ROUND_HALF_UP)) |
| 51 | return width, height |
| 52 | |
| 53 | def _scaled_emu_values(self) -> tuple[Decimal, Decimal]: |
| 54 | precision = max( |
| 55 | 28, |
| 56 | len(self.width.as_tuple().digits) + 8, |
| 57 | len(self.height.as_tuple().digits) + 8, |
| 58 | ) |
| 59 | with localcontext() as context: |
| 60 | context.prec = precision |
| 61 | return self.width * Decimal(9525), self.height * Decimal(9525) |
| 62 | |
| 63 | @property |
| 64 | def canonical(self) -> str: |
| 65 | return f"0 0 {_format_decimal(self.width)} {_format_decimal(self.height)}" |
| 66 | |
| 67 | @property |
| 68 | def has_integer_dimensions(self) -> bool: |
| 69 | return ( |
| 70 | self.width == self.width.to_integral_value() |
| 71 | and self.height == self.height.to_integral_value() |
| 72 | ) |
| 73 | |
| 74 | |
| 75 | def _format_decimal(value: Decimal) -> str: |
| 76 | token = format(value, "f") |
| 77 | if "." in token: |
| 78 | token = token.rstrip("0").rstrip(".") |
| 79 | return token or "0" |
| 80 | |
| 81 | |
| 82 | def parse_project_viewbox( |
| 83 | raw: str | None, |
| 84 | *, |
| 85 | context: str = "root viewBox", |
| 86 | ) -> ProjectViewBox: |
| 87 | """Parse the registered project root-viewBox subset. |
| 88 | |
| 89 | Equivalent SVG numeric spellings are accepted so callers can distinguish |
| 90 | compatible input from canonical authoring. Semantic values stay closed: |
| 91 | the origin is exactly zero and dimensions map to positive DrawingML sizes. |
| 92 | Fractional dimensions remain read-compatible because imported custom PPTX |
| 93 | slide sizes are not necessarily whole CSS pixels. |
| 94 | """ |
| 95 | if raw is None or not raw.strip(): |
| 96 | raise CanvasContractError(f"{context} is required") |
| 97 | |
| 98 | match = _VIEWBOX_RE.fullmatch(raw) |
| 99 | if match is None: |
| 100 | raise CanvasContractError( |
| 101 | f"{context} must contain exactly four SVG numbers; got {raw!r}" |
| 102 | ) |
| 103 | |
| 104 | try: |
| 105 | values = tuple(Decimal(token) for token in match.groups()) |
| 106 | except InvalidOperation as exc: |
| 107 | raise CanvasContractError( |
| 108 | f"{context} contains an invalid numeric value; got {raw!r}" |
| 109 | ) from exc |
| 110 | if not all(value.is_finite() for value in values): |
| 111 | raise CanvasContractError( |
| 112 | f"{context} values must be finite; got {raw!r}" |
| 113 | ) |
| 114 | |
| 115 | x, y, width, height = values |
| 116 | if x != 0 or y != 0: |
| 117 | raise CanvasContractError( |
| 118 | f'{context} origin must be "0 0"; got {raw!r}' |
| 119 | ) |
| 120 | if width <= 0 or height <= 0: |
| 121 | raise CanvasContractError( |
| 122 | f"{context} width and height must be positive; got {raw!r}" |
| 123 | ) |
| 124 | viewbox = ProjectViewBox(width=width, height=height) |
| 125 | require_powerpoint_slide_size(viewbox, context=context) |
| 126 | return viewbox |
| 127 | |
| 128 | |
| 129 | def read_project_viewbox(svg_path: str | Path) -> ProjectViewBox: |
| 130 | """Read and validate one file's root SVG viewBox.""" |
| 131 | path = Path(svg_path) |
| 132 | try: |
| 133 | root = ET.parse(path).getroot() |
| 134 | except (OSError, ET.ParseError) as exc: |
| 135 | raise CanvasContractError(f"{path.name}: unable to parse root SVG: {exc}") from exc |
| 136 | return parse_project_svg_root( |
| 137 | root, |
| 138 | context=path.name, |
| 139 | ) |
| 140 | |
| 141 | |
| 142 | def parse_project_svg_root( |
| 143 | root: ET.Element, |
| 144 | *, |
| 145 | context: str = "document", |
| 146 | ) -> ProjectViewBox: |
| 147 | """Validate one page root element and its project viewBox.""" |
| 148 | if root.tag.rsplit("}", 1)[-1] != "svg": |
| 149 | raise CanvasContractError(f"{context}: root element must be <svg>") |
| 150 | return parse_project_viewbox( |
| 151 | root.get("viewBox"), |
| 152 | context=f"{context} root viewBox", |
| 153 | ) |
| 154 | |
| 155 | |
| 156 | def require_powerpoint_slide_size( |
| 157 | viewbox: ProjectViewBox, |
| 158 | *, |
| 159 | context: str = "SVG canvas", |
| 160 | ) -> tuple[int, int]: |
| 161 | """Return EMU dimensions or reject values outside PowerPoint's range.""" |
| 162 | # Reject before converting to int so an adversarial exponent cannot force |
| 163 | # construction of an enormous Python integer or fixed-point string. |
| 164 | values = viewbox.width, viewbox.height |
| 165 | outside_coarse_bound = any( |
| 166 | value > Decimal(PPTX_SLIDE_EMU_MAX) for value in values |
| 167 | ) |
| 168 | scaled = () if outside_coarse_bound else viewbox._scaled_emu_values() |
| 169 | outside_emu_bound = outside_coarse_bound or not all( |
| 170 | Decimal(PPTX_SLIDE_EMU_MIN) - Decimal("0.5") |
| 171 | <= value |
| 172 | < Decimal(PPTX_SLIDE_EMU_MAX) + Decimal("0.5") |
| 173 | for value in scaled |
| 174 | ) |
| 175 | if outside_emu_bound: |
| 176 | dimensions = f"{viewbox.width} x {viewbox.height} px" |
| 177 | raise CanvasContractError( |
| 178 | f"{context} must map to PowerPoint's supported slide range " |
| 179 | f"({PPTX_SLIDE_EMU_MIN}..{PPTX_SLIDE_EMU_MAX} EMU per side); " |
| 180 | f"got {dimensions}" |
| 181 | ) |
| 182 | return viewbox.emu_dimensions |
| 183 | |
| 184 | |
| 185 | def require_consistent_project_viewboxes( |
| 186 | svg_paths: Iterable[str | Path], |
| 187 | *, |
| 188 | expected_viewbox: str | None = None, |
| 189 | expected_label: str = "expected canvas", |
| 190 | ) -> ProjectViewBox: |
| 191 | """Validate every public/internal SVG and return their shared canvas.""" |
| 192 | paths = [Path(path) for path in svg_paths] |
| 193 | if not paths: |
| 194 | raise CanvasContractError("at least one SVG is required to resolve the canvas") |
| 195 | |
| 196 | errors: list[str] = [] |
| 197 | parsed: list[tuple[Path, ProjectViewBox]] = [] |
| 198 | for path in paths: |
| 199 | try: |
| 200 | parsed.append((path, read_project_viewbox(path))) |
| 201 | except CanvasContractError as exc: |
| 202 | errors.append(str(exc)) |
| 203 | if errors: |
| 204 | details = "\n".join(f" - {error}" for error in errors) |
| 205 | raise CanvasContractError("SVG canvas validation failed:\n" + details) |
| 206 | |
| 207 | if expected_viewbox is not None: |
| 208 | reference = parse_project_viewbox( |
| 209 | expected_viewbox, |
| 210 | context=f"{expected_label} viewBox", |
| 211 | ) |
| 212 | else: |
| 213 | reference = parsed[0][1] |
| 214 | |
| 215 | mismatches = [ |
| 216 | f"{path.name}: expected {reference.canonical}, got {viewbox.canonical}" |
| 217 | for path, viewbox in parsed |
| 218 | if viewbox != reference |
| 219 | ] |
| 220 | if mismatches: |
| 221 | details = "\n".join(f" - {message}" for message in mismatches) |
| 222 | raise CanvasContractError( |
| 223 | f"SVG canvases must all match {expected_label} " |
| 224 | f"({reference.canonical}):\n{details}" |
| 225 | ) |
| 226 | return reference |
| 227 |