返回 ppt-master
ln_to_svg.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / ln_to_svg.py
1 """DrawingML <a:ln> -> SVG stroke conversion.
2
3 Reverse of svg_to_pptx/drawingml/styles.py build_stroke_xml.
4
5 Produces an SVG attribute dict with stroke / stroke-width / stroke-opacity /
6 stroke-dasharray / stroke-linecap / stroke-linejoin / marker-start /
7 marker-end (markers also need a <defs> entry which is returned alongside).
8 """
9
10 from __future__ import annotations
11
12 from dataclasses import dataclass, field
13 from xml.etree import ElementTree as ET
14
15 from pptx_shapes.formula import validate_ooxml_line_width
16
17 from .color_resolver import (
18 ColorPalette,
19 find_color_elem,
20 resolve_color,
21 resolve_solid_fill_color,
22 validate_no_fill,
23 )
24 from .emu_units import NS, emu_to_px, fmt_num, format_ooxml_alpha
25
26
27 @dataclass
28 class StrokeResult:
29 """Resolved stroke: SVG attributes to apply + optional <defs> for markers."""
30
31 attrs: dict[str, str] = field(default_factory=dict)
32 defs: list[str] = field(default_factory=list)
33
34
35 # Reverse of svg_to_pptx DASH_PRESETS (preset name -> dasharray).
36 PRST_DASH_TO_ARRAY = {
37 "solid": None, # no dasharray
38 "dot": "1 3",
39 "dash": "4 4",
40 "lgDash": "8 4",
41 "dashDot": "4 4 1 4",
42 "lgDashDot": "8 4 2 4",
43 "lgDashDotDot": "8 4 2 4 2 4",
44 "sysDash": "3 3",
45 "sysDot": "1 3",
46 "sysDashDot": "3 3 1 3",
47 "sysDashDotDot": "3 3 1 3 1 3",
48 }
49 _OOXML_INT_MAX = 2**31 - 1
50 _LINE_PAINT_TAGS = {
51 f"{{{NS['a']}}}{name}": name
52 for name in (
53 "noFill",
54 "solidFill",
55 "gradFill",
56 "pattFill",
57 "blipFill",
58 "grpFill",
59 )
60 }
61
62 # DrawingML cap -> SVG stroke-linecap
63 CAP_MAP = {
64 "rnd": "round",
65 "sq": "square",
66 "flat": "butt",
67 }
68
69
70 def resolve_stroke(
71 sp_pr: ET.Element | None,
72 palette: ColorPalette | None,
73 *,
74 id_prefix: str = "m",
75 id_seq: list[int] | None = None,
76 style_stroke_default: str | None = None,
77 ) -> StrokeResult:
78 """Resolve <a:ln> child of <p:spPr>.
79
80 Returns:
81 StrokeResult.attrs is empty if no <a:ln> present (caller falls back to
82 the spec default). If <a:ln> exists with <a:noFill/>, attrs has
83 stroke="none".
84 """
85 if sp_pr is None:
86 return StrokeResult()
87
88 ln = sp_pr.find("a:ln", NS)
89 if ln is None:
90 return StrokeResult()
91
92 attrs: dict[str, str] = {}
93 defs: list[str] = []
94
95 compound = ln.attrib.get("cmpd")
96 if compound not in {None, "sng"}:
97 raise ValueError(
98 f"Unsupported DrawingML compound line: {compound!r}"
99 )
100 alignment = ln.attrib.get("algn")
101 if alignment not in {None, "ctr"}:
102 raise ValueError(
103 f"Unsupported DrawingML line alignment: {alignment!r}"
104 )
105
106 # Width (a:ln@w in EMU)
107 width_emu = ln.attrib.get("w")
108 if width_emu is not None:
109 try:
110 width_value = int(width_emu)
111 except (ValueError, TypeError):
112 raise ValueError(
113 f"Invalid DrawingML line width: {width_emu!r}"
114 ) from None
115 validate_ooxml_line_width(width_value)
116 width_px = emu_to_px(width_value)
117 attrs["stroke-width"] = fmt_num(width_px, 5)
118
119 # Cap
120 cap = ln.attrib.get("cap")
121 if cap is not None:
122 if cap not in CAP_MAP:
123 raise ValueError(f"Unsupported DrawingML line cap: {cap!r}")
124 attrs["stroke-linecap"] = CAP_MAP[cap]
125
126 # Fill: noFill / solidFill / gradFill
127 paints = [child for child in ln if child.tag in _LINE_PAINT_TAGS]
128 if len(paints) > 1:
129 raise ValueError("DrawingML line must contain at most one paint")
130 paint = paints[0] if paints else None
131 paint_name = _LINE_PAINT_TAGS.get(paint.tag) if paint is not None else None
132 if paint_name not in {None, "noFill", "solidFill", "gradFill"}:
133 raise ValueError(f"Unsupported DrawingML line paint: {paint_name}")
134 if paint_name == "noFill":
135 validate_no_fill(paint)
136 attrs["stroke"] = "none"
137 elif paint_name == "solidFill":
138 hex_, alpha = resolve_solid_fill_color(paint, palette)
139 attrs["stroke"] = hex_
140 if alpha < 1.0:
141 attrs["stroke-opacity"] = format_ooxml_alpha(alpha)
142 elif paint_name == "gradFill":
143 # Approximate gradient stroke as the first stop color (SVG supports
144 # gradient strokes via fill="url()" but it adds a lot of plumbing;
145 # first-stop is the registered import normalization).
146 first_gs = paint.find("a:gsLst/a:gs", NS)
147 if first_gs is None:
148 raise ValueError("DrawingML gradient line requires a color stop")
149 color_elem = find_color_elem(first_gs)
150 hex_, alpha = resolve_color(color_elem, palette)
151 if hex_ is None:
152 raise ValueError(
153 "DrawingML gradient line first color cannot be resolved"
154 )
155 attrs["stroke"] = hex_
156 if alpha < 1.0:
157 attrs["stroke-opacity"] = format_ooxml_alpha(alpha)
158
159 # Dash pattern
160 preset_tag = f"{{{NS['a']}}}prstDash"
161 custom_tag = f"{{{NS['a']}}}custDash"
162 dashes = [child for child in ln if child.tag in {preset_tag, custom_tag}]
163 if len(dashes) > 1:
164 raise ValueError("DrawingML line must contain at most one dash")
165 dash = dashes[0] if dashes else None
166 if dash is not None and dash.tag == preset_tag:
167 if (
168 set(dash.attrib) != {"val"}
169 or list(dash)
170 or (dash.text or "").strip()
171 ):
172 raise ValueError("Invalid DrawingML preset dash structure")
173 preset = dash.attrib["val"]
174 if preset not in PRST_DASH_TO_ARRAY:
175 raise ValueError(
176 f"Unsupported DrawingML preset dash: {preset!r}"
177 )
178 dasharray = PRST_DASH_TO_ARRAY[preset]
179 if dasharray:
180 attrs["stroke-dasharray"] = dasharray
181 elif dash is not None:
182 cust_dash = dash
183 if cust_dash.attrib or (cust_dash.text or "").strip():
184 raise ValueError("Invalid DrawingML custom dash structure")
185 ds_parts: list[str] = []
186 sw = float(attrs.get("stroke-width", "1") or "1") or 1.0
187 dash_stops = list(cust_dash)
188 expected_tag = f"{{{NS['a']}}}ds"
189 if not dash_stops or any(
190 ds.tag != expected_tag
191 or set(ds.attrib) != {"d", "sp"}
192 or list(ds)
193 for ds in dash_stops
194 ):
195 raise ValueError("Invalid DrawingML custom dash structure")
196 for ds in dash_stops:
197 # d, sp are percentages of stroke width (1000ths)
198 values: dict[str, int] = {}
199 for name in ("d", "sp"):
200 raw_value = ds.attrib[name]
201 try:
202 value = int(raw_value)
203 except (ValueError, TypeError):
204 raise ValueError(
205 f"Invalid DrawingML custom dash {name}: "
206 f"{raw_value!r}"
207 ) from None
208 if not 0 < value <= _OOXML_INT_MAX:
209 raise ValueError(
210 f"DrawingML custom dash {name}={value} is "
211 "outside the positive OOXML integer range"
212 )
213 values[name] = value
214 d_pct = values["d"]
215 sp_pct = values["sp"]
216 ds_parts.append(fmt_num(d_pct / 100000.0 * sw, 10))
217 ds_parts.append(fmt_num(sp_pct / 100000.0 * sw, 10))
218 attrs["stroke-dasharray"] = " ".join(ds_parts)
219
220 # Join
221 join_names = {
222 f"{{{NS['a']}}}round": "round",
223 f"{{{NS['a']}}}bevel": "bevel",
224 f"{{{NS['a']}}}miter": "miter",
225 }
226 joins = [child for child in ln if child.tag in join_names]
227 if len(joins) > 1:
228 raise ValueError("DrawingML line must contain at most one join")
229 if joins:
230 join = joins[0]
231 linejoin = join_names[join.tag]
232 if list(join):
233 raise ValueError("Invalid DrawingML line join structure")
234 if linejoin in {"round", "bevel"} and join.attrib:
235 raise ValueError("Invalid DrawingML line join structure")
236 if linejoin == "miter":
237 if set(join.attrib) - {"lim"}:
238 raise ValueError("Invalid DrawingML line join structure")
239 limit = join.attrib.get("lim")
240 if limit != "800000":
241 raise ValueError(
242 f"Unsupported DrawingML miter limit: {limit!r}"
243 )
244 attrs["stroke-linejoin"] = linejoin
245
246 # Arrow markers (head / tail)
247 if id_seq is None:
248 id_seq = [0]
249 for which, attr in (("headEnd", "marker-start"), ("tailEnd", "marker-end")):
250 endpoints = ln.findall(f"a:{which}", NS)
251 if len(endpoints) > 1:
252 raise ValueError(
253 f"DrawingML line must contain at most one {which}"
254 )
255 if not endpoints:
256 continue
257 end_elem = endpoints[0]
258 if (
259 set(end_elem.attrib) - {"type", "w", "len"}
260 or list(end_elem)
261 or (end_elem.text or "").strip()
262 ):
263 raise ValueError(f"Invalid DrawingML {which} structure")
264 marker_color = attrs.get("stroke") or style_stroke_default or "#000000"
265 marker_id, marker_def = _build_arrow_marker(
266 end_elem,
267 marker_color,
268 id_prefix=id_prefix,
269 seq=id_seq,
270 reversed_=(which == "headEnd"),
271 )
272 if marker_id is None:
273 continue
274 defs.append(marker_def)
275 attrs[attr] = f"url(#{marker_id})"
276
277 return StrokeResult(attrs=attrs, defs=defs)
278
279
280 # ---------------------------------------------------------------------------
281 # Arrow marker generation
282 # ---------------------------------------------------------------------------
283
284 # Bucket -> markerWidth/markerHeight ratio (in stroke widths). These values
285 # are the stable representatives of the SVG-to-DrawingML bucket thresholds,
286 # so importing and exporting preserves both ``w`` and ``len`` categories.
287 SIZE_BUCKET = {"sm": 1.5, "med": 2.5, "lg": 3.5}
288
289
290 def _build_arrow_marker(
291 end_elem: ET.Element,
292 stroke_color: str,
293 *,
294 id_prefix: str,
295 seq: list[int],
296 reversed_: bool,
297 ) -> tuple[str | None, str]:
298 """Build an SVG <marker> def for an <a:headEnd>/<a:tailEnd>."""
299 typ = end_elem.attrib.get("type")
300 if typ not in {
301 None,
302 "none",
303 "triangle",
304 "stealth",
305 "arrow",
306 "diamond",
307 "oval",
308 }:
309 raise ValueError(f"Unsupported DrawingML line-end type: {typ!r}")
310
311 w_b = end_elem.attrib.get("w", "med")
312 l_b = end_elem.attrib.get("len", "med")
313 for dimension, bucket in (("width", w_b), ("length", l_b)):
314 if bucket not in SIZE_BUCKET:
315 raise ValueError(
316 f"Unsupported DrawingML line-end {dimension} bucket: "
317 f"{bucket!r}"
318 )
319 if typ is None or typ == "none":
320 return None, ""
321 if stroke_color.strip().lower() == "none":
322 raise ValueError(
323 "DrawingML line end requires a visible line paint"
324 )
325 mw = SIZE_BUCKET[l_b]
326 mh = SIZE_BUCKET[w_b]
327
328 seq[0] += 1
329 marker_id = f"{id_prefix}arrow{seq[0]}"
330
331 # SVG markers are drawn in their own viewBox; we use a 0..10 box and place
332 # the path so refX is at the line endpoint.
333 if typ == "triangle":
334 path = "M 0 0 L 10 5 L 0 10 z"
335 elif typ == "stealth":
336 path = "M 0 0 L 10 5 L 0 10 L 3 5 z"
337 elif typ == "arrow":
338 path = "M 0 0 L 10 5 L 0 10"
339 elif typ == "diamond":
340 path = "M 0 5 L 5 0 L 10 5 L 5 10 z"
341 elif typ == "oval":
342 path = "" # use circle below
343
344 if typ == "oval":
345 body = f'<circle cx="5" cy="5" r="4" fill="{stroke_color}"/>'
346 elif typ == "arrow":
347 body = (
348 f'<path d="{path}" fill="none" stroke="{stroke_color}"/>'
349 )
350 else:
351 body = f'<path d="{path}" fill="{stroke_color}"/>'
352
353 orient = "auto-start-reverse" if reversed_ else "auto"
354 # Note: stroke="none" prevents marker from inheriting parent stroke.
355 marker_def = (
356 f'<marker id="{marker_id}" viewBox="0 0 10 10" '
357 f'refX="{"0" if reversed_ else "10"}" refY="5" '
358 f'markerWidth="{fmt_num(mw, 2)}" markerHeight="{fmt_num(mh, 2)}" '
359 f'orient="{orient}" markerUnits="strokeWidth">'
360 f"{body}</marker>"
361 )
362 return marker_id, marker_def
363
363 lines PYTHON