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