Source code for vibesketch.render.vector

"""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)