"""EPS and EMF figures for office documents, from the publication SVG (#96).
Word processors and slide programs take EMF (Windows Enhanced Metafile) and
EPS as editable vector pictures. Both are written here from the same scene
SVG the other formats come from, so a figure looks the same in every format:
the SVG is reduced to filled and stroked paths, the text outlined from the
bundled Roboto face (so no font is needed where the file is opened), and the
paths written in each format. No converter or font is taken from the system.
Formats:
- EPS: Adobe "Encapsulated PostScript File Format Specification" 3.0 and the
PostScript Language Reference, 3rd ed.; one point is one PostScript unit.
- EMF: Microsoft "[MS-EMF]: Enhanced Metafile Format", records EMR_HEADER,
EMR_EXTCREATEPEN, EMR_CREATEBRUSHINDIRECT, EMR_SELECTOBJECT, the path
records and EMR_EOF; coordinates in twentieths of a point (a 1440 dpi
reference device) and the frame in hundredths of a millimetre.
- SVG elliptical arcs follow the SVG 1.1 implementation notes, appendix F.6,
and are approximated by cubic Béziers of at most a quarter turn.
"""
from __future__ import annotations
import math
import re
import struct
import xml.etree.ElementTree as ET
from collections.abc import Callable
from dataclasses import dataclass, field
from vibesketch.render import RenderRefused
from vibesketch.render.fonts import NS, glyph_of, outline, units_per_em
__all__ = ["Paint", "eps", "emf", "paints"]
Point = tuple[float, float]
#: A path operation: ("M", p), ("L", p), ("C", c1, c2, p) or ("Z",).
Op = tuple[object, ...]
Matrix = tuple[float, float, float, float, float, float]
_IDENTITY: Matrix = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
[docs]
@dataclass
class Paint:
"""One path, filled and/or stroked, in figure points with y down."""
ops: list[Op]
fill: tuple[float, float, float] | None
stroke: tuple[float, float, float] | None
width: float = 1.0
evenodd: bool = False
cap: str = "butt"
join: str = "miter"
@dataclass
class _Style:
fill: str = "#000"
stroke: str = "none"
width: float = 1.0
fill_opacity: float = 1.0
stroke_opacity: float = 1.0
rule: str = "nonzero"
cap: str = "butt"
join: str = "miter"
matrix: Matrix = _IDENTITY
extra: dict[str, str] = field(default_factory=dict)
def _multiply(a: Matrix, b: Matrix) -> Matrix:
"""``a`` after ``b``: the matrix of applying ``b`` first."""
a0, a1, a2, a3, a4, a5 = a
b0, b1, b2, b3, b4, b5 = b
return (
a0 * b0 + a2 * b1,
a1 * b0 + a3 * b1,
a0 * b2 + a2 * b3,
a1 * b2 + a3 * b3,
a0 * b4 + a2 * b5 + a4,
a1 * b4 + a3 * b5 + a5,
)
def _transform(text: str) -> Matrix:
result = _IDENTITY
for name, args in re.findall(r"(\w+)\s*\(([^)]*)\)", text):
values = [float(v) for v in re.split(r"[\s,]+", args.strip()) if v]
if name == "translate":
step: Matrix = (1, 0, 0, 1, values[0], values[1] if len(values) > 1 else 0.0)
elif name == "scale":
sx = values[0]
sy = values[1] if len(values) > 1 else sx
step = (sx, 0, 0, sy, 0, 0)
elif name == "rotate":
angle = math.radians(values[0])
cos, sin = math.cos(angle), math.sin(angle)
step = (cos, sin, -sin, cos, 0, 0)
if len(values) == 3:
cx, cy = values[1], values[2]
step = _multiply((1, 0, 0, 1, cx, cy), _multiply(step, (1, 0, 0, 1, -cx, -cy)))
elif name == "matrix" and len(values) == 6:
step = (values[0], values[1], values[2], values[3], values[4], values[5])
else:
raise RenderRefused(f"cannot convert SVG transform {name!r}")
result = _multiply(result, step)
return result
def _apply(matrix: Matrix, point: Point) -> Point:
a, b, c, d, e, f = matrix
x, y = point
return (a * x + c * y + e, b * x + d * y + f)
def _colour(text: str, opacity: float) -> tuple[float, float, float] | None:
"""An SVG colour as RGB from 0 to 1, blended onto white by its opacity."""
text = text.strip().lower()
if text in ("none", "transparent", ""):
return None
names = {"black": "#000000", "white": "#ffffff"}
text = names.get(text, text)
match = re.fullmatch(r"#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})", text)
if match is None:
raise RenderRefused(f"cannot convert SVG colour {text!r}")
value = match[1]
if len(value) == 3:
value = "".join(c * 2 for c in value)
if len(value) == 8:
opacity *= int(value[6:8], 16) / 255
value = value[:6]
rgb = [int(value[i : i + 2], 16) / 255 for i in (0, 2, 4)]
red, green, blue = (opacity * c + (1 - opacity) for c in rgb)
return (red, green, blue)
def _style(element: ET.Element, parent: _Style) -> _Style:
own: dict[str, str] = {}
for key in ("fill", "stroke", "stroke-width", "fill-opacity", "stroke-opacity",
"fill-rule", "stroke-linecap", "stroke-linejoin", "opacity"): # fmt: skip
if key in element.attrib:
own[key] = element.attrib[key]
for declaration in element.attrib.get("style", "").split(";"):
if ":" in declaration:
key, value = declaration.split(":", 1)
own[key.strip()] = value.strip()
style = _Style(**{**parent.__dict__, "extra": dict(parent.extra)})
style.fill = own.get("fill", parent.fill)
style.stroke = own.get("stroke", parent.stroke)
if "stroke-width" in own:
style.width = float(own["stroke-width"].removesuffix("px").removesuffix("pt"))
opacity = float(own.get("opacity", 1))
style.fill_opacity = float(own.get("fill-opacity", parent.fill_opacity)) * opacity
style.stroke_opacity = float(own.get("stroke-opacity", parent.stroke_opacity)) * opacity
style.rule = own.get("fill-rule", parent.rule)
style.cap = own.get("stroke-linecap", parent.cap)
style.join = own.get("stroke-linejoin", parent.join)
if "transform" in element.attrib:
style.matrix = _multiply(parent.matrix, _transform(element.attrib["transform"]))
return style
def _arc(start: Point, rx: float, ry: float, phi: float, large: bool, sweep: bool,
end: Point) -> list[Op]: # fmt: skip
"""An SVG elliptical arc as cubic Béziers (SVG 1.1, appendix F.6.5)."""
if start == end:
return []
if rx == 0 or ry == 0:
return [("L", end)]
rx, ry = abs(rx), abs(ry)
cos, sin = math.cos(math.radians(phi)), math.sin(math.radians(phi))
dx, dy = (start[0] - end[0]) / 2, (start[1] - end[1]) / 2
x1, y1 = cos * dx + sin * dy, -sin * dx + cos * dy
scale = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry)
if scale > 1:
rx, ry = rx * math.sqrt(scale), ry * math.sqrt(scale)
numerator = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1
root = math.sqrt(max(0.0, numerator / (rx * rx * y1 * y1 + ry * ry * x1 * x1)))
if large == sweep:
root = -root
cx1, cy1 = root * rx * y1 / ry, -root * ry * x1 / rx
cx = cos * cx1 - sin * cy1 + (start[0] + end[0]) / 2
cy = sin * cx1 + cos * cy1 + (start[1] + end[1]) / 2
def angle(ux: float, uy: float, vx: float, vy: float) -> float:
return math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)
theta = angle(1, 0, (x1 - cx1) / rx, (y1 - cy1) / ry)
delta = angle((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry)
if not sweep and delta > 0:
delta -= 2 * math.pi
elif sweep and delta < 0:
delta += 2 * math.pi
pieces = max(1, math.ceil(abs(delta) / (math.pi / 2)))
step = delta / pieces
k = 4 / 3 * math.tan(step / 4)
def rotate(x: float, y: float) -> Point:
return (cos * x - sin * y, sin * x + cos * y)
def at(t: float) -> tuple[Point, Point]:
px, py = rotate(rx * math.cos(t), ry * math.sin(t))
tangent = rotate(-rx * math.sin(t), ry * math.cos(t))
return (cx + px, cy + py), tangent
ops: list[Op] = []
for index in range(pieces):
a, b = theta + index * step, theta + (index + 1) * step
(p0, d0), (p1, d1) = at(a), at(b)
ops.append(
(
"C",
(p0[0] + k * d0[0], p0[1] + k * d0[1]),
(p1[0] - k * d1[0], p1[1] - k * d1[1]),
p1,
)
)
return ops
def _path(data: str) -> list[Op]:
"""SVG path data as absolute moves, lines, cubic curves and closes."""
tokens = re.findall(r"[MmLlHhVvCcSsQqTtAaZz]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?", data)
ops: list[Op] = []
index, command = 0, ""
current: Point = (0.0, 0.0)
start: Point = (0.0, 0.0)
last_control: Point | None = None
def number() -> float:
nonlocal index
value = float(tokens[index])
index += 1
return value
while index < len(tokens):
if re.fullmatch(r"[A-Za-z]", tokens[index]):
command = tokens[index]
index += 1
relative = command.islower()
kind = command.upper()
ox, oy = current if relative else (0.0, 0.0)
if kind == "Z":
ops.append(("Z",))
current, last_control = start, None
continue
if kind == "M":
current = (ox + number(), oy + number())
start = current
ops.append(("M", current))
command = "l" if relative else "L"
elif kind == "L":
current = (ox + number(), oy + number())
ops.append(("L", current))
elif kind == "H":
current = ((current[0] if relative else 0.0) + number(), current[1])
ops.append(("L", current))
elif kind == "V":
current = (current[0], (current[1] if relative else 0.0) + number())
ops.append(("L", current))
elif kind == "C":
c1 = (ox + number(), oy + number())
c2 = (ox + number(), oy + number())
current = (ox + number(), oy + number())
ops.append(("C", c1, c2, current))
last_control = c2
continue
elif kind == "S":
reflected = (
(2 * current[0] - last_control[0], 2 * current[1] - last_control[1])
if last_control
else current
)
c2 = (ox + number(), oy + number())
end = (ox + number(), oy + number())
ops.append(("C", reflected, c2, end))
current, last_control = end, c2
continue
elif kind in ("Q", "T"):
if kind == "Q":
control = (ox + number(), oy + number())
else:
control = (
(2 * current[0] - last_control[0], 2 * current[1] - last_control[1])
if last_control
else current
)
end = (ox + number(), oy + number())
ops.append(_quadratic(current, control, end))
current, last_control = end, control
continue
elif kind == "A":
rx, ry, phi = number(), number(), number()
large, sweep = bool(number()), bool(number())
end = (ox + number(), oy + number())
ops.extend(_arc(current, rx, ry, phi, large, sweep, end))
current = end
else:
raise RenderRefused(f"cannot convert SVG path command {command!r}")
last_control = None
return ops
def _quadratic(start: Point, control: Point, end: Point) -> Op:
return (
"C",
(start[0] + 2 / 3 * (control[0] - start[0]), start[1] + 2 / 3 * (control[1] - start[1])),
(end[0] + 2 / 3 * (control[0] - end[0]), end[1] + 2 / 3 * (control[1] - end[1])),
end,
)
def _ellipse(cx: float, cy: float, rx: float, ry: float) -> list[Op]:
k = 4 / 3 * (math.sqrt(2) - 1)
return [
("M", (cx + rx, cy)),
("C", (cx + rx, cy + k * ry), (cx + k * rx, cy + ry), (cx, cy + ry)),
("C", (cx - k * rx, cy + ry), (cx - rx, cy + k * ry), (cx - rx, cy)),
("C", (cx - rx, cy - k * ry), (cx - k * rx, cy - ry), (cx, cy - ry)),
("C", (cx + k * rx, cy - ry), (cx + rx, cy - k * ry), (cx + rx, cy)),
("Z",),
]
def _glyphs(text: str, x: float, y: float, size: float, anchor: str) -> list[Op]:
"""``text`` outlined from the publication font, its baseline at ``y``."""
scale = size / units_per_em()
width = sum(glyph_of(char)[1] for char in text) * scale
pen = x - (width / 2 if anchor == "middle" else width if anchor == "end" else 0.0)
ops: list[Op] = []
for char in text:
glyph, advance = glyph_of(char)
if glyph is not None:
def place(u: float, v: float, origin: float = pen) -> Point:
return (origin + u * scale, y - v * scale)
for contour in outline(glyph):
ops.extend(_contour(contour, place))
pen += advance * scale
return ops
def _contour(
points: list[tuple[float, float, bool]], to: Callable[[float, float], Point]
) -> list[Op]:
"""A TrueType contour, quadratic B-spline, as moves and cubic curves."""
if not points:
return []
first_on = next((i for i, p in enumerate(points) if p[2]), None)
if first_on is None:
a, b = points[0], points[1 % len(points)]
start = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
ordered = points
else:
ordered = points[first_on:] + points[:first_on]
start = (ordered[0][0], ordered[0][1])
ordered = ordered[1:]
ops: list[Op] = [("M", to(*start))]
current = start
control: Point | None = None
for x, y, on in [*ordered, (start[0], start[1], True)]:
if on:
if control is None:
ops.append(("L", to(x, y)))
else:
ops.append(_quadratic(to(*current), to(*control), to(x, y)))
current, control = (x, y), None
else:
if control is not None:
middle = ((control[0] + x) / 2, (control[1] + y) / 2)
ops.append(_quadratic(to(*current), to(*control), to(*middle)))
current = middle
control = (x, y)
ops.append(("Z",))
return ops
[docs]
def paints(svg: bytes) -> tuple[list[Paint], tuple[float, float, float, float]]:
"""The figure's paths in drawing order, and its box (left, top, width, height) in points."""
root = ET.fromstring(svg)
box = [float(v) for v in root.attrib["viewBox"].split()]
found: list[Paint] = []
def emit(ops: list[Op], style: _Style) -> None:
if not ops:
return
fill = _colour(style.fill, style.fill_opacity)
stroke = _colour(style.stroke, style.stroke_opacity)
if fill is None and stroke is None:
return
placed: list[Op] = []
for op in ops:
if op[0] == "Z":
placed.append(op)
else:
placed.append((op[0], *(_apply(style.matrix, p) for p in op[1:]))) # type: ignore[arg-type]
a, b, c, d, _, _ = style.matrix
factor = math.sqrt(abs(a * d - b * c))
found.append(
Paint(
placed,
fill,
stroke,
style.width * factor,
style.rule == "evenodd",
style.cap,
style.join,
) # fmt: skip
)
def walk(element: ET.Element, parent: _Style) -> None:
tag = element.tag.removeprefix(NS)
if tag in ("defs", "style", "title", "desc", "metadata"):
return
style = _style(element, parent)
a = element.attrib
if tag == "path":
emit(_path(a.get("d", "")), style)
elif tag in ("ellipse", "circle"):
rx = float(a.get("rx", a.get("r", 0)))
ry = float(a.get("ry", a.get("r", 0)))
emit(_ellipse(float(a.get("cx", 0)), float(a.get("cy", 0)), rx, ry), style)
elif tag == "rect":
x, y = float(a.get("x", 0)), float(a.get("y", 0))
w, h = float(a["width"]), float(a["height"])
emit(
[
("M", (x, y)),
("L", (x + w, y)),
("L", (x + w, y + h)),
("L", (x, y + h)),
("Z",),
],
style,
)
elif tag in ("line", "polyline", "polygon"):
if tag == "line":
points = [(float(a["x1"]), float(a["y1"])), (float(a["x2"]), float(a["y2"]))]
else:
values = [float(v) for v in re.split(r"[\s,]+", a.get("points", "").strip()) if v]
points = list(zip(values[::2], values[1::2], strict=True))
ops: list[Op] = [("M", points[0]), *(("L", p) for p in points[1:])]
emit(ops + ([("Z",)] if tag == "polygon" else []), style)
elif tag == "text":
size = float(a.get("font-size", "10").removesuffix("px"))
anchor = a.get("text-anchor", "start")
spans = [s for s in element if s.tag.removeprefix(NS) == "tspan"] or [element]
ops = []
for span in spans:
text = "".join(span.itertext()) if span is not element else (element.text or "")
x = float(span.attrib.get("x", a.get("x", 0)))
y = float(span.attrib.get("y", a.get("y", 0)))
ops.extend(_glyphs(text, x, y, size, anchor))
emit(ops, style)
return
elif tag not in ("svg", "g"):
raise RenderRefused(f"cannot convert SVG element {tag!r} to a vector figure")
for child in element:
walk(child, style)
walk(root, _Style())
return found, (box[0], box[1], box[2], box[3])
def _xy(point: object) -> Point:
if not isinstance(point, tuple) or len(point) != 2:
raise RenderRefused("a vector figure point is malformed")
return float(point[0]), float(point[1])
def _flat(ops: list[Op]) -> list[Op]:
"""Paths start with a move, as both formats need."""
result: list[Op] = []
for op in ops:
if op[0] in ("L", "C") and (not result or result[-1][0] == "Z"):
result.append(("M", op[-1]))
result.append(op)
return result
[docs]
def eps(svg: bytes) -> bytes:
"""The figure as Encapsulated PostScript, text outlined, at its physical size."""
found, (left, top, width, height) = paints(svg)
def at(point: object) -> str:
x, y = _xy(point)
return f"{x - left:.3f} {top + height - y:.3f}"
caps = {"butt": 0, "round": 1, "square": 2}
joins = {"miter": 0, "round": 1, "bevel": 2}
lines = [
"%!PS-Adobe-3.0 EPSF-3.0",
f"%%BoundingBox: 0 0 {math.ceil(width)} {math.ceil(height)}",
f"%%HiResBoundingBox: 0 0 {width:.3f} {height:.3f}",
"%%Creator: vibe-sketch",
"%%LanguageLevel: 2",
"%%Pages: 1",
"%%EndComments",
"%%Page: 1 1",
"gsave",
]
for paint in found:
body = []
for op in _flat(paint.ops):
if op[0] == "M":
body.append(f"{at(op[1])} moveto")
elif op[0] == "L":
body.append(f"{at(op[1])} lineto")
elif op[0] == "C":
body.append(f"{at(op[1])} {at(op[2])} {at(op[3])} curveto")
else:
body.append("closepath")
path = "newpath " + " ".join(body)
if paint.fill is not None:
lines.append(f"{path} {paint.fill[0]:.4f} {paint.fill[1]:.4f} {paint.fill[2]:.4f} "
f"setrgbcolor {'eofill' if paint.evenodd else 'fill'}") # fmt: skip
if paint.stroke is not None and paint.width > 0:
lines.append(
f"{path} {paint.stroke[0]:.4f} {paint.stroke[1]:.4f} {paint.stroke[2]:.4f} "
f"setrgbcolor {paint.width:.3f} setlinewidth {caps.get(paint.cap, 0)} "
f"setlinecap {joins.get(paint.join, 0)} setlinejoin stroke"
)
lines += ["grestore", "showpage", "%%EOF", ""]
return "\n".join(lines).encode("ascii")
#: EMF coordinates: twentieths of a point, on a 1440 dpi reference device.
_UNIT = 20.0
_DEVICE = (12240, 15840) # US Letter at 1440 dpi
_MILLIMETRES = (216, 279)
def _colorref(rgb: tuple[float, float, float]) -> int:
red, green, blue = (max(0, min(255, round(c * 255))) for c in rgb)
return red | green << 8 | blue << 16
[docs]
def emf(svg: bytes) -> bytes:
"""The figure as an Enhanced Metafile, text outlined, at its physical size."""
found, (left, top, width, height) = paints(svg)
records: list[bytes] = []
def record(kind: int, payload: bytes) -> None:
records.append(struct.pack("<II", kind, 8 + len(payload)) + payload)
def point(p: object) -> tuple[int, int]:
x, y = _xy(p)
return round((x - left) * _UNIT), round((y - top) * _UNIT)
right, bottom = round(width * _UNIT), round(height * _UNIT)
bounds = struct.pack("<iiii", 0, 0, right, bottom)
caps = {"round": 0x000, "square": 0x100, "butt": 0x200}
joins = {"round": 0x0000, "bevel": 0x1000, "miter": 0x2000}
for paint in found:
record(19, struct.pack("<I", 1 if paint.evenodd else 2)) # EMR_SETPOLYFILLMODE
if paint.stroke is not None and paint.width > 0:
style = 0x10000 | caps.get(paint.cap, 0x200) | joins.get(paint.join, 0x2000)
pen = struct.pack(
"<IIIIIIIIIII", 1, 0, 0, 0, 0, style, max(1, round(paint.width * _UNIT)), 0,
_colorref(paint.stroke), 0, 0,
) # fmt: skip
record(95, pen) # EMR_EXTCREATEPEN, handle 1
record(37, struct.pack("<I", 1))
else:
record(37, struct.pack("<I", 0x80000008)) # NULL_PEN
if paint.fill is not None:
record(39, struct.pack("<IIII", 2, 0, _colorref(paint.fill), 0)) # brush, handle 2
record(37, struct.pack("<I", 2))
else:
record(37, struct.pack("<I", 0x80000005)) # NULL_BRUSH
record(59, b"") # EMR_BEGINPATH
for op in _flat(paint.ops):
if op[0] == "M":
record(27, struct.pack("<ii", *point(op[1])))
elif op[0] == "L":
record(54, struct.pack("<ii", *point(op[1])))
elif op[0] == "C":
points = [point(p) for p in op[1:]]
record(
5,
bounds
+ struct.pack("<I", 3)
+ b"".join(struct.pack("<ii", *p) for p in points),
)
else:
record(61, b"") # EMR_CLOSEFIGURE
record(60, b"") # EMR_ENDPATH
drawn = (paint.fill is not None, paint.stroke is not None and paint.width > 0)
record({(True, True): 63, (True, False): 62, (False, True): 64}[drawn], bounds)
record(37, struct.pack("<I", 0x80000008))
record(37, struct.pack("<I", 0x80000005))
if drawn[1]:
record(40, struct.pack("<I", 1)) # EMR_DELETEOBJECT
if drawn[0]:
record(40, struct.pack("<I", 2))
record(14, struct.pack("<III", 0, 16, 20)) # EMR_EOF
frame = struct.pack(
"<iiii", 0, 0, round(width * 2540 / 72), round(height * 2540 / 72)
) # hundredths of a millimetre
total = 108 + sum(len(r) for r in records)
header = struct.pack(
"<II", 1, 108
) + bounds + frame + struct.pack(
"<IIIIHHIIIiiiiIIIii",
0x464D4520, 0x10000, total, len(records) + 1, 3, 0, 0, 0, 0,
_DEVICE[0], _DEVICE[1], _MILLIMETRES[0], _MILLIMETRES[1], 0, 0, 0,
_MILLIMETRES[0] * 1000, _MILLIMETRES[1] * 1000,
) # fmt: skip
if len(header) != 108: # pragma: no cover - a mistake in the layout above
raise AssertionError(f"EMF header is {len(header)} bytes")
return header + b"".join(records)