Skip to content

Commit 02ec220

Browse files
matmenclaude
andcommitted
SVG: markers
renderSVG draws marker-start/mid/end (inherited, and the marker shorthand in CSS only, like resvg): orient (any angle unit, auto, auto-start-reverse), markerUnits, refX/refY, markerWidth/markerHeight, viewBox, clipped unless overflow is visible. Placement and orientation are ported from usvg's marker.rs, on the segments as tiny-skia's path builder makes them (repeated moves/closes merged, a move after a close). Arcs now split into as many cubics as kurbo makes for usvg (tolerance 0.1), so mid markers sit at the same vertices. resvg's marker tests: 61/64 (was 11; the others have text or images), copied to tests/svgs/resvg. Patterns 32/33, filters 71 unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent 25ff4a6 commit 02ec220

125 files changed

Lines changed: 1051 additions & 10 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
through `href`. Like resvg, a tile is rendered once in device resolution and repeated (bicubic, or the nearest
1515
pixel where it's only translated); patterns painting their own content paint nothing. 32 of resvg's 33 pattern
1616
tests pass (the other one has text), up from 4.
17+
- `Image.renderSVG()` draws markers (`marker-start`, `marker-mid`, `marker-end`, and the `marker` shorthand in CSS):
18+
`orient` (angles in any unit, `auto`, `auto-start-reverse`), `markerUnits`, `refX`/`refY`, `markerWidth`/
19+
`markerHeight`, `viewBox`, clipped unless `overflow` is visible; placed and oriented like resvg's (ported from usvg).
20+
Arcs are split into as many curves as resvg splits them into (more for large radii), which places the mid markers
21+
alike. 61 of resvg's 64 marker tests pass (the others have text or images), up from 11.
1722
- Optimized lossy WebP animations are smaller: like libwebp's, they leave out pixels that changed by up to a tolerance
1823
from the quality (3 per channel at quality 85, 1 at 100), and make unchanged pixels transparent only in whole 8x8
1924
blocks. Making each unchanged pixel transparent gave speckled alpha masks where most pixels change a little

‎THIRD_PARTY_NOTICES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ following projects, under their own licenses. Each derived file says so in its h
2525
| [Pillow](https://github.com/python-pillow/Pillow) | `src/util/resample.ts` (filter coefficients, box, bilinear and bicubic filters) | MIT-CMU |
2626
| [HarfBuzz](https://github.com/harfbuzz/harfbuzz) | `src/render/buffer.ts`, `src/render/layout.ts`, `src/render/shape.ts`, `src/render/shapers/*` (OpenType shaping: buffer, GSUB/GPOS, normalization, fallback positioning, the script shapers and syllable grammars), `scripts/unicode.js` (shaping category tables) | Old MIT |
2727
| [Universal Shaping Engine data](https://github.com/harfbuzz/harfbuzz/tree/main/src/ms-use) by Microsoft | `scripts/ms-use` (category overrides, invalid vowel sequences), the tables generated from them in `src/render/unicode-data.ts` | MIT |
28-
| [resvg](https://github.com/linebender/resvg) by the Resvg Authors | `src/render/svg.ts` (how patterns are rendered and their reference cycles broken, following resvg and usvg), `tests/svgs/resvg` (test cases with resvg's renderings) | MIT (or Apache-2.0) |
28+
| [resvg](https://github.com/linebender/resvg) by the Resvg Authors | `src/render/svg.ts` (how patterns are rendered and their reference cycles broken, following resvg and usvg; the markers' placement and orientation, ported from usvg; as many cubics per arc as kurbo makes for usvg), `tests/svgs/resvg` (test cases with resvg's renderings) | MIT (or Apache-2.0) |
2929
| [Unicode Character Database](https://www.unicode.org/ucd/) | `src/render/unicode-data.ts` (character properties, generated by `scripts/unicode.js`) | Unicode License v3 |
3030

3131
## fflate, Fast CRC32 in JavaScript

‎src/render/svg.ts‎

Lines changed: 227 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
* SVG renderer supporting a static subset of SVG 1.1/2:
33
* shapes, paths, transforms, nested svg/use/symbol, presentation attributes, inline styles and simple
44
* <style> sheets (type, class and id selectors), solid colors, linear and radial gradients, patterns, strokes
5-
* (joins, caps, dashes), opacity, clip paths and luminance masks.
6-
* Not supported: text, images, filters, markers, animation and scripting.
5+
* (joins, caps, dashes), markers, opacity, clip paths and luminance masks.
6+
* Not supported: text, images, filters, animation and scripting.
77
* How patterns are rendered (a tile in device resolution, its filtering) and their reference cycles broken follows
8-
* resvg/usvg (https://github.com/linebender/resvg; Copyright 2017 the Resvg Authors, MIT License, see
9-
* THIRD_PARTY_NOTICES.md)
8+
* resvg/usvg, the markers (their placement and orientation, crates/usvg/src/parser/marker.rs) are ported from usvg
9+
* (https://github.com/linebender/resvg; Copyright 2017 the Resvg Authors, MIT License, see THIRD_PARTY_NOTICES.md)
1010
*/
1111

1212
import { decode as decodeUTF8 } from '../util/utf8.ts';
@@ -16,7 +16,8 @@ const UNSUPPORTED = new Set(['text', 'image', 'filter', 'pattern', 'marker', 'fo
1616
'defs', 'clipPath', 'mask', 'linearGradient', 'radialGradient', 'stop', 'symbol', 'font', 'font-face', 'animate', 'animateTransform', 'animateMotion', 'set']);
1717

1818
const INHERITED = new Set(['fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-opacity', 'stroke-linecap', 'stroke-linejoin',
19-
'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'color', 'visibility', 'clip-rule', 'font-size']);
19+
'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'color', 'visibility', 'clip-rule', 'font-size', 'marker-start', 'marker-mid',
20+
'marker-end']);
2021

2122
// elements a switch may choose from
2223
const SWITCHABLE = new Set(['svg', 'g', 'a', 'switch', 'use', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'image', 'foreignObject']);
@@ -100,7 +101,7 @@ const DEFAULTS: Record<string, string> = {
100101
'fill': 'black', 'fill-opacity': '1', 'fill-rule': 'nonzero', 'stroke': 'none', 'stroke-width': '1', 'stroke-opacity': '1',
101102
'stroke-linecap': 'butt', 'stroke-linejoin': 'miter', 'stroke-miterlimit': '4', 'stroke-dasharray': 'none', 'stroke-dashoffset': '0',
102103
'color': 'black', 'visibility': 'visible', 'clip-rule': 'nonzero', 'font-size': '16', 'opacity': '1', 'display': 'inline',
103-
'stop-color': 'black', 'stop-opacity': '1'
104+
'stop-color': 'black', 'stop-opacity': '1', 'marker-start': 'none', 'marker-mid': 'none', 'marker-end': 'none'
104105
};
105106

106107
const SAMPLES = 16; // sub-scanlines per pixel
@@ -567,7 +568,11 @@ class Document {
567568

568569
const own: Record<string, string> = {};
569570
const apply = (declarations: Record<string, string>): void => {
570-
for (const name in declarations) if (validProperty(name, declarations[name])) own[name] = declarations[name];
571+
for (const name in declarations) {
572+
// (the marker shorthand only exists in CSS, not as an attribute)
573+
if (name === 'marker') for (const kind of ['marker-start', 'marker-mid', 'marker-end']) own[kind] = declarations[name];
574+
else if (validProperty(name, declarations[name])) own[name] = declarations[name];
575+
}
571576
};
572577

573578
const presentation: Record<string, string> = {};
@@ -654,7 +659,9 @@ class Document {
654659
case 'use': return this.renderUse(node, ctx);
655660
default: {
656661
const path = shapePath(node, ctx.viewport, parseNumber(style['font-size'], 16));
657-
if (path) this.paintPath(path, node, ctx);
662+
if (!path) return;
663+
this.paintPath(path, node, ctx);
664+
if (style.visibility === 'visible') this.renderMarkers(path, node, ctx);
658665
}
659666
}
660667
};
@@ -788,6 +795,74 @@ class Document {
788795
}
789796
}
790797

798+
// draws the markers of a shape (like usvg's markers, crates/usvg/src/parser/marker.rs)
799+
renderMarkers(path: PathCommand[], node: XNode, ctx: Context): void {
800+
const style = ctx.style as Style;
801+
for (let p = node.parent; p; p = p.parent) if (p.name === 'clipPath') return;
802+
const kinds = ['marker-start', 'marker-mid', 'marker-end'] as const;
803+
if (kinds.every(kind => !style[kind] || style[kind] === 'none')) return;
804+
const segments = markerSegments(path);
805+
if (segments.length < 2) return;
806+
807+
for (let k = 0; k < 3; k++) {
808+
const marker = this.reference(style[kinds[k]]);
809+
if (!marker || marker.name !== 'marker' || this.active.has(marker)) continue;
810+
811+
const units = marker.attrs.markerUnits === 'userSpaceOnUse' ? 1 : parseLength(style['stroke-width'], ctx.viewport, 16, 'diagonal');
812+
if (!(units > 0)) continue;
813+
const length = (name: string, axis: 'x' | 'y', fallback: number): number =>
814+
marker.attrs[name] === undefined ? fallback : parseLength(marker.attrs[name], ctx.viewport, 16, axis);
815+
const refX = length('refX', 'x', 0), refY = length('refY', 'y', 0);
816+
const width = length('markerWidth', 'x', 3), height = length('markerHeight', 'y', 3);
817+
if (!(width > 0) || !(height > 0)) continue;
818+
819+
const viewBox = parseViewBox(marker.attrs.viewBox);
820+
const viewBoxMatrix = viewBox ? viewBoxTransform(viewBox, width, height, marker.attrs.preserveAspectRatio) : [1, 0, 0, 1, 0, 0];
821+
const sx = Math.hypot(viewBoxMatrix[0], viewBoxMatrix[1]), sy = Math.hypot(viewBoxMatrix[2], viewBoxMatrix[3]);
822+
// the clip (unless overflow is visible): the marker's size in the viewBox's coordinates
823+
const inverse = invert(viewBoxMatrix);
824+
if (!inverse) continue;
825+
const clip = /^(visible|auto)$/.test(marker.attrs.overflow?.trim() || '') ? null
826+
: transformPoints([0, 0, width, height], inverse);
827+
828+
const orient = (marker.attrs.orient || '').trim();
829+
const fixed = orient === 'auto' || orient === 'auto-start-reverse' ? null : parseAngle(orient);
830+
const markerStyle = this.styleOf(marker);
831+
832+
const draw = (x: number, y: number, index: number): void => {
833+
let angle = fixed ?? vertexAngle(segments, index);
834+
if (orient === 'auto-start-reverse' && index === 0) angle = (angle + 180) % 360;
835+
let matrix = multiply(ctx.matrix, [1, 0, 0, 1, x, y]);
836+
if (angle) {
837+
const r = angle * Math.PI / 180, cos = Math.cos(r), sin = Math.sin(r);
838+
matrix = multiply(matrix, [cos, sin, -sin, cos, 0, 0]);
839+
}
840+
matrix = multiply(matrix, [sx * units, 0, 0, sy * units, -refX * sx * units, -refY * sy * units]);
841+
842+
const inner = { ...ctx, matrix, style: markerStyle, depth: ctx.depth + 1, viewport: viewBox ? { width: viewBox[2], height: viewBox[3] } : ctx.viewport };
843+
this.active.add(marker);
844+
if (!clip) this.renderChildren(marker, inner);
845+
else this.clipped(inner, Math.min(clip[0], clip[2]), Math.min(clip[1], clip[3]), Math.abs(clip[2] - clip[0]), Math.abs(clip[3] - clip[1]),
846+
layer => this.renderChildren(marker, { ...inner, layer }));
847+
this.active.delete(marker);
848+
};
849+
850+
if (k === 0) {
851+
if (segments[0].type === 'M') draw(segments[0].x, segments[0].y, 0);
852+
} else if (k === 1) {
853+
for (let i = 1; i < segments.length - 1; i++)
854+
if (segments[i].type !== 'Z') draw(segments[i].x, segments[i].y, i);
855+
} else {
856+
const i = segments.length - 1, last = segments[i];
857+
if (last.type === 'L' || last.type === 'C') draw(last.x, last.y, i);
858+
else if (last.type === 'Z') {
859+
const start = subpathStart(segments, i);
860+
draw(start.x, start.y, i);
861+
}
862+
}
863+
}
864+
}
865+
791866
// returns a paint function (x, y) => [r, g, b, a] in device pixels, or null for no paint
792867
paint(value: string | undefined, style: Style, bbox: BBox | null, matrix: Matrix, opacityValue: string | undefined, ctx: Context): Paint | null {
793868
if (!value || value === 'none') return null;
@@ -1212,6 +1287,147 @@ class Document {
12121287
}
12131288
}
12141289

1290+
// ---------------------------------------------------------------------------
1291+
// markers (like usvg's, crates/usvg/src/parser/marker.rs)
1292+
// ---------------------------------------------------------------------------
1293+
1294+
/** a path segment: its end point (x, y), a cubic's control points (x1, y1, x2, y2) */
1295+
interface Segment {
1296+
type: string;
1297+
x: number;
1298+
y: number;
1299+
x1: number;
1300+
y1: number;
1301+
x2: number;
1302+
y2: number;
1303+
}
1304+
1305+
// the segments of a path like tiny-skia's path builder makes them: repeated moves and closes merged, a move after a
1306+
// close that's followed by a line or curve, no move at the end
1307+
function markerSegments(path: PathCommand[]): Segment[] {
1308+
const segments: Segment[] = [];
1309+
let mx = 0, my = 0;
1310+
const push = (type: string, x: number, y: number, x1 = 0, y1 = 0, x2 = 0, y2 = 0): void => {
1311+
segments.push({ type, x, y, x1, y1, x2, y2 });
1312+
};
1313+
for (const [command, ...v] of path) {
1314+
const last = segments[segments.length - 1];
1315+
if (command === 'M') {
1316+
if (last && last.type === 'M') segments.pop();
1317+
push('M', mx = v[0], my = v[1]);
1318+
} else if (command === 'Z') {
1319+
if (last && last.type !== 'Z' && last.type !== 'M') push('Z', mx, my);
1320+
} else {
1321+
if (!last || last.type === 'Z') push('M', mx, my);
1322+
if (command === 'L') push('L', v[0], v[1]);
1323+
else push('C', v[4], v[5], v[0], v[1], v[2], v[3]);
1324+
}
1325+
}
1326+
if (segments.length && segments[segments.length - 1].type === 'M') segments.pop();
1327+
return segments;
1328+
}
1329+
1330+
function subpathStart(segments: Segment[], index: number): Segment {
1331+
for (let i = index - 1; i >= 0; i--) if (segments[i].type === 'M') return segments[i];
1332+
return { type: 'M', x: 0, y: 0, x1: 0, y1: 0, x2: 0, y2: 0 };
1333+
}
1334+
1335+
function previousVertex(segments: Segment[], index: number): Segment {
1336+
const previous = segments[index - 1];
1337+
return previous.type === 'Z' ? subpathStart(segments, index) : previous;
1338+
}
1339+
1340+
// (usvg compares single precision floats within 4 ulps)
1341+
function near(a: number, b: number): boolean {
1342+
return Math.abs(a - b) <= 4 * 1.1920929e-7 * Math.max(Math.abs(a), Math.abs(b));
1343+
}
1344+
1345+
function normalizeAngle(rad: number): number {
1346+
const v = rad % (Math.PI * 2);
1347+
return v < 0 ? v + Math.PI * 2 : v;
1348+
}
1349+
1350+
// the bisector of the incoming (1 to 2) and outgoing (3 to 4) directions, in degrees
1351+
function bisector(x1: number, y1: number, x2: number, y2: number, x3: number, y3: number, x4: number, y4: number): number {
1352+
const incoming = normalizeAngle(Math.atan2(y2 - y1, x2 - x1)), outgoing = normalizeAngle(Math.atan2(y4 - y3, x4 - x3));
1353+
const d = (outgoing - incoming) / 2;
1354+
let angle = incoming + d;
1355+
if (Math.abs(d) > Math.PI / 2) angle -= Math.PI;
1356+
return normalizeAngle(angle) * 180 / Math.PI;
1357+
}
1358+
1359+
function lineAngle(x1: number, y1: number, x2: number, y2: number): number {
1360+
return bisector(x1, y1, x2, y2, x1, y1, x2, y2);
1361+
}
1362+
1363+
function curvesAngle(px: number, py: number, cx1: number, cy1: number, x: number, y: number, cx2: number, cy2: number, nx: number, ny: number): number {
1364+
if (near(cx1, x) && near(cy1, y)) return bisector(px, py, x, y, x, y, cx2, cy2);
1365+
if (near(x, cx2) && near(y, cy2)) return bisector(cx1, cy1, x, y, x, y, nx, ny);
1366+
return bisector(cx1, cy1, x, y, x, y, cx2, cy2);
1367+
}
1368+
1369+
// a marker's orientation at a vertex (orient="auto")
1370+
function vertexAngle(segments: Segment[], index: number): number {
1371+
if (index === 0) {
1372+
const [m, s] = segments;
1373+
if (m.type !== 'M') return 0;
1374+
if (s.type === 'L') return lineAngle(m.x, m.y, s.x, s.y);
1375+
if (s.type === 'C') return near(m.x, s.x1) && near(m.y, s.y1) ? lineAngle(m.x, m.y, s.x, s.y) : lineAngle(m.x, m.y, s.x1, s.y1);
1376+
return 0;
1377+
}
1378+
1379+
if (index === segments.length - 1) {
1380+
const a = segments[index - 1], b = segments[index];
1381+
if (b.type === 'L') {
1382+
const prev = previousVertex(segments, index);
1383+
return lineAngle(prev.x, prev.y, b.x, b.y);
1384+
}
1385+
if (b.type === 'C') return near(b.x2, b.x) && near(b.y2, b.y) ? lineAngle(b.x1, b.y1, b.x, b.y) : lineAngle(b.x2, b.y2, b.x, b.y);
1386+
if (b.type === 'Z') {
1387+
const next = subpathStart(segments, index);
1388+
if (a.type === 'L') return lineAngle(a.x, a.y, next.x, next.y);
1389+
if (a.type === 'C') {
1390+
const prev = previousVertex(segments, index);
1391+
return curvesAngle(prev.x, prev.y, a.x2, a.y2, a.x, a.y, next.x, next.y, next.x, next.y);
1392+
}
1393+
}
1394+
return 0;
1395+
}
1396+
1397+
const a = segments[index], b = segments[index + 1];
1398+
if (a.type === 'M') {
1399+
if (b.type === 'L') return lineAngle(a.x, a.y, b.x, b.y);
1400+
if (b.type === 'C') return lineAngle(a.x, a.y, b.x1, b.y1);
1401+
return 0;
1402+
}
1403+
if (a.type === 'Z') return 0;
1404+
const prev = previousVertex(segments, index);
1405+
if (b.type === 'L') {
1406+
if (a.type === 'L') return bisector(prev.x, prev.y, a.x, a.y, a.x, a.y, b.x, b.y);
1407+
return curvesAngle(prev.x, prev.y, a.x2, a.y2, a.x, a.y, b.x, b.y, b.x, b.y);
1408+
}
1409+
if (b.type === 'C') {
1410+
if (a.type === 'L') return curvesAngle(prev.x, prev.y, prev.x, prev.y, a.x, a.y, b.x1, b.y1, b.x, b.y);
1411+
return curvesAngle(prev.x, prev.y, a.x2, a.y2, a.x, a.y, b.x1, b.y1, b.x, b.y);
1412+
}
1413+
if (b.type === 'M') {
1414+
if (a.type === 'L') return lineAngle(prev.x, prev.y, a.x, a.y);
1415+
return near(a.x, a.x2) && near(a.y, a.y2) ? lineAngle(prev.x, prev.y, a.x, a.y) : lineAngle(a.x2, a.y2, a.x, a.y);
1416+
}
1417+
// (b is a close)
1418+
const next = subpathStart(segments, index);
1419+
if (a.type === 'L') return bisector(prev.x, prev.y, a.x, a.y, a.x, a.y, next.x, next.y);
1420+
return lineAngle(prev.x, prev.y, next.x, next.y);
1421+
}
1422+
1423+
// an angle in degrees (deg, grad, rad, turn or a plain number), 0 if invalid
1424+
function parseAngle(value: string): number {
1425+
const m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(deg|grad|rad|turn)?$/i.exec(value);
1426+
if (!m) return 0;
1427+
const n = parseFloat(m[1]), unit = (m[2] || 'deg').toLowerCase();
1428+
return unit === 'grad' ? n * 0.9 : unit === 'rad' ? n * 180 / Math.PI : unit === 'turn' ? n * 360 : n;
1429+
}
1430+
12151431
const TRANSPARENT: Color = [0, 0, 0, 0];
12161432

12171433
function solidPaint(color: Color): Paint {
@@ -1701,7 +1917,9 @@ function arcToCubics(path: PathCommand[], x1: number, y1: number, rx: number, ry
17011917
if (!sweep && delta > 0) delta -= 2 * Math.PI;
17021918
else if (sweep && delta < 0) delta += 2 * Math.PI;
17031919

1704-
const segments = Math.ceil(Math.abs(delta) / (Math.PI / 2) - 1e-9);
1920+
// as many cubics as kurbo makes for usvg (at a tolerance of 0.1), which places mid markers at their ends
1921+
const perTurn = Math.max(Math.pow(1.1163 * Math.max(rx, ry) / 0.1, 1 / 6), 3.999999);
1922+
const segments = Math.max(1, Math.ceil(perTurn * Math.abs(delta) / (2 * Math.PI)));
17051923
const step = delta / segments;
17061924
const k = 4 / 3 * Math.tan(step / 4);
17071925

2.13 KB
Loading
Lines changed: 13 additions & 0 deletions
Loading
2.27 KB
Loading
Lines changed: 10 additions & 0 deletions
Loading
2.97 KB
Loading
Lines changed: 13 additions & 0 deletions
Loading
2.97 KB
Loading

0 commit comments

Comments
 (0)