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