Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -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