"""DrawingML -> SVG stroke conversion. Reverse of svg_to_pptx/drawingml/styles.py build_stroke_xml. Produces an SVG attribute dict with stroke / stroke-width / stroke-opacity / stroke-dasharray / stroke-linecap / stroke-linejoin / marker-start / marker-end (markers also need a entry which is returned alongside). """ from __future__ import annotations from dataclasses import dataclass, field from xml.etree import ElementTree as ET from pptx_shapes.formula import validate_ooxml_line_width from .color_resolver import ( ColorPalette, find_color_elem, resolve_color, resolve_solid_fill_color, validate_no_fill, ) from .emu_units import NS, emu_to_px, fmt_num, format_ooxml_alpha @dataclass class StrokeResult: """Resolved stroke: SVG attributes to apply + optional for markers.""" attrs: dict[str, str] = field(default_factory=dict) defs: list[str] = field(default_factory=list) # Reverse of svg_to_pptx DASH_PRESETS (preset name -> dasharray). PRST_DASH_TO_ARRAY = { "solid": None, # no dasharray "dot": "1 3", "dash": "4 4", "lgDash": "8 4", "dashDot": "4 4 1 4", "lgDashDot": "8 4 2 4", "lgDashDotDot": "8 4 2 4 2 4", "sysDash": "3 3", "sysDot": "1 3", "sysDashDot": "3 3 1 3", "sysDashDotDot": "3 3 1 3 1 3", } _OOXML_INT_MAX = 2**31 - 1 _LINE_PAINT_TAGS = { f"{{{NS['a']}}}{name}": name for name in ( "noFill", "solidFill", "gradFill", "pattFill", "blipFill", "grpFill", ) } # DrawingML cap -> SVG stroke-linecap CAP_MAP = { "rnd": "round", "sq": "square", "flat": "butt", } def resolve_stroke( sp_pr: ET.Element | None, palette: ColorPalette | None, *, id_prefix: str = "m", id_seq: list[int] | None = None, style_stroke_default: str | None = None, ) -> StrokeResult: """Resolve child of . Returns: StrokeResult.attrs is empty if no present (caller falls back to the spec default). If exists with , attrs has stroke="none". """ if sp_pr is None: return StrokeResult() ln = sp_pr.find("a:ln", NS) if ln is None: return StrokeResult() attrs: dict[str, str] = {} defs: list[str] = [] compound = ln.attrib.get("cmpd") if compound not in {None, "sng"}: raise ValueError( f"Unsupported DrawingML compound line: {compound!r}" ) alignment = ln.attrib.get("algn") if alignment not in {None, "ctr"}: raise ValueError( f"Unsupported DrawingML line alignment: {alignment!r}" ) # Width (a:ln@w in EMU) width_emu = ln.attrib.get("w") if width_emu is not None: try: width_value = int(width_emu) except (ValueError, TypeError): raise ValueError( f"Invalid DrawingML line width: {width_emu!r}" ) from None validate_ooxml_line_width(width_value) width_px = emu_to_px(width_value) attrs["stroke-width"] = fmt_num(width_px, 5) # Cap cap = ln.attrib.get("cap") if cap is not None: if cap not in CAP_MAP: raise ValueError(f"Unsupported DrawingML line cap: {cap!r}") attrs["stroke-linecap"] = CAP_MAP[cap] # Fill: noFill / solidFill / gradFill paints = [child for child in ln if child.tag in _LINE_PAINT_TAGS] if len(paints) > 1: raise ValueError("DrawingML line must contain at most one paint") paint = paints[0] if paints else None paint_name = _LINE_PAINT_TAGS.get(paint.tag) if paint is not None else None if paint_name not in {None, "noFill", "solidFill", "gradFill"}: raise ValueError(f"Unsupported DrawingML line paint: {paint_name}") if paint_name == "noFill": validate_no_fill(paint) attrs["stroke"] = "none" elif paint_name == "solidFill": hex_, alpha = resolve_solid_fill_color(paint, palette) attrs["stroke"] = hex_ if alpha < 1.0: attrs["stroke-opacity"] = format_ooxml_alpha(alpha) elif paint_name == "gradFill": # Approximate gradient stroke as the first stop color (SVG supports # gradient strokes via fill="url()" but it adds a lot of plumbing; # first-stop is the registered import normalization). first_gs = paint.find("a:gsLst/a:gs", NS) if first_gs is None: raise ValueError("DrawingML gradient line requires a color stop") color_elem = find_color_elem(first_gs) hex_, alpha = resolve_color(color_elem, palette) if hex_ is None: raise ValueError( "DrawingML gradient line first color cannot be resolved" ) attrs["stroke"] = hex_ if alpha < 1.0: attrs["stroke-opacity"] = format_ooxml_alpha(alpha) # Dash pattern preset_tag = f"{{{NS['a']}}}prstDash" custom_tag = f"{{{NS['a']}}}custDash" dashes = [child for child in ln if child.tag in {preset_tag, custom_tag}] if len(dashes) > 1: raise ValueError("DrawingML line must contain at most one dash") dash = dashes[0] if dashes else None if dash is not None and dash.tag == preset_tag: if ( set(dash.attrib) != {"val"} or list(dash) or (dash.text or "").strip() ): raise ValueError("Invalid DrawingML preset dash structure") preset = dash.attrib["val"] if preset not in PRST_DASH_TO_ARRAY: raise ValueError( f"Unsupported DrawingML preset dash: {preset!r}" ) dasharray = PRST_DASH_TO_ARRAY[preset] if dasharray: attrs["stroke-dasharray"] = dasharray elif dash is not None: cust_dash = dash if cust_dash.attrib or (cust_dash.text or "").strip(): raise ValueError("Invalid DrawingML custom dash structure") ds_parts: list[str] = [] sw = float(attrs.get("stroke-width", "1") or "1") or 1.0 dash_stops = list(cust_dash) expected_tag = f"{{{NS['a']}}}ds" if not dash_stops or any( ds.tag != expected_tag or set(ds.attrib) != {"d", "sp"} or list(ds) for ds in dash_stops ): raise ValueError("Invalid DrawingML custom dash structure") for ds in dash_stops: # d, sp are percentages of stroke width (1000ths) values: dict[str, int] = {} for name in ("d", "sp"): raw_value = ds.attrib[name] try: value = int(raw_value) except (ValueError, TypeError): raise ValueError( f"Invalid DrawingML custom dash {name}: " f"{raw_value!r}" ) from None if not 0 < value <= _OOXML_INT_MAX: raise ValueError( f"DrawingML custom dash {name}={value} is " "outside the positive OOXML integer range" ) values[name] = value d_pct = values["d"] sp_pct = values["sp"] ds_parts.append(fmt_num(d_pct / 100000.0 * sw, 10)) ds_parts.append(fmt_num(sp_pct / 100000.0 * sw, 10)) attrs["stroke-dasharray"] = " ".join(ds_parts) # Join join_names = { f"{{{NS['a']}}}round": "round", f"{{{NS['a']}}}bevel": "bevel", f"{{{NS['a']}}}miter": "miter", } joins = [child for child in ln if child.tag in join_names] if len(joins) > 1: raise ValueError("DrawingML line must contain at most one join") if joins: join = joins[0] linejoin = join_names[join.tag] if list(join): raise ValueError("Invalid DrawingML line join structure") if linejoin in {"round", "bevel"} and join.attrib: raise ValueError("Invalid DrawingML line join structure") if linejoin == "miter": if set(join.attrib) - {"lim"}: raise ValueError("Invalid DrawingML line join structure") limit = join.attrib.get("lim") if limit != "800000": raise ValueError( f"Unsupported DrawingML miter limit: {limit!r}" ) attrs["stroke-linejoin"] = linejoin # Arrow markers (head / tail) if id_seq is None: id_seq = [0] for which, attr in (("headEnd", "marker-start"), ("tailEnd", "marker-end")): endpoints = ln.findall(f"a:{which}", NS) if len(endpoints) > 1: raise ValueError( f"DrawingML line must contain at most one {which}" ) if not endpoints: continue end_elem = endpoints[0] if ( set(end_elem.attrib) - {"type", "w", "len"} or list(end_elem) or (end_elem.text or "").strip() ): raise ValueError(f"Invalid DrawingML {which} structure") marker_color = attrs.get("stroke") or style_stroke_default or "#000000" marker_id, marker_def = _build_arrow_marker( end_elem, marker_color, id_prefix=id_prefix, seq=id_seq, reversed_=(which == "headEnd"), ) if marker_id is None: continue defs.append(marker_def) attrs[attr] = f"url(#{marker_id})" return StrokeResult(attrs=attrs, defs=defs) # --------------------------------------------------------------------------- # Arrow marker generation # --------------------------------------------------------------------------- # Bucket -> markerWidth/markerHeight ratio (in stroke widths). These values # are the stable representatives of the SVG-to-DrawingML bucket thresholds, # so importing and exporting preserves both ``w`` and ``len`` categories. SIZE_BUCKET = {"sm": 1.5, "med": 2.5, "lg": 3.5} def _build_arrow_marker( end_elem: ET.Element, stroke_color: str, *, id_prefix: str, seq: list[int], reversed_: bool, ) -> tuple[str | None, str]: """Build an SVG def for an /.""" typ = end_elem.attrib.get("type") if typ not in { None, "none", "triangle", "stealth", "arrow", "diamond", "oval", }: raise ValueError(f"Unsupported DrawingML line-end type: {typ!r}") w_b = end_elem.attrib.get("w", "med") l_b = end_elem.attrib.get("len", "med") for dimension, bucket in (("width", w_b), ("length", l_b)): if bucket not in SIZE_BUCKET: raise ValueError( f"Unsupported DrawingML line-end {dimension} bucket: " f"{bucket!r}" ) if typ is None or typ == "none": return None, "" if stroke_color.strip().lower() == "none": raise ValueError( "DrawingML line end requires a visible line paint" ) mw = SIZE_BUCKET[l_b] mh = SIZE_BUCKET[w_b] seq[0] += 1 marker_id = f"{id_prefix}arrow{seq[0]}" # SVG markers are drawn in their own viewBox; we use a 0..10 box and place # the path so refX is at the line endpoint. if typ == "triangle": path = "M 0 0 L 10 5 L 0 10 z" elif typ == "stealth": path = "M 0 0 L 10 5 L 0 10 L 3 5 z" elif typ == "arrow": path = "M 0 0 L 10 5 L 0 10" elif typ == "diamond": path = "M 0 5 L 5 0 L 10 5 L 5 10 z" elif typ == "oval": path = "" # use circle below if typ == "oval": body = f'' elif typ == "arrow": body = ( f'' ) else: body = f'' orient = "auto-start-reverse" if reversed_ else "auto" # Note: stroke="none" prevents marker from inheriting parent stroke. marker_def = ( f'' f"{body}" ) return marker_id, marker_def