1963 lines
69 KiB
Python
1963 lines
69 KiB
Python
"""SVG element converters: rect, circle, line, path, polygon, polyline, text, image, ellipse."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import math
|
||
import re
|
||
import base64
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from xml.etree import ElementTree as ET
|
||
|
||
from .drawingml_context import ConvertContext, ShapeResult
|
||
from .drawingml_utils import (
|
||
SVG_NS, XLINK_NS, ANGLE_UNIT, FONT_PX_TO_HUNDREDTHS_PT, DASH_PRESETS,
|
||
px_to_emu, _f, _get_attr,
|
||
ctx_x, ctx_y, ctx_w, ctx_h,
|
||
rect_to_dml_xfrm,
|
||
parse_hex_color, resolve_url_id, get_effective_filter_id,
|
||
parse_font_family, is_cjk_char, estimate_text_width,
|
||
detect_text_lang, resolve_text_run_fonts,
|
||
parse_transform_matrix, _xml_escape,
|
||
)
|
||
from .drawingml_styles import (
|
||
build_solid_fill, build_gradient_fill,
|
||
build_fill_xml, build_stroke_xml, build_effect_xml, classify_filter_effect,
|
||
get_fill_opacity, get_stroke_opacity,
|
||
)
|
||
from .drawingml_paths import (
|
||
PathCommand, parse_svg_path, svg_path_to_absolute,
|
||
normalize_path_commands, path_commands_to_drawingml,
|
||
)
|
||
|
||
|
||
def _resolve_external_image(svg_dir: Path, href: str) -> Path:
|
||
"""Resolve a non-data-URI image href to a file on disk.
|
||
|
||
Search order: next to the SVG (``svg_output/``), the project root, the
|
||
project's ``images/`` (the single runtime image pool — template-bundled
|
||
bitmaps plus AI / web / user images all live here), then ``templates/``
|
||
(legacy flat-copied template assets). Raises ``FileNotFoundError`` if none
|
||
of these exist.
|
||
"""
|
||
for candidate in (
|
||
svg_dir / href,
|
||
svg_dir.parent / href,
|
||
svg_dir.parent / 'images' / href,
|
||
svg_dir.parent / 'templates' / href,
|
||
):
|
||
if candidate.exists():
|
||
return candidate
|
||
raise FileNotFoundError(f'External image not found: {href}')
|
||
|
||
|
||
def _wrap_shape(
|
||
shape_id: int, name: str,
|
||
off_x: int, off_y: int,
|
||
ext_cx: int, ext_cy: int,
|
||
geom_xml: str, fill_xml: str, stroke_xml: str,
|
||
effect_xml: str = '', extra_xml: str = '',
|
||
rot: int = 0,
|
||
) -> str:
|
||
"""Wrap DrawingML content into a <p:sp> shape element."""
|
||
rot_attr = f' rot="{rot}"' if rot else ''
|
||
return f'''<p:sp>
|
||
<p:nvSpPr>
|
||
<p:cNvPr id="{shape_id}" name="{_xml_escape(name)}"/>
|
||
<p:cNvSpPr/><p:nvPr/>
|
||
</p:nvSpPr>
|
||
<p:spPr>
|
||
<a:xfrm{rot_attr}><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||
{geom_xml}
|
||
{fill_xml}
|
||
{stroke_xml}
|
||
{effect_xml}
|
||
</p:spPr>
|
||
{extra_xml}
|
||
</p:sp>'''
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# rect
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Cubic-Bézier control distance for approximating a quarter circle / ellipse.
|
||
# Distance from corner to control point along the tangent, expressed as a
|
||
# fraction of the radius. Standard "magic number" for a 90° arc (max error
|
||
# ~0.027% of the radius).
|
||
_BEZIER_QUARTER_K = 0.5522847498
|
||
|
||
|
||
def _build_preset_geom_from_meta(elem: ET.Element) -> str | None:
|
||
"""Build native DrawingML preset geometry from SVG metadata."""
|
||
prst = elem.get('data-pptx-prst')
|
||
if prst != 'round2SameRect':
|
||
return None
|
||
|
||
def _adj_attr(name: str, default: int) -> int:
|
||
try:
|
||
return int(float(elem.get(name, str(default))))
|
||
except ValueError:
|
||
return default
|
||
|
||
adj1 = max(0, min(100000, _adj_attr('data-pptx-adj1', 16667)))
|
||
adj2 = max(0, min(100000, _adj_attr('data-pptx-adj2', 0)))
|
||
return (
|
||
'<a:prstGeom prst="round2SameRect">'
|
||
'<a:avLst>'
|
||
f'<a:gd name="adj1" fmla="val {adj1}"/>'
|
||
f'<a:gd name="adj2" fmla="val {adj2}"/>'
|
||
'</a:avLst>'
|
||
'</a:prstGeom>'
|
||
)
|
||
|
||
|
||
def _build_round_rect_custgeom(w: float, h: float, rx: float, ry: float) -> str:
|
||
"""Build a DrawingML ``custGeom`` for a rectangle with elliptical corners.
|
||
|
||
Used when ``<rect>`` has rx ≠ ry, which DrawingML's preset ``roundRect``
|
||
cannot express (the preset takes a single ``adj`` shared by all four
|
||
corners and is implicitly symmetric). Each 90° elliptical arc is
|
||
approximated by one cubic Bézier — within 0.03% of the true ellipse, far
|
||
below any visible threshold at slide resolution.
|
||
|
||
Trade-off vs. the symmetric ``prstGeom roundRect`` path: this geometry
|
||
is custom, so PowerPoint's yellow corner-radius handle is gone and the
|
||
shape can no longer be retuned in-place. That matches the underlying
|
||
reality — rx ≠ ry has no single "radius" to drag — and remains far
|
||
better than the previous behaviour (silently dropping all corners and
|
||
rendering a hard rectangle).
|
||
|
||
Args:
|
||
w, h: Pixel dimensions of the rectangle (post ctx-scale).
|
||
rx, ry: Pixel corner radii along x and y. Will be clamped to half
|
||
of w / h respectively per the SVG spec.
|
||
|
||
Returns:
|
||
A complete ``<a:custGeom>...</a:custGeom>`` XML string. Coordinates
|
||
are emitted in EMU within a path-local coordinate system whose
|
||
``w`` / ``h`` equal the rectangle's pixel-converted dimensions.
|
||
"""
|
||
# Clamp radii (SVG spec): rx > w/2 collapses to a half-circle end.
|
||
rx = min(max(rx, 0.0), w / 2)
|
||
ry = min(max(ry, 0.0), h / 2)
|
||
|
||
width_emu = px_to_emu(w)
|
||
height_emu = px_to_emu(h)
|
||
rx_emu = px_to_emu(rx)
|
||
ry_emu = px_to_emu(ry)
|
||
|
||
cx_off = int(round(rx_emu * _BEZIER_QUARTER_K))
|
||
cy_off = int(round(ry_emu * _BEZIER_QUARTER_K))
|
||
|
||
def pt(x: int, y: int) -> str:
|
||
return f'<a:pt x="{x}" y="{y}"/>'
|
||
|
||
def cubic(c1: tuple[int, int], c2: tuple[int, int], end: tuple[int, int]) -> str:
|
||
return (
|
||
f'<a:cubicBezTo>{pt(*c1)}{pt(*c2)}{pt(*end)}</a:cubicBezTo>'
|
||
)
|
||
|
||
# Path traversed clockwise, starting just past the top-left corner.
|
||
parts = [
|
||
f'<a:moveTo>{pt(rx_emu, 0)}</a:moveTo>',
|
||
f'<a:lnTo>{pt(width_emu - rx_emu, 0)}</a:lnTo>',
|
||
# Top-right corner: (W-Rx, 0) → (W, Ry)
|
||
cubic(
|
||
(width_emu - rx_emu + cx_off, 0),
|
||
(width_emu, ry_emu - cy_off),
|
||
(width_emu, ry_emu),
|
||
),
|
||
f'<a:lnTo>{pt(width_emu, height_emu - ry_emu)}</a:lnTo>',
|
||
# Bottom-right corner: (W, H-Ry) → (W-Rx, H)
|
||
cubic(
|
||
(width_emu, height_emu - ry_emu + cy_off),
|
||
(width_emu - rx_emu + cx_off, height_emu),
|
||
(width_emu - rx_emu, height_emu),
|
||
),
|
||
f'<a:lnTo>{pt(rx_emu, height_emu)}</a:lnTo>',
|
||
# Bottom-left corner: (Rx, H) → (0, H-Ry)
|
||
cubic(
|
||
(rx_emu - cx_off, height_emu),
|
||
(0, height_emu - ry_emu + cy_off),
|
||
(0, height_emu - ry_emu),
|
||
),
|
||
f'<a:lnTo>{pt(0, ry_emu)}</a:lnTo>',
|
||
# Top-left corner: (0, Ry) → (Rx, 0)
|
||
cubic(
|
||
(0, ry_emu - cy_off),
|
||
(rx_emu - cx_off, 0),
|
||
(rx_emu, 0),
|
||
),
|
||
'<a:close/>',
|
||
]
|
||
|
||
path_xml = '\n'.join(parts)
|
||
return (
|
||
'<a:custGeom>'
|
||
'<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>'
|
||
'<a:rect l="l" t="t" r="r" b="b"/>'
|
||
f'<a:pathLst><a:path w="{width_emu}" h="{height_emu}">'
|
||
f'\n{path_xml}\n'
|
||
'</a:path></a:pathLst>'
|
||
'</a:custGeom>'
|
||
)
|
||
|
||
|
||
def convert_rect(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <rect> to DrawingML shape.
|
||
|
||
Symmetric rounded corners (rx == ry) are emitted as ``prstGeom roundRect``
|
||
so PowerPoint treats them as a native rounded-rectangle shape: the yellow
|
||
adjustment handle stays draggable, and "Reset Picture / Shape" works as
|
||
expected. Elliptical corners (rx != ry) fall back to plain rect geometry
|
||
for now — current corpora contain none, but the branch keeps callers from
|
||
silently producing distorted custom geometry if one ever appears.
|
||
"""
|
||
x = ctx_x(_f(elem.get('x')), ctx)
|
||
y = ctx_y(_f(elem.get('y')), ctx)
|
||
w = ctx_w(_f(elem.get('width')), ctx)
|
||
h = ctx_h(_f(elem.get('height')), ctx)
|
||
|
||
if w <= 0 or h <= 0:
|
||
return None
|
||
|
||
# SVG spec: when only one of rx/ry is specified, the other inherits its
|
||
# value. Real-world svg_output decks always write only `rx`, so ry must
|
||
# be inferred to keep round corners from collapsing to zero on one axis.
|
||
rx_attr = elem.get('rx')
|
||
ry_attr = elem.get('ry')
|
||
rx_raw = _f(rx_attr) if rx_attr is not None else 0.0
|
||
ry_raw = _f(ry_attr) if ry_attr is not None else 0.0
|
||
if rx_attr is not None and ry_attr is None:
|
||
ry_raw = rx_raw
|
||
elif ry_attr is not None and rx_attr is None:
|
||
rx_raw = ry_raw
|
||
rx = rx_raw * ctx.scale_x
|
||
ry = ry_raw * ctx.scale_y
|
||
|
||
fill_op = get_fill_opacity(elem, ctx)
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
fill = build_fill_xml(elem, ctx, fill_op)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
effect = ''
|
||
filt_id = get_effective_filter_id(elem, ctx)
|
||
if filt_id and filt_id in ctx.defs:
|
||
effect = build_effect_xml(ctx.defs[filt_id])
|
||
|
||
rot = 0
|
||
transform = elem.get('transform')
|
||
if transform:
|
||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
||
if r_match:
|
||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
||
|
||
if rx > 0 and abs(rx - ry) < 0.5:
|
||
# Symmetric corners → native PowerPoint rounded rectangle. adj is
|
||
# the corner radius as a fraction of the shorter side, in 1/1000-
|
||
# percent units, capped at 50000 (= radius equals half the shorter
|
||
# side, i.e. capsule end).
|
||
short_side = min(w, h)
|
||
radius = min(rx, short_side / 2)
|
||
adj = max(0, min(50000, int(round(radius / short_side * 100000))))
|
||
geom = (
|
||
'<a:prstGeom prst="roundRect">'
|
||
f'<a:avLst><a:gd name="adj" fmla="val {adj}"/></a:avLst>'
|
||
'</a:prstGeom>'
|
||
)
|
||
elif rx > 0 or ry > 0:
|
||
# Asymmetric corners (rx != ry) → DrawingML has no preset for
|
||
# elliptical-corner rectangles, so emit a custGeom with one cubic
|
||
# Bézier per 90° arc. We lose the prstGeom roundRect adjustment
|
||
# handle, but symmetric and asymmetric cases now both render with
|
||
# rounded corners instead of one of them silently flattening to
|
||
# a hard rectangle.
|
||
geom = _build_round_rect_custgeom(w, h, rx, ry)
|
||
else:
|
||
geom = '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
|
||
|
||
shape_id = ctx.next_id()
|
||
off_x = px_to_emu(x)
|
||
off_y = px_to_emu(y)
|
||
ext_cx = px_to_emu(w)
|
||
ext_cy = px_to_emu(h)
|
||
return ShapeResult(
|
||
xml=_wrap_shape(
|
||
shape_id, f'Rectangle {shape_id}',
|
||
off_x, off_y, ext_cx, ext_cy,
|
||
geom, fill, stroke, effect, rot=rot,
|
||
),
|
||
bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# circle (including donut-chart arc segments)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _build_arc_ring_path(
|
||
cx: float, cy: float, r: float,
|
||
stroke_width: float,
|
||
dash_len: float, dash_offset: float,
|
||
rotate_deg: float,
|
||
sx: float, sy: float,
|
||
) -> tuple[str, int, int, int, int]:
|
||
"""Build a filled annular-sector (donut segment) as DrawingML custGeom.
|
||
|
||
SVG donut charts use stroke-dasharray on a circle to draw arc segments.
|
||
DrawingML cannot reproduce this, so we convert each arc segment into a
|
||
filled ring shape (outer arc -> line -> inner arc -> close).
|
||
|
||
Returns:
|
||
(geom_xml, min_x_emu, min_y_emu, w_emu, h_emu).
|
||
"""
|
||
circumference = 2 * math.pi * r
|
||
if circumference <= 0:
|
||
return '', 0, 0, 0, 0
|
||
|
||
start_frac = -dash_offset / circumference
|
||
end_frac = start_frac + dash_len / circumference
|
||
|
||
start_angle = start_frac * 2 * math.pi + math.radians(rotate_deg)
|
||
end_angle = end_frac * 2 * math.pi + math.radians(rotate_deg)
|
||
|
||
half_sw = stroke_width / 2
|
||
r_outer = r + half_sw
|
||
r_inner = r - half_sw
|
||
|
||
num_segments = max(16, int(abs(end_angle - start_angle) / (math.pi / 32)))
|
||
angles = [
|
||
start_angle + (end_angle - start_angle) * i / num_segments
|
||
for i in range(num_segments + 1)
|
||
]
|
||
|
||
outer_pts = [(cx + r_outer * math.sin(a), cy - r_outer * math.cos(a)) for a in angles]
|
||
inner_pts = [(cx + r_inner * math.sin(a), cy - r_inner * math.cos(a)) for a in reversed(angles)]
|
||
|
||
all_pts = [(px * sx, py * sy) for px, py in outer_pts + inner_pts]
|
||
|
||
xs = [p[0] for p in all_pts]
|
||
ys = [p[1] for p in all_pts]
|
||
min_x, max_x = min(xs), max(xs)
|
||
min_y, max_y = min(ys), max(ys)
|
||
width = max_x - min_x
|
||
height = max_y - min_y
|
||
|
||
if width < 0.5 or height < 0.5:
|
||
return '', 0, 0, 0, 0
|
||
|
||
w_emu = px_to_emu(width)
|
||
h_emu = px_to_emu(height)
|
||
|
||
lines: list[str] = []
|
||
for i, (px, py) in enumerate(all_pts):
|
||
lx = px_to_emu(px - min_x)
|
||
ly = px_to_emu(py - min_y)
|
||
if i == 0:
|
||
lines.append(f'<a:moveTo><a:pt x="{lx}" y="{ly}"/></a:moveTo>')
|
||
else:
|
||
lines.append(f'<a:lnTo><a:pt x="{lx}" y="{ly}"/></a:lnTo>')
|
||
lines.append('<a:close/>')
|
||
|
||
path_xml = '\n'.join(lines)
|
||
geom = f'''<a:custGeom>
|
||
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
|
||
<a:rect l="l" t="t" r="r" b="b"/>
|
||
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
|
||
{path_xml}
|
||
</a:path></a:pathLst>
|
||
</a:custGeom>'''
|
||
|
||
return geom, px_to_emu(min_x), px_to_emu(min_y), w_emu, h_emu
|
||
|
||
|
||
def _is_donut_circle(elem: ET.Element, ctx: ConvertContext) -> bool:
|
||
"""Detect if a circle uses stroke-dasharray to simulate an arc segment."""
|
||
dasharray = _get_attr(elem, 'stroke-dasharray', ctx)
|
||
if not dasharray or dasharray == 'none':
|
||
return False
|
||
stroke = _get_attr(elem, 'stroke', ctx)
|
||
if not stroke or stroke == 'none':
|
||
return False
|
||
|
||
sw = _f(_get_attr(elem, 'stroke-width', ctx), 0)
|
||
r = _f(elem.get('r'), 0)
|
||
if sw <= 0 or r <= 0:
|
||
return False
|
||
|
||
# Standard dash presets are not donut segments
|
||
if dasharray.strip() in DASH_PRESETS:
|
||
return False
|
||
|
||
# Thin strokes relative to radius are decorative dashed rings, not donut arcs.
|
||
# Real donut arcs need sw/r >= 0.15 (e.g. sw=40 on r=100 → 0.40).
|
||
if sw / r < 0.15:
|
||
return False
|
||
|
||
return True
|
||
|
||
|
||
def convert_circle(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <circle> to DrawingML ellipse or donut-arc shape."""
|
||
cx_ = _f(elem.get('cx'))
|
||
cy_ = _f(elem.get('cy'))
|
||
r = _f(elem.get('r'))
|
||
|
||
if r <= 0:
|
||
return None
|
||
|
||
# --- Donut-chart arc segment detection ---
|
||
if _is_donut_circle(elem, ctx):
|
||
dasharray = _get_attr(elem, 'stroke-dasharray', ctx)
|
||
dash_vals = re.split(r'[\s,]+', dasharray.strip())
|
||
dash_len = float(dash_vals[0]) if dash_vals else 0
|
||
dash_offset = _f(elem.get('stroke-dashoffset'), 0)
|
||
stroke_width = _f(_get_attr(elem, 'stroke-width', ctx), 1)
|
||
|
||
rotate_deg = 0.0
|
||
transform = elem.get('transform', '')
|
||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
||
if r_match:
|
||
rotate_deg = float(r_match.group(1))
|
||
|
||
geom, min_x, min_y, w_emu, h_emu = _build_arc_ring_path(
|
||
ctx_x(cx_, ctx) / ctx.scale_x,
|
||
ctx_y(cy_, ctx) / ctx.scale_y,
|
||
r, stroke_width, dash_len, dash_offset, rotate_deg,
|
||
ctx.scale_x, ctx.scale_y,
|
||
)
|
||
if not geom:
|
||
return None
|
||
|
||
# Use the stroke color/gradient as fill for the arc shape
|
||
stroke_val = _get_attr(elem, 'stroke', ctx)
|
||
op = get_fill_opacity(elem, ctx)
|
||
grad_id = resolve_url_id(stroke_val) if stroke_val else None
|
||
if grad_id and grad_id in ctx.defs:
|
||
fill = build_gradient_fill(ctx.defs[grad_id], op)
|
||
elif stroke_val:
|
||
color = parse_hex_color(stroke_val)
|
||
fill = build_solid_fill(color, op) if color else '<a:noFill/>'
|
||
else:
|
||
fill = '<a:noFill/>'
|
||
|
||
stroke_xml = '<a:ln><a:noFill/></a:ln>'
|
||
|
||
effect = ''
|
||
filt_id = get_effective_filter_id(elem, ctx)
|
||
if filt_id and filt_id in ctx.defs:
|
||
effect = build_effect_xml(ctx.defs[filt_id])
|
||
|
||
shape_id = ctx.next_id()
|
||
return ShapeResult(
|
||
xml=_wrap_shape(
|
||
shape_id, f'Arc {shape_id}',
|
||
min_x, min_y, w_emu, h_emu,
|
||
geom, fill, stroke_xml, effect,
|
||
),
|
||
bounds_emu=(min_x, min_y, min_x + w_emu, min_y + h_emu),
|
||
)
|
||
|
||
# --- Normal circle ---
|
||
cx_s = ctx_x(cx_, ctx)
|
||
cy_s = ctx_y(cy_, ctx)
|
||
r_x = r * ctx.scale_x
|
||
r_y = r * ctx.scale_y
|
||
|
||
x = cx_s - r_x
|
||
y = cy_s - r_y
|
||
w = r_x * 2
|
||
h = r_y * 2
|
||
|
||
fill_op = get_fill_opacity(elem, ctx)
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
fill = build_fill_xml(elem, ctx, fill_op)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
effect = ''
|
||
filt_id = get_effective_filter_id(elem, ctx)
|
||
if filt_id and filt_id in ctx.defs:
|
||
effect = build_effect_xml(ctx.defs[filt_id])
|
||
|
||
geom = '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
|
||
|
||
shape_id = ctx.next_id()
|
||
off_x = px_to_emu(x)
|
||
off_y = px_to_emu(y)
|
||
ext_cx = px_to_emu(w)
|
||
ext_cy = px_to_emu(h)
|
||
return ShapeResult(
|
||
xml=_wrap_shape(
|
||
shape_id, f'Ellipse {shape_id}',
|
||
off_x, off_y, ext_cx, ext_cy,
|
||
geom, fill, stroke, effect,
|
||
),
|
||
bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# line
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def convert_line(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <line> to DrawingML shape.
|
||
|
||
Lines with marker-start / marker-end are converted using the 'line' preset
|
||
geometry (prstGeom prst="line") so that PowerPoint renders native arrow
|
||
heads (headEnd / tailEnd) correctly. Plain lines (no markers) continue to
|
||
use custom geometry which is sufficient and avoids flipH/flipV complexity.
|
||
"""
|
||
x1 = ctx_x(_f(elem.get('x1')), ctx)
|
||
y1 = ctx_y(_f(elem.get('y1')), ctx)
|
||
x2 = ctx_x(_f(elem.get('x2')), ctx)
|
||
y2 = ctx_y(_f(elem.get('y2')), ctx)
|
||
|
||
min_x = min(x1, x2)
|
||
min_y = min(y1, y2)
|
||
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
rot = 0
|
||
transform = elem.get('transform')
|
||
if transform:
|
||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
||
if r_match:
|
||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
||
|
||
shape_id = ctx.next_id()
|
||
off_x = px_to_emu(min_x)
|
||
off_y = px_to_emu(min_y)
|
||
|
||
# Determine if this line carries arrow markers.
|
||
has_marker = bool(
|
||
_get_attr(elem, 'marker-start', ctx) or
|
||
_get_attr(elem, 'marker-end', ctx)
|
||
)
|
||
|
||
if has_marker:
|
||
# ----------------------------------------------------------------
|
||
# Preset geometry approach: prstGeom prst="line"
|
||
# PowerPoint only renders headEnd / tailEnd on lines whose geometry
|
||
# it can intrinsically understand as a "line" (i.e. preset or
|
||
# connector shapes). Custom geometry shapes silently ignore
|
||
# headEnd / tailEnd in most PowerPoint versions.
|
||
#
|
||
# The "line" preset draws from (0,0) to (w,h).
|
||
# headEnd → placed at the start of the line = (x1, y1)
|
||
# tailEnd → placed at the end of the line = (x2, y2)
|
||
# We set flipH / flipV so that the preset start/end align with the
|
||
# original SVG endpoints:
|
||
# default (no flip) : top-left → bottom-right (x1≤x2, y1≤y2)
|
||
# flipH : top-right → bottom-left (x1>x2, y1≤y2)
|
||
# flipV : bottom-left → top-right (x1≤x2, y1>y2)
|
||
# flipH + flipV : bottom-right → top-left (x1>x2, y1>y2)
|
||
# ----------------------------------------------------------------
|
||
w = abs(x2 - x1)
|
||
h = abs(y2 - y1)
|
||
# DrawingML requires ext cx/cy ≥ 1 EMU
|
||
w_emu = px_to_emu(w) if w > 0 else 1
|
||
h_emu = px_to_emu(h) if h > 0 else 1
|
||
|
||
flip_h = x1 > x2
|
||
flip_v = y1 > y2
|
||
flip_attr = ''
|
||
if flip_h and flip_v:
|
||
flip_attr = ' flipH="1" flipV="1"'
|
||
elif flip_h:
|
||
flip_attr = ' flipH="1"'
|
||
elif flip_v:
|
||
flip_attr = ' flipV="1"'
|
||
|
||
rot_attr = f' rot="{rot}"' if rot else ''
|
||
xml = (
|
||
f'<p:sp>'
|
||
f'<p:nvSpPr>'
|
||
f'<p:cNvPr id="{shape_id}" name="{_xml_escape(f"Line {shape_id}")}"/>'
|
||
f'<p:cNvSpPr/><p:nvPr/>'
|
||
f'</p:nvSpPr>'
|
||
f'<p:spPr>'
|
||
f'<a:xfrm{flip_attr}{rot_attr}>'
|
||
f'<a:off x="{off_x}" y="{off_y}"/>'
|
||
f'<a:ext cx="{w_emu}" cy="{h_emu}"/>'
|
||
f'</a:xfrm>'
|
||
f'<a:prstGeom prst="line"><a:avLst/></a:prstGeom>'
|
||
f'<a:noFill/>'
|
||
f'{stroke}'
|
||
f'</p:spPr>'
|
||
f'</p:sp>'
|
||
)
|
||
else:
|
||
# ----------------------------------------------------------------
|
||
# Custom geometry (original behaviour) for plain lines.
|
||
# ----------------------------------------------------------------
|
||
w = max(abs(x2 - x1), 1)
|
||
h = max(abs(y2 - y1), 1)
|
||
w_emu = px_to_emu(w)
|
||
h_emu = px_to_emu(h)
|
||
|
||
lx1 = px_to_emu(x1 - min_x)
|
||
ly1 = px_to_emu(y1 - min_y)
|
||
lx2 = px_to_emu(x2 - min_x)
|
||
ly2 = px_to_emu(y2 - min_y)
|
||
|
||
geom = (
|
||
f'<a:custGeom>'
|
||
f'<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>'
|
||
f'<a:rect l="l" t="t" r="r" b="b"/>'
|
||
f'<a:pathLst><a:path w="{w_emu}" h="{h_emu}">'
|
||
f'<a:moveTo><a:pt x="{lx1}" y="{ly1}"/></a:moveTo>'
|
||
f'<a:lnTo><a:pt x="{lx2}" y="{ly2}"/></a:lnTo>'
|
||
f'</a:path></a:pathLst>'
|
||
f'</a:custGeom>'
|
||
)
|
||
xml = _wrap_shape(
|
||
shape_id, f'Line {shape_id}',
|
||
off_x, off_y, w_emu, h_emu,
|
||
geom, '<a:noFill/>', stroke, rot=rot,
|
||
)
|
||
|
||
return ShapeResult(
|
||
xml=xml,
|
||
bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# path
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def convert_path(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <path> to DrawingML custom geometry shape."""
|
||
d = elem.get('d', '')
|
||
if not d:
|
||
return None
|
||
|
||
commands = parse_svg_path(d)
|
||
commands = svg_path_to_absolute(commands)
|
||
commands = normalize_path_commands(commands)
|
||
|
||
tx, ty = 0.0, 0.0
|
||
rot = 0
|
||
transform = elem.get('transform')
|
||
if transform:
|
||
t_match = re.search(r'translate\(\s*([-\d.]+)[\s,]+([-\d.]+)\s*\)', transform)
|
||
if t_match:
|
||
tx = float(t_match.group(1))
|
||
ty = float(t_match.group(2))
|
||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
||
if r_match:
|
||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
||
|
||
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||
commands, ctx.translate_x + tx, ctx.translate_y + ty,
|
||
ctx.scale_x, ctx.scale_y,
|
||
)
|
||
|
||
if not path_xml:
|
||
return None
|
||
|
||
w_emu = px_to_emu(width)
|
||
h_emu = px_to_emu(height)
|
||
|
||
geom = _build_preset_geom_from_meta(elem)
|
||
if geom is None:
|
||
geom = f'''<a:custGeom>
|
||
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
|
||
<a:rect l="l" t="t" r="r" b="b"/>
|
||
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
|
||
{path_xml}
|
||
</a:path></a:pathLst>
|
||
</a:custGeom>'''
|
||
|
||
fill_op = get_fill_opacity(elem, ctx)
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
fill = build_fill_xml(elem, ctx, fill_op)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
effect = ''
|
||
filt_id = get_effective_filter_id(elem, ctx)
|
||
if filt_id and filt_id in ctx.defs:
|
||
effect = build_effect_xml(ctx.defs[filt_id])
|
||
|
||
shape_id = ctx.next_id()
|
||
off_x = px_to_emu(min_x)
|
||
off_y = px_to_emu(min_y)
|
||
return ShapeResult(
|
||
xml=_wrap_shape(
|
||
shape_id, f'Freeform {shape_id}',
|
||
off_x, off_y, w_emu, h_emu,
|
||
geom, fill, stroke, effect, rot=rot,
|
||
),
|
||
bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# polygon / polyline
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _parse_points(points_str: str) -> list[tuple[float, float]]:
|
||
"""Parse SVG points attribute into a list of (x, y) tuples."""
|
||
nums = re.findall(r'[-+]?(?:\d+\.?\d*|\.\d+)', points_str)
|
||
if len(nums) < 4:
|
||
return []
|
||
return [(float(nums[i]), float(nums[i + 1])) for i in range(0, len(nums) - 1, 2)]
|
||
|
||
|
||
def convert_polygon(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <polygon> to DrawingML custom geometry shape."""
|
||
points = _parse_points(elem.get('points', ''))
|
||
if not points:
|
||
return None
|
||
|
||
commands = [PathCommand('M', [points[0][0], points[0][1]])]
|
||
for px_, py_ in points[1:]:
|
||
commands.append(PathCommand('L', [px_, py_]))
|
||
commands.append(PathCommand('Z', []))
|
||
|
||
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||
commands, ctx.translate_x, ctx.translate_y,
|
||
ctx.scale_x, ctx.scale_y,
|
||
)
|
||
|
||
if not path_xml:
|
||
return None
|
||
|
||
w_emu = px_to_emu(width)
|
||
h_emu = px_to_emu(height)
|
||
|
||
geom = f'''<a:custGeom>
|
||
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
|
||
<a:rect l="l" t="t" r="r" b="b"/>
|
||
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
|
||
{path_xml}
|
||
</a:path></a:pathLst>
|
||
</a:custGeom>'''
|
||
|
||
fill_op = get_fill_opacity(elem, ctx)
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
fill = build_fill_xml(elem, ctx, fill_op)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
rot = 0
|
||
transform = elem.get('transform')
|
||
if transform:
|
||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
||
if r_match:
|
||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
||
|
||
shape_id = ctx.next_id()
|
||
off_x = px_to_emu(min_x)
|
||
off_y = px_to_emu(min_y)
|
||
return ShapeResult(
|
||
xml=_wrap_shape(
|
||
shape_id, f'Polygon {shape_id}',
|
||
off_x, off_y, w_emu, h_emu,
|
||
geom, fill, stroke, rot=rot,
|
||
),
|
||
bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu),
|
||
)
|
||
|
||
|
||
def convert_polyline(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <polyline> to DrawingML custom geometry shape."""
|
||
points = _parse_points(elem.get('points', ''))
|
||
if not points:
|
||
return None
|
||
|
||
commands = [PathCommand('M', [points[0][0], points[0][1]])]
|
||
for px_, py_ in points[1:]:
|
||
commands.append(PathCommand('L', [px_, py_]))
|
||
|
||
path_xml, min_x, min_y, width, height = path_commands_to_drawingml(
|
||
commands, ctx.translate_x, ctx.translate_y,
|
||
ctx.scale_x, ctx.scale_y,
|
||
)
|
||
|
||
if not path_xml:
|
||
return None
|
||
|
||
w_emu = px_to_emu(width)
|
||
h_emu = px_to_emu(height)
|
||
|
||
geom = f'''<a:custGeom>
|
||
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
|
||
<a:rect l="l" t="t" r="r" b="b"/>
|
||
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
|
||
{path_xml}
|
||
</a:path></a:pathLst>
|
||
</a:custGeom>'''
|
||
|
||
fill_op = get_fill_opacity(elem, ctx)
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
fill = build_fill_xml(elem, ctx, fill_op)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
rot = 0
|
||
transform = elem.get('transform')
|
||
if transform:
|
||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
||
if r_match:
|
||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
||
|
||
shape_id = ctx.next_id()
|
||
off_x = px_to_emu(min_x)
|
||
off_y = px_to_emu(min_y)
|
||
return ShapeResult(
|
||
xml=_wrap_shape(
|
||
shape_id, f'Polyline {shape_id}',
|
||
off_x, off_y, w_emu, h_emu,
|
||
geom, '<a:noFill/>', stroke, rot=rot,
|
||
),
|
||
bounds_emu=(off_x, off_y, off_x + w_emu, off_y + h_emu),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# text
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _normalize_text(text: str, *, preserve_space: bool = False) -> str:
|
||
"""Collapse runs of whitespace into a single space; do NOT strip the ends.
|
||
|
||
Stripping at this layer would silently delete the inline boundary
|
||
spaces in nested-tspan structures like
|
||
``<tspan>foo <tspan>bar</tspan> baz</tspan>``: the parent's text
|
||
("foo ") and the child's tail (" baz") would each lose the only space
|
||
that separated them from the inner run, producing "foobarbaz".
|
||
|
||
The paragraph's overall leading / trailing whitespace is removed once
|
||
in ``_build_text_runs`` after all inline runs have been concatenated.
|
||
"""
|
||
if not text:
|
||
return ''
|
||
if preserve_space:
|
||
return text
|
||
return re.sub(r'\s+', ' ', text)
|
||
|
||
|
||
def _preserves_space(elem: ET.Element) -> bool:
|
||
xml_space = elem.get('{http://www.w3.org/XML/1998/namespace}space') or elem.get('xml:space')
|
||
return xml_space == 'preserve'
|
||
|
||
|
||
def _override_run_attrs(
|
||
parent_attrs: dict[str, Any],
|
||
tspan: ET.Element,
|
||
) -> dict[str, Any]:
|
||
"""Layer a tspan's styling attributes over the inherited run attrs."""
|
||
run_attrs = dict(parent_attrs)
|
||
if tspan.get('font-weight'):
|
||
run_attrs['font_weight'] = tspan.get('font-weight')
|
||
if tspan.get('fill'):
|
||
child_fill = tspan.get('fill')
|
||
run_attrs['fill_raw'] = child_fill
|
||
c = parse_hex_color(child_fill)
|
||
if c:
|
||
run_attrs['fill'] = c
|
||
if tspan.get('stroke'):
|
||
run_attrs['stroke_raw'] = tspan.get('stroke')
|
||
if tspan.get('stroke-width'):
|
||
run_attrs['stroke_width'] = _f(tspan.get('stroke-width'), run_attrs.get('stroke_width', 1.0))
|
||
if tspan.get('stroke-opacity'):
|
||
try:
|
||
run_attrs['stroke_opacity'] = float(tspan.get('stroke-opacity', '1'))
|
||
except ValueError:
|
||
pass
|
||
if tspan.get('font-size'):
|
||
run_attrs['font_size'] = _f(tspan.get('font-size'), run_attrs['font_size'])
|
||
if tspan.get('font-family'):
|
||
run_attrs['font_family'] = tspan.get('font-family')
|
||
if tspan.get('font-style'):
|
||
run_attrs['font_style'] = tspan.get('font-style')
|
||
if tspan.get('text-decoration'):
|
||
run_attrs['text_decoration'] = tspan.get('text-decoration')
|
||
return run_attrs
|
||
|
||
|
||
def _collect_tspan_runs(
|
||
tspan: ET.Element,
|
||
inherited_attrs: dict[str, Any],
|
||
preserve_space: bool = False,
|
||
) -> list[dict[str, Any]]:
|
||
"""Recursively turn a tspan subtree into runs, propagating styling through nested tspans.
|
||
|
||
Order: tspan.text → (each nested child tspan's runs → that child's tail under THIS tspan's attrs).
|
||
"""
|
||
runs: list[dict[str, Any]] = []
|
||
own_attrs = _override_run_attrs(inherited_attrs, tspan)
|
||
child_preserve_space = preserve_space or _preserves_space(tspan)
|
||
|
||
if tspan.text:
|
||
t = _normalize_text(tspan.text, preserve_space=child_preserve_space)
|
||
if t:
|
||
runs.append({**own_attrs, 'text': t})
|
||
|
||
for child in tspan:
|
||
child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
|
||
if child_tag == 'tspan':
|
||
runs.extend(_collect_tspan_runs(child, own_attrs, child_preserve_space))
|
||
if child.tail:
|
||
t = _normalize_text(child.tail, preserve_space=child_preserve_space)
|
||
if t:
|
||
runs.append({**own_attrs, 'text': t})
|
||
|
||
return runs
|
||
|
||
|
||
def _build_text_runs(
|
||
elem: ET.Element,
|
||
parent_attrs: dict[str, Any],
|
||
) -> list[dict[str, Any]]:
|
||
"""Build a list of text runs from a <text> element, handling <tspan> children.
|
||
|
||
Each run is a dict with keys: text, fill, fill_raw, font_weight,
|
||
font_style, font_family, font_size. Nested tspans are walked recursively so
|
||
inline format changes inside a tspan still produce distinct runs.
|
||
"""
|
||
runs: list[dict[str, Any]] = []
|
||
preserve_space = _preserves_space(elem)
|
||
|
||
if elem.text:
|
||
t = _normalize_text(elem.text, preserve_space=preserve_space)
|
||
if t:
|
||
runs.append({**parent_attrs, 'text': t})
|
||
|
||
for child in elem:
|
||
child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
|
||
if child_tag == 'tspan':
|
||
runs.extend(_collect_tspan_runs(child, parent_attrs, preserve_space))
|
||
if child.tail:
|
||
t = _normalize_text(child.tail, preserve_space=preserve_space)
|
||
if t:
|
||
runs.append({**parent_attrs, 'text': t})
|
||
|
||
# Strip the paragraph's overall leading / trailing whitespace once unless
|
||
# xml:space="preserve" asks us to keep source indentation.
|
||
if runs and not preserve_space:
|
||
runs[0]['text'] = runs[0]['text'].lstrip(' ')
|
||
runs[-1]['text'] = runs[-1]['text'].rstrip(' ')
|
||
runs = [r for r in runs if r['text']]
|
||
|
||
return runs
|
||
|
||
|
||
def _build_text_fill_xml(
|
||
fill: str,
|
||
fill_raw: str,
|
||
opacity: float | None,
|
||
ctx: ConvertContext | None,
|
||
) -> str:
|
||
"""Build DrawingML fill XML for a text run."""
|
||
if fill_raw == 'none':
|
||
return '<a:noFill/>'
|
||
|
||
grad_id = resolve_url_id(fill_raw)
|
||
if grad_id and ctx and grad_id in ctx.defs:
|
||
return build_gradient_fill(ctx.defs[grad_id], opacity)
|
||
|
||
alpha_xml = ''
|
||
if opacity is not None and opacity < 1.0:
|
||
alpha_xml = f'<a:alphaMod val="{int(opacity * 100000)}"/>'
|
||
return f'<a:solidFill><a:srgbClr val="{fill}">{alpha_xml}</a:srgbClr></a:solidFill>'
|
||
|
||
|
||
def _build_text_outline_xml(run: dict[str, Any]) -> str:
|
||
"""Build DrawingML outline XML for a text run from SVG stroke attributes."""
|
||
stroke_raw = run.get('stroke_raw')
|
||
if not stroke_raw or stroke_raw == 'none':
|
||
return ''
|
||
|
||
color = parse_hex_color(stroke_raw)
|
||
if not color:
|
||
return ''
|
||
|
||
stroke_width = _f(str(run.get('stroke_width', 1.0)), 1.0)
|
||
stroke_opacity = run.get('stroke_opacity')
|
||
alpha_xml = ''
|
||
if stroke_opacity is not None and stroke_opacity < 1.0:
|
||
alpha_xml = f'<a:alphaMod val="{int(stroke_opacity * 100000)}"/>'
|
||
|
||
return (
|
||
f'<a:ln w="{px_to_emu(stroke_width)}">'
|
||
f'<a:solidFill><a:srgbClr val="{color}">{alpha_xml}</a:srgbClr></a:solidFill>'
|
||
'</a:ln>'
|
||
)
|
||
|
||
|
||
def _build_run_xml(
|
||
run: dict[str, Any],
|
||
default_fonts: dict[str, str],
|
||
ctx: ConvertContext | None = None,
|
||
effect_xml: str = '',
|
||
) -> str:
|
||
"""Build a single <a:r> XML from a run dict. Supports gradient fills on text."""
|
||
text = run['text']
|
||
fill = run.get('fill', '000000')
|
||
fill_raw = run.get('fill_raw', '')
|
||
fw = run.get('font_weight', '400')
|
||
fs_px = run.get('font_size', 16)
|
||
fstyle = run.get('font_style', '')
|
||
ff = run.get('font_family', '')
|
||
opacity = run.get('opacity')
|
||
|
||
text_dec = run.get('text_decoration', '')
|
||
|
||
# Exported font size = fs_px * FONT_PX_TO_HUNDREDTHS_PT hundredths-of-pt,
|
||
# rounded to **one decimal place of pt** (the nearest 10 hundredths). No 0.5pt
|
||
# / integer snapping — whatever the px works out to is the size, e.g.
|
||
# 18px -> 13.5pt, 24px -> 18.0pt, 42px -> 31.5pt.
|
||
sz = int(round(fs_px * FONT_PX_TO_HUNDREDTHS_PT / 10.0)) * 10
|
||
b_attr = ' b="1"' if fw in ('bold', '600', '700', '800', '900') else ''
|
||
i_attr = ' i="1"' if fstyle == 'italic' else ''
|
||
u_attr = ' u="sng"' if 'underline' in text_dec else ''
|
||
strike_attr = ' strike="sngStrike"' if 'line-through' in text_dec else ''
|
||
|
||
fonts = parse_font_family(ff) if ff else default_fonts
|
||
run_fonts = resolve_text_run_fonts(text, fonts)
|
||
lang = detect_text_lang(text)
|
||
|
||
fill_xml = _build_text_fill_xml(fill, fill_raw, opacity, ctx)
|
||
outline_xml = _build_text_outline_xml(run)
|
||
|
||
space_attr = ' xml:space="preserve"' if text != text.strip() or ' ' in text else ''
|
||
|
||
return f'''<a:r>
|
||
<a:rPr lang="{lang}" sz="{sz}"{b_attr}{i_attr}{u_attr}{strike_attr} dirty="0">
|
||
{outline_xml}
|
||
{fill_xml}
|
||
{effect_xml}
|
||
<a:latin typeface="{_xml_escape(run_fonts['latin'])}"/>
|
||
<a:ea typeface="{_xml_escape(run_fonts['ea'])}"/>
|
||
<a:cs typeface="{_xml_escape(run_fonts['cs'])}"/>
|
||
</a:rPr>
|
||
<a:t{space_attr}>{_xml_escape(text)}</a:t>
|
||
</a:r>'''
|
||
|
||
|
||
def convert_text(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <text> to DrawingML text shape with multi-run support."""
|
||
x = ctx_x(_f(elem.get('x')), ctx)
|
||
y = ctx_y(_f(elem.get('y')), ctx)
|
||
font_size = _f(_get_attr(elem, 'font-size', ctx), 16) * ctx.scale_y
|
||
font_weight = _get_attr(elem, 'font-weight', ctx) or '400'
|
||
font_family_str = _get_attr(elem, 'font-family', ctx) or ''
|
||
text_anchor = _get_attr(elem, 'text-anchor', ctx) or 'start'
|
||
fill_raw = _get_attr(elem, 'fill', ctx) or '#000000'
|
||
fill_color = parse_hex_color(fill_raw) or '000000'
|
||
opacity = get_fill_opacity(elem, ctx)
|
||
stroke_raw = _get_attr(elem, 'stroke', ctx) or ''
|
||
stroke_width = _f(_get_attr(elem, 'stroke-width', ctx), 1.0)
|
||
stroke_opacity = get_stroke_opacity(elem, ctx)
|
||
font_style = _get_attr(elem, 'font-style', ctx) or ''
|
||
text_decoration = _get_attr(elem, 'text-decoration', ctx) or ''
|
||
|
||
fonts = parse_font_family(font_family_str)
|
||
|
||
parent_attrs: dict[str, Any] = {
|
||
'fill': fill_color,
|
||
'fill_raw': fill_raw,
|
||
'font_weight': font_weight,
|
||
'font_size': font_size,
|
||
'font_family': font_family_str,
|
||
'font_style': font_style,
|
||
'text_decoration': text_decoration,
|
||
'opacity': opacity,
|
||
'stroke_raw': stroke_raw,
|
||
'stroke_width': stroke_width,
|
||
'stroke_opacity': stroke_opacity,
|
||
}
|
||
|
||
# Paragraph mode: flatten_tspan marks <text> with data-paragraph-line-height
|
||
# when its direct-child tspans form a mergeable paragraph (same x, dy
|
||
# clustered around one base line-height). Each direct tspan becomes one
|
||
# <a:p> so the paragraph survives as a single editable text frame.
|
||
# Per-line data-paragraph-space-before encodes paragraph gaps (extra dy
|
||
# above the base line-height) for the corresponding <a:p>.
|
||
# Paragraph mode is controlled by ctx.merge_paragraphs. When off, ignore
|
||
# any data-paragraph-* markers and fall through to the original
|
||
# one-text-per-tspan path so the SVG's pixel layout is preserved.
|
||
line_height_attr = elem.get('data-paragraph-line-height') if ctx.merge_paragraphs else None
|
||
line_height_px = _f(line_height_attr) if line_height_attr is not None else None
|
||
paragraph_runs: list[list[dict[str, Any]]] | None = None
|
||
paragraph_space_before: list[float] = []
|
||
# Per-tspan widths (visual lines as the deck author drew them) regardless
|
||
# of how many merge into one <a:p>; used to size the textbox so PowerPoint
|
||
# has room to wrap text to the SVG's original line widths.
|
||
visual_line_widths: list[float] = []
|
||
if line_height_px is not None and line_height_px > 0:
|
||
preserve_space = _preserves_space(elem)
|
||
paragraph_runs = []
|
||
for child in elem:
|
||
if child.tag != f'{{{SVG_NS}}}tspan':
|
||
continue
|
||
line_runs = _collect_tspan_runs(child, parent_attrs, preserve_space)
|
||
if line_runs and not preserve_space:
|
||
line_runs[0]['text'] = line_runs[0]['text'].lstrip(' ')
|
||
line_runs[-1]['text'] = line_runs[-1]['text'].rstrip(' ')
|
||
line_runs = [r for r in line_runs if r['text']]
|
||
if not line_runs:
|
||
continue
|
||
visual_line_widths.append(
|
||
estimate_text_width(
|
||
''.join(r['text'] for r in line_runs),
|
||
font_size,
|
||
font_weight,
|
||
)
|
||
)
|
||
soft_break = child.get('data-paragraph-soft-break') == '1'
|
||
if soft_break and paragraph_runs:
|
||
# Append to the previous paragraph. A Latin line-wrap needs a
|
||
# space to keep two words apart (SVG used a dy break, not
|
||
# punctuation); CJK wraps mid-sentence with no inter-character
|
||
# space, so a joining space there is a spurious artifact.
|
||
prev = paragraph_runs[-1]
|
||
prev_text = prev[-1]['text'] if prev else ''
|
||
next_text = line_runs[0]['text']
|
||
boundary_is_cjk = (
|
||
(prev_text and is_cjk_char(prev_text[-1]))
|
||
or (next_text and is_cjk_char(next_text[0]))
|
||
)
|
||
if prev and not prev_text.endswith(' ') \
|
||
and not next_text.startswith(' ') \
|
||
and not boundary_is_cjk:
|
||
prev[-1] = {**prev[-1], 'text': prev_text + ' '}
|
||
prev.extend(line_runs)
|
||
else:
|
||
paragraph_runs.append(line_runs)
|
||
sb_attr = child.get('data-paragraph-space-before')
|
||
paragraph_space_before.append(_f(sb_attr) if sb_attr else 0.0)
|
||
if not paragraph_runs:
|
||
paragraph_runs = None
|
||
paragraph_space_before = []
|
||
visual_line_widths = []
|
||
|
||
if paragraph_runs is not None:
|
||
runs = [r for line in paragraph_runs for r in line]
|
||
else:
|
||
runs = _build_text_runs(elem, parent_attrs)
|
||
|
||
if not runs:
|
||
return None
|
||
|
||
full_text = ''.join(r['text'] for r in runs)
|
||
if not full_text.strip():
|
||
return None
|
||
|
||
# Estimate text dimensions
|
||
if paragraph_runs is not None:
|
||
# Use the WIDEST visual line (per-tspan as the deck author drew it),
|
||
# not the joined-up paragraph: soft-broken paragraphs concatenate
|
||
# many lines into one <a:p>, and measuring the joined string would
|
||
# blow the textbox past the canvas.
|
||
text_width = max(visual_line_widths) if visual_line_widths else 0.0
|
||
# Total height assumes the visual line count from the SVG source;
|
||
# if PowerPoint wraps to more or fewer lines after the user resizes,
|
||
# the user resizes the height accordingly.
|
||
text_height = (
|
||
line_height_px * (len(visual_line_widths) - 1)
|
||
+ sum(paragraph_space_before)
|
||
+ font_size * 1.5
|
||
)
|
||
else:
|
||
text_width = estimate_text_width(full_text, font_size, font_weight) * 1.05
|
||
text_height = font_size * 1.5
|
||
padding = font_size * 0.1
|
||
|
||
# Adjust position based on text-anchor
|
||
if text_anchor == 'middle':
|
||
box_x = x - text_width / 2 - padding
|
||
elif text_anchor == 'end':
|
||
box_x = x - text_width - padding
|
||
else:
|
||
box_x = x - padding
|
||
|
||
box_y = y - font_size * 0.85
|
||
box_w = text_width + padding * 2
|
||
box_h = text_height + padding
|
||
|
||
text_transform = elem.get('transform', '')
|
||
if text_transform and 'rotate' not in text_transform and not ctx.use_transform_matrix:
|
||
try:
|
||
a, b, c, d, e, f = parse_transform_matrix(text_transform)
|
||
except Exception:
|
||
a, b, c, d, e, f = 1.0, 0.0, 0.0, 1.0, 0.0, 0.0
|
||
# A pure-translate transform on a text element (hand-authored, or written
|
||
# by a live-preview move) was otherwise ignored here, drifting the text.
|
||
# Absorb the translation into the frame position; a scaling transform
|
||
# would also need to scale font size / line metrics, so leave
|
||
# non-translate transforms alone.
|
||
if (
|
||
abs(a - 1.0) < 1e-9 and abs(b) < 1e-9
|
||
and abs(c) < 1e-9 and abs(d - 1.0) < 1e-9
|
||
):
|
||
sx = ctx.scale_x or 1.0
|
||
sy = ctx.scale_y or 1.0
|
||
raw_box_x = (box_x - ctx.translate_x) / sx
|
||
raw_box_y = (box_y - ctx.translate_y) / sy
|
||
box_x = ctx.translate_x + sx * (a * raw_box_x + e)
|
||
box_y = ctx.translate_y + sy * (d * raw_box_y + f)
|
||
|
||
# Letter spacing
|
||
spc_attr = ''
|
||
letter_spacing = _get_attr(elem, 'letter-spacing', ctx)
|
||
if letter_spacing:
|
||
try:
|
||
spc_val = float(letter_spacing) * 100
|
||
spc_attr = f' spc="{int(spc_val)}"'
|
||
except ValueError:
|
||
pass
|
||
|
||
# Text rotation. SVG's rotate(angle [cx cy]) rotates around (cx, cy), but
|
||
# DrawingML's <a:xfrm rot="..."> rotates the shape around its own center.
|
||
# When a pivot is given (and differs from the box center), translate the
|
||
# box so its center lands where SVG would place the rotated visual center —
|
||
# otherwise rotated y-axis labels etc. drift to the wrong location.
|
||
text_rot = 0
|
||
if text_transform:
|
||
rot_match = re.search(
|
||
r'rotate\(\s*([-\d.]+)(?:[\s,]+([-\d.]+)[\s,]+([-\d.]+))?',
|
||
text_transform,
|
||
)
|
||
if rot_match:
|
||
angle_deg = float(rot_match.group(1))
|
||
text_rot = int(angle_deg * ANGLE_UNIT)
|
||
if rot_match.group(2) is not None:
|
||
pivot_x = ctx_x(float(rot_match.group(2)), ctx)
|
||
pivot_y = ctx_y(float(rot_match.group(3)), ctx)
|
||
cx_box = box_x + box_w / 2
|
||
cy_box = box_y + box_h / 2
|
||
rad = math.radians(angle_deg)
|
||
dx = cx_box - pivot_x
|
||
dy = cy_box - pivot_y
|
||
new_cx = pivot_x + dx * math.cos(rad) - dy * math.sin(rad)
|
||
new_cy = pivot_y + dx * math.sin(rad) + dy * math.cos(rad)
|
||
box_x = new_cx - box_w / 2
|
||
box_y = new_cy - box_h / 2
|
||
|
||
# Alignment
|
||
algn_map = {'start': 'l', 'middle': 'ctr', 'end': 'r'}
|
||
algn = algn_map.get(text_anchor, 'l')
|
||
|
||
# Shadow effect
|
||
shape_effect_xml = ''
|
||
text_effect_xml = ''
|
||
filt_id = get_effective_filter_id(elem, ctx)
|
||
if filt_id and filt_id in ctx.defs:
|
||
filter_elem = ctx.defs[filt_id]
|
||
effect_kind = classify_filter_effect(filter_elem)
|
||
if effect_kind == 'glow':
|
||
text_effect_xml = build_effect_xml(filter_elem)
|
||
elif effect_kind == 'shadow':
|
||
shape_effect_xml = build_effect_xml(filter_elem)
|
||
|
||
shape_id = ctx.next_id()
|
||
rot_attr = f' rot="{text_rot}"' if text_rot else ''
|
||
|
||
if paragraph_runs is not None:
|
||
# SVG dy(px) -> hundredths-of-a-point: dy_pt = dy_px * 0.75, then x100.
|
||
line_spc_val = round(line_height_px * FONT_PX_TO_HUNDREDTHS_PT)
|
||
ln_spc_xml = f'<a:lnSpc><a:spcPts val="{line_spc_val}"/></a:lnSpc>'
|
||
paragraph_xml_chunks = []
|
||
for line, extra_px in zip(paragraph_runs, paragraph_space_before):
|
||
spc_bef_xml = ''
|
||
if extra_px > 0:
|
||
spc_bef_val = round(extra_px * FONT_PX_TO_HUNDREDTHS_PT)
|
||
spc_bef_xml = f'<a:spcBef><a:spcPts val="{spc_bef_val}"/></a:spcBef>'
|
||
runs_inner = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in line)
|
||
paragraph_xml_chunks.append(
|
||
f'<a:p>\n<a:pPr algn="{algn}">{ln_spc_xml}{spc_bef_xml}</a:pPr>\n'
|
||
f'{runs_inner}\n</a:p>'
|
||
)
|
||
paragraphs_xml = '\n'.join(paragraph_xml_chunks)
|
||
else:
|
||
runs_xml = '\n'.join(_build_run_xml(r, fonts, ctx, text_effect_xml) for r in runs)
|
||
paragraphs_xml = f'<a:p>\n<a:pPr algn="{algn}"/>\n{runs_xml}\n</a:p>'
|
||
|
||
off_x = px_to_emu(box_x)
|
||
off_y = px_to_emu(box_y)
|
||
ext_cx = px_to_emu(box_w)
|
||
ext_cy = px_to_emu(box_h)
|
||
|
||
# Paragraph mode: wrap="square" so text reflows when the user resizes,
|
||
# but NO spAutoFit — otherwise PowerPoint expands the frame to fit a
|
||
# long joined-up <a:p> on one line, blowing past the canvas. The cx we
|
||
# write below (longest SVG line) is the design target width;
|
||
# PowerPoint wraps long paragraphs inside this width.
|
||
# Single-line text keeps wrap="none" + spAutoFit for tight fidelity.
|
||
if paragraph_runs is not None:
|
||
body_pr_xml = (
|
||
'<a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" '
|
||
'anchor="t" anchorCtr="0"/>'
|
||
)
|
||
else:
|
||
body_pr_xml = (
|
||
'<a:bodyPr wrap="none" lIns="0" tIns="0" rIns="0" bIns="0" '
|
||
'anchor="t" anchorCtr="0">\n<a:spAutoFit/>\n</a:bodyPr>'
|
||
)
|
||
|
||
return ShapeResult(xml=f'''<p:sp>
|
||
<p:nvSpPr>
|
||
<p:cNvPr id="{shape_id}" name="TextBox {shape_id}"/>
|
||
<p:cNvSpPr txBox="1"/><p:nvPr/>
|
||
</p:nvSpPr>
|
||
<p:spPr>
|
||
<a:xfrm{rot_attr}><a:off x="{off_x}" y="{off_y}"/>
|
||
<a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||
<a:noFill/>
|
||
<a:ln><a:noFill/></a:ln>
|
||
{shape_effect_xml}
|
||
</p:spPr>
|
||
<p:txBody>
|
||
{body_pr_xml}
|
||
<a:lstStyle/>
|
||
{paragraphs_xml}
|
||
</p:txBody>
|
||
</p:sp>''', bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# clipPath support (image clipping)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _clip_commands_to_geom(
|
||
commands: list[PathCommand],
|
||
img_x: float, img_y: float,
|
||
img_w: float, img_h: float,
|
||
object_bbox: bool,
|
||
) -> str:
|
||
"""Convert clip path commands to DrawingML custGeom XML.
|
||
|
||
Coordinates are transformed relative to the image bounding box so that
|
||
(img_x, img_y) maps to (0, 0) and (img_x+img_w, img_y+img_h) maps to
|
||
(w_emu, h_emu).
|
||
"""
|
||
w_emu = px_to_emu(img_w)
|
||
h_emu = px_to_emu(img_h)
|
||
|
||
if w_emu <= 0 or h_emu <= 0:
|
||
return '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
|
||
|
||
def _tx(x: float) -> int:
|
||
if object_bbox:
|
||
return int(x * w_emu)
|
||
return px_to_emu(x - img_x)
|
||
|
||
def _ty(y: float) -> int:
|
||
if object_bbox:
|
||
return int(y * h_emu)
|
||
return px_to_emu(y - img_y)
|
||
|
||
parts: list[str] = []
|
||
for cmd in commands:
|
||
if cmd.cmd == 'M':
|
||
parts.append(
|
||
f'<a:moveTo><a:pt x="{_tx(cmd.args[0])}" '
|
||
f'y="{_ty(cmd.args[1])}"/></a:moveTo>'
|
||
)
|
||
elif cmd.cmd == 'L':
|
||
parts.append(
|
||
f'<a:lnTo><a:pt x="{_tx(cmd.args[0])}" '
|
||
f'y="{_ty(cmd.args[1])}"/></a:lnTo>'
|
||
)
|
||
elif cmd.cmd == 'C':
|
||
pts = ''.join(
|
||
f'<a:pt x="{_tx(cmd.args[i])}" y="{_ty(cmd.args[i + 1])}"/>'
|
||
for i in range(0, 6, 2)
|
||
)
|
||
parts.append(f'<a:cubicBezTo>{pts}</a:cubicBezTo>')
|
||
elif cmd.cmd == 'Z':
|
||
parts.append('<a:close/>')
|
||
|
||
path_inner = '\n'.join(parts)
|
||
return f'''<a:custGeom>
|
||
<a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/>
|
||
<a:rect l="l" t="t" r="r" b="b"/>
|
||
<a:pathLst><a:path w="{w_emu}" h="{h_emu}">
|
||
{path_inner}
|
||
</a:path></a:pathLst>
|
||
</a:custGeom>'''
|
||
|
||
|
||
def _resolve_clip_geometry(
|
||
elem: ET.Element,
|
||
ctx: ConvertContext,
|
||
raw_x: float, raw_y: float,
|
||
raw_w: float, raw_h: float,
|
||
) -> str:
|
||
"""Resolve clip-path on an image element to DrawingML geometry XML.
|
||
|
||
Supports:
|
||
- circle / ellipse → prstGeom ellipse
|
||
- rect with rx/ry → prstGeom roundRect
|
||
- path / polygon → custGeom
|
||
|
||
Args:
|
||
elem: SVG element bearing a clip-path attribute.
|
||
ctx: Conversion context (carries defs).
|
||
raw_x, raw_y: Image position in SVG space (pre-ctx-transform).
|
||
raw_w, raw_h: Image dimensions in SVG space (pre-ctx-transform).
|
||
|
||
Returns:
|
||
DrawingML geometry XML string.
|
||
"""
|
||
DEFAULT = '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
|
||
|
||
clip_ref = elem.get('clip-path', '')
|
||
if not clip_ref or clip_ref == 'none':
|
||
return DEFAULT
|
||
|
||
clip_id = resolve_url_id(clip_ref)
|
||
if not clip_id or clip_id not in ctx.defs:
|
||
return DEFAULT
|
||
|
||
clip_elem = ctx.defs[clip_id]
|
||
clip_tag = clip_elem.tag.replace(f'{{{SVG_NS}}}', '')
|
||
if clip_tag != 'clipPath':
|
||
return DEFAULT
|
||
|
||
# Find the first shape child of the clipPath
|
||
shape = None
|
||
for child in clip_elem:
|
||
child_tag = child.tag.replace(f'{{{SVG_NS}}}', '')
|
||
if child_tag in ('circle', 'ellipse', 'rect', 'path', 'polygon'):
|
||
shape = child
|
||
break
|
||
|
||
if shape is None:
|
||
return DEFAULT
|
||
|
||
shape_tag = shape.tag.replace(f'{{{SVG_NS}}}', '')
|
||
is_obb = clip_elem.get('clipPathUnits') == 'objectBoundingBox'
|
||
|
||
# --- Circle / Ellipse → preset ellipse ---
|
||
if shape_tag in ('circle', 'ellipse'):
|
||
return '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
|
||
|
||
# --- Rect with rx/ry → preset roundRect ---
|
||
if shape_tag == 'rect':
|
||
rx = _f(shape.get('rx'))
|
||
ry = _f(shape.get('ry'), rx)
|
||
if rx <= 0 and ry <= 0:
|
||
return DEFAULT # plain rect clip is a no-op
|
||
r = max(rx, ry)
|
||
if is_obb:
|
||
r = r * min(raw_w, raw_h)
|
||
shorter = min(raw_w, raw_h)
|
||
if shorter <= 0:
|
||
return DEFAULT
|
||
adj = int(min(r / (shorter / 2), 1.0) * 50000)
|
||
return (
|
||
f'<a:prstGeom prst="roundRect"><a:avLst>'
|
||
f'<a:gd name="adj" fmla="val {adj}"/>'
|
||
f'</a:avLst></a:prstGeom>'
|
||
)
|
||
|
||
# --- Path → custGeom ---
|
||
if shape_tag == 'path':
|
||
d = shape.get('d', '')
|
||
if not d:
|
||
return DEFAULT
|
||
commands = parse_svg_path(d)
|
||
commands = svg_path_to_absolute(commands)
|
||
commands = normalize_path_commands(commands)
|
||
if not commands:
|
||
return DEFAULT
|
||
return _clip_commands_to_geom(
|
||
commands, raw_x, raw_y, raw_w, raw_h, is_obb,
|
||
)
|
||
|
||
# --- Polygon → custGeom ---
|
||
if shape_tag == 'polygon':
|
||
pts = _parse_points(shape.get('points', ''))
|
||
if not pts:
|
||
return DEFAULT
|
||
commands = [PathCommand('M', [pts[0][0], pts[0][1]])]
|
||
for px_, py_ in pts[1:]:
|
||
commands.append(PathCommand('L', [px_, py_]))
|
||
commands.append(PathCommand('Z', []))
|
||
return _clip_commands_to_geom(
|
||
commands, raw_x, raw_y, raw_w, raw_h, is_obb,
|
||
)
|
||
|
||
return DEFAULT
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# image
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _picture_xfrm_from_rect(
|
||
ctx: ConvertContext,
|
||
x: float,
|
||
y: float,
|
||
w: float,
|
||
h: float,
|
||
) -> tuple[str, int, int, int, int, tuple[int, int, int, int]]:
|
||
"""Build DrawingML xfrm data for a picture rectangle.
|
||
|
||
Coordinates ``x``, ``y``, ``w``, ``h`` MUST already be in ctx-resolved
|
||
space (i.e. callers have applied ``ctx_x`` / ``ctx_w`` upstream). When
|
||
``ctx.use_transform_matrix`` is set, raw SVG-space coordinates are
|
||
expected and the matrix path applies the transform itself.
|
||
"""
|
||
if ctx.use_transform_matrix:
|
||
return rect_to_dml_xfrm(x, y, w, h, ctx.transform_matrix)
|
||
|
||
off_x = px_to_emu(x)
|
||
off_y = px_to_emu(y)
|
||
ext_cx = px_to_emu(w)
|
||
ext_cy = px_to_emu(h)
|
||
return '', off_x, off_y, ext_cx, ext_cy, (off_x, off_y, off_x + ext_cx, off_y + ext_cy)
|
||
|
||
|
||
def _read_image_size(data: bytes) -> tuple[int | None, int | None]:
|
||
"""Read intrinsic image dimensions (width, height) from raw bytes.
|
||
|
||
Used by ``convert_image`` to translate SVG ``preserveAspectRatio`` into
|
||
DrawingML ``<a:srcRect>`` so the original image is preserved and remains
|
||
croppable inside PowerPoint.
|
||
|
||
Returns ``(None, None)`` on any failure — callers fall back to the
|
||
legacy stretch behaviour.
|
||
"""
|
||
try:
|
||
from PIL import Image, UnidentifiedImageError # type: ignore
|
||
except ImportError:
|
||
return (None, None)
|
||
try:
|
||
with Image.open(io.BytesIO(data)) as img:
|
||
return img.size
|
||
except (UnidentifiedImageError, OSError, ValueError):
|
||
return (None, None)
|
||
|
||
|
||
def _compute_slice_src_rect(
|
||
img_w: float, img_h: float,
|
||
box_w: float, box_h: float,
|
||
align: str,
|
||
) -> tuple[int, int, int, int] | None:
|
||
"""Compute DrawingML ``<a:srcRect>`` (l, t, r, b) for SVG slice mode.
|
||
|
||
SVG ``preserveAspectRatio="<align> slice"`` means: scale the image so it
|
||
fully covers the box (CSS object-fit: cover) and crop the overflow at the
|
||
given alignment anchor. DrawingML ``srcRect`` expresses the same intent
|
||
by specifying which sub-rectangle of the source image to display, in
|
||
units of 1/1000 of a percent (0–100000).
|
||
|
||
Returns ``None`` when no cropping is required (image and box already
|
||
match) or when inputs are degenerate.
|
||
"""
|
||
if img_w <= 0 or img_h <= 0 or box_w <= 0 or box_h <= 0:
|
||
return None
|
||
|
||
# Scale factor that makes the image cover the box (cover semantics).
|
||
scale = max(box_w / img_w, box_h / img_h)
|
||
visible_w = box_w / scale # ≤ img_w
|
||
visible_h = box_h / scale # ≤ img_h
|
||
|
||
if abs(visible_w - img_w) < 0.5 and abs(visible_h - img_h) < 0.5:
|
||
return None # No crop needed
|
||
|
||
crop_w_total = max(0.0, img_w - visible_w)
|
||
crop_h_total = max(0.0, img_h - visible_h)
|
||
|
||
x_anchor = {'xMin': 0.0, 'xMid': 0.5, 'xMax': 1.0}.get(align[:4], 0.5)
|
||
y_anchor = {'YMin': 0.0, 'YMid': 0.5, 'YMax': 1.0}.get(align[4:], 0.5)
|
||
|
||
crop_l = crop_w_total * x_anchor
|
||
crop_r = crop_w_total - crop_l
|
||
crop_t = crop_h_total * y_anchor
|
||
crop_b = crop_h_total - crop_t
|
||
|
||
l = max(0, min(100000, int(round(crop_l / img_w * 100000))))
|
||
t = max(0, min(100000, int(round(crop_t / img_h * 100000))))
|
||
r = max(0, min(100000, int(round(crop_r / img_w * 100000))))
|
||
b = max(0, min(100000, int(round(crop_b / img_h * 100000))))
|
||
|
||
return (l, t, r, b)
|
||
|
||
|
||
def _resolve_image_src_rect(
|
||
elem: ET.Element,
|
||
img_data: bytes,
|
||
box_w: float, box_h: float,
|
||
) -> str:
|
||
"""Build ``<a:srcRect .../>`` XML for an SVG <image> based on its
|
||
preserveAspectRatio. Returns an empty string when no srcRect is needed
|
||
(meet mode, none mode, or already-aligned content).
|
||
|
||
Slice mode is resolved into a srcRect so the original image is embedded
|
||
intact and PowerPoint's crop tool / "Reset Picture" continue to work.
|
||
Meet mode is handled separately by ``_resolve_image_meet_fit`` (which
|
||
shrinks the picture frame to match image aspect ratio); none mode keeps
|
||
the legacy stretch behaviour intentionally.
|
||
"""
|
||
par = (elem.get('preserveAspectRatio') or 'xMidYMid meet').strip()
|
||
parts = par.split()
|
||
align = parts[0] if parts else 'xMidYMid'
|
||
mode = parts[1] if len(parts) > 1 else 'meet'
|
||
|
||
if align == 'none' or mode != 'slice':
|
||
return '' # meet handled by frame fit; none → stretch is correct per SVG spec
|
||
|
||
img_w, img_h = _read_image_size(img_data)
|
||
if img_w is None or img_h is None:
|
||
return ''
|
||
|
||
rect = _compute_slice_src_rect(float(img_w), float(img_h), box_w, box_h, align)
|
||
if rect is None:
|
||
return ''
|
||
|
||
l, t, r, b = rect
|
||
return f'<a:srcRect l="{l}" t="{t}" r="{r}" b="{b}"/>'
|
||
|
||
|
||
def _resolve_image_meet_fit(
|
||
elem: ET.Element,
|
||
img_data: bytes,
|
||
box_w: float, box_h: float,
|
||
) -> tuple[float, float, float, float] | None:
|
||
"""For SVG ``preserveAspectRatio="<align> meet"``, compute the letterboxed
|
||
sub-rectangle ``(dx, dy, fit_w, fit_h)`` inside the original box that
|
||
matches the image's intrinsic aspect ratio.
|
||
|
||
PowerPoint has no native ``meet`` semantic — ``<a:stretch><a:fillRect/>``
|
||
fills the entire frame and would distort the image whenever the SVG
|
||
container ratio differs from the source image ratio. The fix is to shrink
|
||
the ``<p:pic>`` frame itself (off + ext) so the frame and image share an
|
||
aspect ratio; the stretch then fills a correctly-shaped frame.
|
||
|
||
Returns ``None`` when the adjustment is not applicable:
|
||
- mode is ``slice`` (handled by srcRect path)
|
||
- align is ``none`` (SVG spec says: stretch — do not adjust)
|
||
- intrinsic image dimensions cannot be read
|
||
- frame already matches image ratio (no-op)
|
||
"""
|
||
par = (elem.get('preserveAspectRatio') or 'xMidYMid meet').strip()
|
||
parts = par.split()
|
||
align = parts[0] if parts else 'xMidYMid'
|
||
mode = parts[1] if len(parts) > 1 else 'meet'
|
||
|
||
if align == 'none' or mode == 'slice':
|
||
return None
|
||
|
||
img_w, img_h = _read_image_size(img_data)
|
||
if img_w is None or img_h is None or img_w <= 0 or img_h <= 0:
|
||
return None
|
||
if box_w <= 0 or box_h <= 0:
|
||
return None
|
||
|
||
scale = min(box_w / img_w, box_h / img_h)
|
||
fit_w = img_w * scale
|
||
fit_h = img_h * scale
|
||
|
||
if abs(fit_w - box_w) < 0.5 and abs(fit_h - box_h) < 0.5:
|
||
return None # already matches — no adjustment
|
||
|
||
x_anchor = {'xMin': 0.0, 'xMid': 0.5, 'xMax': 1.0}.get(align[:4], 0.5)
|
||
y_anchor = {'YMin': 0.0, 'YMid': 0.5, 'YMax': 1.0}.get(align[4:], 0.5)
|
||
|
||
dx = (box_w - fit_w) * x_anchor
|
||
dy = (box_h - fit_h) * y_anchor
|
||
|
||
return (dx, dy, fit_w, fit_h)
|
||
|
||
|
||
def convert_image(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <image> to DrawingML picture element.
|
||
|
||
Supports clip-path attribute: when present, the clipPath shape is mapped
|
||
to DrawingML picture geometry (prstGeom or custGeom) so the image is
|
||
natively clipped in PowerPoint.
|
||
"""
|
||
href = elem.get('href') or elem.get(f'{{{XLINK_NS}}}href')
|
||
if not href:
|
||
return None
|
||
|
||
# Raw coordinates (pre-context-transform) for clip path calculations
|
||
raw_x = _f(elem.get('x'))
|
||
raw_y = _f(elem.get('y'))
|
||
raw_w = _f(elem.get('width'))
|
||
raw_h = _f(elem.get('height'))
|
||
|
||
if ctx.use_transform_matrix:
|
||
x = raw_x
|
||
y = raw_y
|
||
w = raw_w
|
||
h = raw_h
|
||
else:
|
||
x = ctx_x(raw_x, ctx)
|
||
y = ctx_y(raw_y, ctx)
|
||
w = ctx_w(raw_w, ctx)
|
||
h = ctx_h(raw_h, ctx)
|
||
|
||
if w <= 0 or h <= 0:
|
||
return None
|
||
|
||
# Extract image data
|
||
if href.startswith('data:'):
|
||
match = re.match(r'data:image/([A-Za-z0-9.+-]+);base64,(.+)', href, re.DOTALL)
|
||
if not match:
|
||
return None
|
||
img_format = match.group(1).lower()
|
||
if img_format == 'svg+xml':
|
||
img_format = 'svg'
|
||
if img_format == 'jpeg':
|
||
img_format = 'jpg'
|
||
img_data = base64.b64decode(match.group(2))
|
||
else:
|
||
if ctx.svg_dir is None:
|
||
return None
|
||
img_path = _resolve_external_image(ctx.svg_dir, href)
|
||
img_format = img_path.suffix.lstrip('.').lower()
|
||
if img_format == 'jpeg':
|
||
img_format = 'jpg'
|
||
img_data = img_path.read_bytes()
|
||
|
||
img_idx = len(ctx.media_files) + 1
|
||
img_filename = f's{ctx.slide_num}_img{img_idx}.{img_format}'
|
||
ctx.media_files[img_filename] = img_data
|
||
|
||
r_id = ctx.next_rel_id()
|
||
ctx.rel_entries.append({
|
||
'id': r_id,
|
||
'type': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
|
||
'target': f'../media/{img_filename}',
|
||
})
|
||
|
||
rot = 0
|
||
transform = elem.get('transform')
|
||
if transform and not ctx.use_transform_matrix:
|
||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
||
if r_match:
|
||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
||
rot_attr = f' rot="{rot}"' if rot else ''
|
||
|
||
# Resolve clip-path → DrawingML geometry
|
||
clip_geom = _resolve_clip_geometry(elem, ctx, raw_x, raw_y, raw_w, raw_h)
|
||
|
||
# Resolve preserveAspectRatio="<align> slice" → DrawingML <a:srcRect>.
|
||
# This keeps the original image intact in the .pptx and lets users
|
||
# re-crop or reset the picture in PowerPoint, instead of permanently
|
||
# baking the crop into the embedded asset.
|
||
src_rect_xml = _resolve_image_src_rect(elem, img_data, w, h)
|
||
|
||
# Resolve preserveAspectRatio="<align> meet" by shrinking the picture
|
||
# frame to match the image's aspect ratio. Skipped when a real clip-path
|
||
# produces non-trivial geometry: such clip rectangles are defined against
|
||
# the original box and would no longer line up after a frame shift.
|
||
# A clip-path that resolves back to the default rect geometry (e.g. plain
|
||
# <rect> without rx/ry) is a no-op and must not block meet adjustment.
|
||
clip_is_noop = clip_geom == '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
|
||
meet_fit = None if not clip_is_noop else _resolve_image_meet_fit(elem, img_data, w, h)
|
||
|
||
shape_id = ctx.next_id()
|
||
if meet_fit is not None:
|
||
dx, dy, fit_w, fit_h = meet_fit
|
||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_rect(
|
||
ctx, x + dx, y + dy, fit_w, fit_h,
|
||
)
|
||
else:
|
||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_rect(
|
||
ctx, x, y, w, h,
|
||
)
|
||
if rot_attr:
|
||
xfrm_attr += rot_attr
|
||
|
||
return ShapeResult(xml=f'''<p:pic>
|
||
<p:nvPicPr>
|
||
<p:cNvPr id="{shape_id}" name="Image {shape_id}"/>
|
||
<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
|
||
<p:nvPr/>
|
||
</p:nvPicPr>
|
||
<p:blipFill>
|
||
<a:blip r:embed="{r_id}"/>
|
||
{src_rect_xml}<a:stretch><a:fillRect/></a:stretch>
|
||
</p:blipFill>
|
||
<p:spPr>
|
||
<a:xfrm{xfrm_attr}><a:off x="{off_x}" y="{off_y}"/>
|
||
<a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||
{clip_geom}
|
||
</p:spPr>
|
||
</p:pic>''', bounds_emu=bounds_emu)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ellipse
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def convert_ellipse(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert SVG <ellipse> to DrawingML ellipse shape."""
|
||
cx_ = ctx_x(_f(elem.get('cx')), ctx)
|
||
cy_ = ctx_y(_f(elem.get('cy')), ctx)
|
||
rx = _f(elem.get('rx')) * ctx.scale_x
|
||
ry = _f(elem.get('ry')) * ctx.scale_y
|
||
|
||
if rx <= 0 or ry <= 0:
|
||
return None
|
||
|
||
x = cx_ - rx
|
||
y = cy_ - ry
|
||
w = rx * 2
|
||
h = ry * 2
|
||
|
||
fill_op = get_fill_opacity(elem, ctx)
|
||
stroke_op = get_stroke_opacity(elem, ctx)
|
||
fill = build_fill_xml(elem, ctx, fill_op)
|
||
stroke = build_stroke_xml(elem, ctx, stroke_op)
|
||
|
||
geom = '<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>'
|
||
|
||
rot = 0
|
||
transform = elem.get('transform')
|
||
if transform:
|
||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
||
if r_match:
|
||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
||
|
||
shape_id = ctx.next_id()
|
||
off_x = px_to_emu(x)
|
||
off_y = px_to_emu(y)
|
||
ext_cx = px_to_emu(w)
|
||
ext_cy = px_to_emu(h)
|
||
return ShapeResult(
|
||
xml=_wrap_shape(
|
||
shape_id, f'Ellipse {shape_id}',
|
||
off_x, off_y, ext_cx, ext_cy,
|
||
geom, fill, stroke, rot=rot,
|
||
),
|
||
bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# nested <svg> sprite (template-import round-trip)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Inverse of pptx_to_svg/pic_to_svg.py:101-113 — that path writes a cropped
|
||
# DrawingML picture as an outer <svg viewBox> wrapping a unit-rectangle <image>.
|
||
# Without this converter, every cropped picture in a template-import SVG is
|
||
# silently dropped on re-export.
|
||
|
||
def convert_nested_svg(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
|
||
"""Convert a nested <svg> sprite-crop wrapper to a DrawingML picture.
|
||
|
||
Pattern produced by pptx_to_svg::
|
||
|
||
<svg x="10" y="20" width="200" height="300" viewBox="0.5 0.3 0.5 0.7">
|
||
<image href="..." x="0" y="0" width="1" height="1" preserveAspectRatio="none"/>
|
||
</svg>
|
||
|
||
The viewBox crops the unit-rectangle inner image; that crop is mapped to a
|
||
DrawingML <a:srcRect> so PowerPoint can re-crop / "Reset Picture".
|
||
"""
|
||
image_elem = elem.find(f'{{{SVG_NS}}}image')
|
||
if image_elem is None:
|
||
image_elem = elem.find('image')
|
||
if image_elem is None:
|
||
return None
|
||
|
||
href = image_elem.get('href') or image_elem.get(f'{{{XLINK_NS}}}href')
|
||
if not href:
|
||
return None
|
||
|
||
svg_x = _f(elem.get('x'))
|
||
svg_y = _f(elem.get('y'))
|
||
svg_w = _f(elem.get('width'))
|
||
svg_h = _f(elem.get('height'))
|
||
if svg_w <= 0 or svg_h <= 0:
|
||
return None
|
||
|
||
if ctx.use_transform_matrix:
|
||
x = svg_x
|
||
y = svg_y
|
||
w = svg_w
|
||
h = svg_h
|
||
else:
|
||
x = ctx_x(svg_x, ctx)
|
||
y = ctx_y(svg_y, ctx)
|
||
w = ctx_w(svg_w, ctx)
|
||
h = ctx_h(svg_h, ctx)
|
||
|
||
src_rect_xml = ''
|
||
view_box = elem.get('viewBox', '')
|
||
if view_box:
|
||
parts = view_box.strip().split()
|
||
if len(parts) == 4:
|
||
vb_x, vb_y, vb_w, vb_h = (float(p) for p in parts)
|
||
l = max(0, min(int(round(vb_x * 100000)), 100000))
|
||
t = max(0, min(int(round(vb_y * 100000)), 100000))
|
||
r = max(0, min(int(round((1.0 - vb_x - vb_w) * 100000)), 100000))
|
||
b = max(0, min(int(round((1.0 - vb_y - vb_h) * 100000)), 100000))
|
||
if l or t or r or b:
|
||
src_rect_xml = f'<a:srcRect l="{l}" t="{t}" r="{r}" b="{b}"/>'
|
||
|
||
if href.startswith('data:'):
|
||
match = re.match(r'data:image/([A-Za-z0-9.+-]+);base64,(.+)', href, re.DOTALL)
|
||
if not match:
|
||
return None
|
||
img_format = match.group(1).lower()
|
||
if img_format == 'svg+xml':
|
||
img_format = 'svg'
|
||
if img_format == 'jpeg':
|
||
img_format = 'jpg'
|
||
img_data = base64.b64decode(match.group(2))
|
||
else:
|
||
if ctx.svg_dir is None:
|
||
return None
|
||
img_path = _resolve_external_image(ctx.svg_dir, href)
|
||
img_format = img_path.suffix.lstrip('.').lower()
|
||
if img_format == 'jpeg':
|
||
img_format = 'jpg'
|
||
img_data = img_path.read_bytes()
|
||
|
||
img_idx = len(ctx.media_files) + 1
|
||
img_filename = f's{ctx.slide_num}_img{img_idx}.{img_format}'
|
||
ctx.media_files[img_filename] = img_data
|
||
|
||
r_id = ctx.next_rel_id()
|
||
ctx.rel_entries.append({
|
||
'id': r_id,
|
||
'type': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
|
||
'target': f'../media/{img_filename}',
|
||
})
|
||
|
||
rot = 0
|
||
transform = elem.get('transform')
|
||
if transform and not ctx.use_transform_matrix:
|
||
r_match = re.search(r'rotate\(\s*([-\d.]+)', transform)
|
||
if r_match:
|
||
rot = int(float(r_match.group(1)) * ANGLE_UNIT)
|
||
rot_attr = f' rot="{rot}"' if rot else ''
|
||
|
||
shape_id = ctx.next_id()
|
||
xfrm_attr, off_x, off_y, ext_cx, ext_cy, bounds_emu = _picture_xfrm_from_rect(
|
||
ctx, x, y, w, h,
|
||
)
|
||
if rot_attr:
|
||
xfrm_attr += rot_attr
|
||
clip_geom = _resolve_clip_geometry(elem, ctx, svg_x, svg_y, svg_w, svg_h)
|
||
|
||
return ShapeResult(xml=f'''<p:pic>
|
||
<p:nvPicPr>
|
||
<p:cNvPr id="{shape_id}" name="Image {shape_id}"/>
|
||
<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
|
||
<p:nvPr/>
|
||
</p:nvPicPr>
|
||
<p:blipFill>
|
||
<a:blip r:embed="{r_id}"/>
|
||
{src_rect_xml}<a:stretch><a:fillRect/></a:stretch>
|
||
</p:blipFill>
|
||
<p:spPr>
|
||
<a:xfrm{xfrm_attr}><a:off x="{off_x}" y="{off_y}"/>
|
||
<a:ext cx="{ext_cx}" cy="{ext_cy}"/></a:xfrm>
|
||
{clip_geom}
|
||
</p:spPr>
|
||
</p:pic>''', bounds_emu=bounds_emu)
|