返回 ppt-master
canvas_contract.py
根目录 / skills / ppt-master / scripts / svg_to_pptx / canvas_contract.py
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
227 lines PYTHON