Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
"""DrawingML <a:ln> -> 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 <defs> 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 <defs> 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 <a:ln> child of <p:spPr>.
|
||||
|
||||
Returns:
|
||||
StrokeResult.attrs is empty if no <a:ln> present (caller falls back to
|
||||
the spec default). If <a:ln> exists with <a:noFill/>, 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 <marker> def for an <a:headEnd>/<a:tailEnd>."""
|
||||
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'<circle cx="5" cy="5" r="4" fill="{stroke_color}"/>'
|
||||
elif typ == "arrow":
|
||||
body = (
|
||||
f'<path d="{path}" fill="none" stroke="{stroke_color}"/>'
|
||||
)
|
||||
else:
|
||||
body = f'<path d="{path}" fill="{stroke_color}"/>'
|
||||
|
||||
orient = "auto-start-reverse" if reversed_ else "auto"
|
||||
# Note: stroke="none" prevents marker from inheriting parent stroke.
|
||||
marker_def = (
|
||||
f'<marker id="{marker_id}" viewBox="0 0 10 10" '
|
||||
f'refX="{"0" if reversed_ else "10"}" refY="5" '
|
||||
f'markerWidth="{fmt_num(mw, 2)}" markerHeight="{fmt_num(mh, 2)}" '
|
||||
f'orient="{orient}" markerUnits="strokeWidth">'
|
||||
f"{body}</marker>"
|
||||
)
|
||||
return marker_id, marker_def
|
||||
Reference in New Issue
Block a user