返回 ppt-master
preset_registry_to_svg.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / preset_registry_to_svg.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Preset Geometry SVG Adapter
4
5 Render evaluated DrawingML preset geometry as absolute SVG path layers.
6
7 Usage:
8 Import render_preset_geometry from pptx_to_svg.preset_registry_to_svg.
9
10 Examples:
11 geometry = render_preset_geometry("rightArrow", xfrm)
12
13 Dependencies:
14 None (only uses standard library and local PPT Master modules)
15 """
16
17 from __future__ import annotations
18
19 import math
20 from dataclasses import dataclass
21 from typing import Mapping
22
23 from pptx_shapes import get_preset_registry
24
25 from .emu_units import Xfrm, fmt_num
26
27
28 @dataclass(frozen=True)
29 class SvgPresetPath:
30 """One visible layer from a DrawingML preset's ``a:pathLst``."""
31
32 d: str
33 fill: str
34 stroke: bool
35
36
37 @dataclass(frozen=True)
38 class SvgPresetGeometry:
39 """A fully evaluated preset preview in slide-absolute SVG coordinates."""
40
41 paths: tuple[SvgPresetPath, ...]
42
43
44 def render_preset_geometry(
45 preset: str,
46 xfrm: Xfrm,
47 adjustments: Mapping[str, str | int | float] | None = None,
48 ) -> SvgPresetGeometry:
49 """Evaluate ``preset`` and project every DrawingML path into SVG space."""
50
51 evaluated = get_preset_registry().evaluate(
52 preset,
53 xfrm.w,
54 xfrm.h,
55 adjustments=adjustments,
56 )
57 layers = tuple(
58 SvgPresetPath(
59 d=render_evaluated_path(
60 path.commands,
61 x=xfrm.x,
62 y=xfrm.y,
63 width=xfrm.w,
64 height=xfrm.h,
65 coordinate_width=path.coordinate_width,
66 coordinate_height=path.coordinate_height,
67 ),
68 fill=path.fill,
69 stroke=path.stroke,
70 )
71 for path in evaluated.paths
72 )
73 return SvgPresetGeometry(paths=tuple(layer for layer in layers if layer.d))
74
75
76 def render_evaluated_path(
77 commands,
78 *,
79 x: float,
80 y: float,
81 width: float,
82 height: float,
83 coordinate_width: float,
84 coordinate_height: float,
85 ) -> str:
86 scale_x = width / coordinate_width if coordinate_width else 1.0
87 scale_y = height / coordinate_height if coordinate_height else 1.0
88
89 def point(px: float, py: float) -> tuple[float, float]:
90 return x + px * scale_x, y + py * scale_y
91
92 parts: list[str] = []
93 current = (x, y)
94 subpath_start = current
95 for command in commands:
96 values = command.parameters
97 if command.name == "moveTo":
98 current = point(values[0], values[1])
99 subpath_start = current
100 parts.append(f"M {fmt_num(current[0])} {fmt_num(current[1])}")
101 elif command.name == "lnTo":
102 current = point(values[0], values[1])
103 parts.append(f"L {fmt_num(current[0])} {fmt_num(current[1])}")
104 elif command.name == "quadBezTo":
105 control = point(values[0], values[1])
106 current = point(values[2], values[3])
107 parts.append(
108 "Q "
109 f"{fmt_num(control[0])} {fmt_num(control[1])} "
110 f"{fmt_num(current[0])} {fmt_num(current[1])}"
111 )
112 elif command.name == "cubicBezTo":
113 control_1 = point(values[0], values[1])
114 control_2 = point(values[2], values[3])
115 current = point(values[4], values[5])
116 parts.append(
117 "C "
118 f"{fmt_num(control_1[0])} {fmt_num(control_1[1])} "
119 f"{fmt_num(control_2[0])} {fmt_num(control_2[1])} "
120 f"{fmt_num(current[0])} {fmt_num(current[1])}"
121 )
122 elif command.name == "arcTo":
123 arc_parts, current = _render_arc(
124 current,
125 radius_x=values[0],
126 radius_y=values[1],
127 scale_x=scale_x,
128 scale_y=scale_y,
129 start_angle=values[2],
130 sweep_angle=values[3],
131 )
132 parts.extend(arc_parts)
133 elif command.name == "close":
134 parts.append("Z")
135 current = subpath_start
136 return " ".join(parts)
137
138
139 def _render_arc(
140 current: tuple[float, float],
141 *,
142 radius_x: float,
143 radius_y: float,
144 scale_x: float = 1.0,
145 scale_y: float = 1.0,
146 start_angle: float,
147 sweep_angle: float,
148 ) -> tuple[list[str], tuple[float, float]]:
149 """Render one DrawingML arc, splitting full circles for SVG validity.
150
151 DrawingML resolves the polar angle in the path-local ellipse before the
152 path coordinate system is scaled into the shape frame. Applying the
153 angle correction to already-scaled radii bends explicit-extent paths such
154 as ``cloud`` when the containing shape has a non-square aspect ratio.
155 """
156
157 radius_x = abs(radius_x)
158 radius_y = abs(radius_y)
159 scaled_radius_x = abs(radius_x * scale_x)
160 scaled_radius_y = abs(radius_y * scale_y)
161 if (
162 radius_x <= 1e-12
163 or radius_y <= 1e-12
164 or scaled_radius_x <= 1e-12
165 or scaled_radius_y <= 1e-12
166 or abs(sweep_angle) <= 1e-12
167 ):
168 return [], current
169
170 start_radians = _ellipse_parameter_angle(
171 start_angle,
172 radius_x,
173 radius_y,
174 )
175 center_x = current[0] - scaled_radius_x * math.cos(start_radians)
176 center_y = current[1] - scaled_radius_y * math.sin(start_radians)
177
178 # SVG cannot represent a 360-degree arc with one A command because its
179 # start and end points coincide. Chunks of at most 180 degrees also keep
180 # the large-arc flag deterministic for every preset definition.
181 remaining = sweep_angle
182 angle = start_angle
183 parts: list[str] = []
184 endpoint = current
185 half_circle = 180.0 * 60000.0
186 while abs(remaining) > 1e-9:
187 step = math.copysign(min(abs(remaining), half_circle), remaining)
188 angle += step
189 end_radians = _ellipse_parameter_angle(angle, radius_x, radius_y)
190 endpoint = (
191 center_x + scaled_radius_x * math.cos(end_radians),
192 center_y + scaled_radius_y * math.sin(end_radians),
193 )
194 large_arc = 1 if abs(step) > half_circle else 0
195 sweep = 1 if step >= 0 else 0
196 parts.append(
197 "A "
198 f"{fmt_num(scaled_radius_x)} {fmt_num(scaled_radius_y)} "
199 f"0 {large_arc} {sweep} "
200 f"{fmt_num(endpoint[0])} {fmt_num(endpoint[1])}"
201 )
202 remaining -= step
203 return parts, endpoint
204
205
206 def _ellipse_parameter_angle(
207 ooxml_angle: float,
208 radius_x: float,
209 radius_y: float,
210 ) -> float:
211 """Unskew an OOXML polar angle into an ellipse parameter angle."""
212 radians = math.radians(ooxml_angle / 60000.0)
213 return math.atan2(
214 radius_x * math.sin(radians),
215 radius_y * math.cos(radians),
216 )
217
217 lines PYTHON