Skip to content

Commit dd0a23f

Browse files
matmenclaude
andcommitted
SVG: patterns
renderSVG paints with <pattern>: tiles in user space or relative to the bounding box (patternUnits), content in either (patternContentUnits), viewBox and preserveAspectRatio, patternTransform (ignored if it can't be inverted), attributes and children through href chains. Like resvg, the tile is rendered once in device resolution (its size rounded, at most 4M pixels) and repeated: bicubic (Mitchell, B = C = 1/3), or the nearest pixel where it's only translated. Patterns painting their own content paint nothing, decided up front in document order like usvg's fix_recursive_patterns. resvg's pattern tests: 32/33 pass (was 4; the other needs text), copied to tests/svgs/resvg. Marker and image tests unchanged; filters 81 -> 71, all of them invalid filters on pattern-filled shapes that resvg doesn't render (they passed only because the pattern was missing; filters are still ignored). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent 0bf69b3 commit dd0a23f

66 files changed

Lines changed: 663 additions & 2 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
@@ -9,6 +9,11 @@
99

1010
### Improvements
1111

12+
- `Image.renderSVG()` paints with patterns (`<pattern>`): tiles in user space or relative to the bounding box, their
13+
content in either, `viewBox` and `preserveAspectRatio`, `patternTransform`, attributes and children inherited
14+
through `href`. Like resvg, a tile is rendered once in device resolution and repeated (bicubic, or the nearest
15+
pixel where it's only translated); patterns painting their own content paint nothing. 32 of resvg's 33 pattern
16+
tests pass (the other one has text), up from 4.
1217
- Optimized lossy WebP animations are smaller: like libwebp's, they leave out pixels that changed by up to a tolerance
1318
from the quality (3 per channel at quality 85, 1 at 100), and make unchanged pixels transparent only in whole 8x8
1419
blocks. Making each unchanged pixel transparent gave speckled alpha masks where most pixels change a little

‎src/render/svg.ts‎

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { PathCommand } from './font.ts';
44
/**
55
* SVG renderer supporting a static subset of SVG 1.1/2:
66
* shapes, paths, transforms, nested svg/use/symbol, presentation attributes, inline styles and simple
7-
* <style> sheets (type, class and id selectors), solid colors, linear and radial gradients, strokes
7+
* <style> sheets (type, class and id selectors), solid colors, linear and radial gradients, patterns, strokes
88
* (joins, caps, dashes), opacity, clip paths and luminance masks.
9-
* Not supported: text, images, filters, patterns, markers, animation and scripting.
9+
* Not supported: text, images, filters, markers, animation and scripting.
1010
*/
1111

1212
const UNSUPPORTED = new Set(['text', 'image', 'filter', 'pattern', 'marker', 'foreignObject', 'script', 'style', 'title', 'desc', 'metadata',
@@ -482,6 +482,8 @@ function validProperty(name: string, value: string): boolean {
482482
// the most elements to render through <use> references (like librsvg's limit), and dashes of a path
483483
const MAX_INSTANCES = 100000;
484484
const MAX_DASHES = 1000000;
485+
// the most pixels of a pattern's tile
486+
const MAX_TILE = 4096 * 1024;
485487

486488
class Document {
487489
/** elements rendered through <use> references so far */
@@ -511,6 +513,39 @@ class Document {
511513

512514
this.root = root;
513515
this.rules = parseStyleSheet(styles.join('\n'));
516+
for (const name of ['fill', 'stroke']) this.breakPatternCycles(name);
517+
}
518+
519+
// like resvg: in document order, a pattern's descendant painted with the pattern itself, or a descendant of the
520+
// pattern it's painted with painted with the first pattern, paints nothing (instead of recursing)
521+
breakPatternCycles(name: string): void {
522+
const paintOf = (node: XNode): string | undefined => {
523+
const inline = node.attrs.style ? parseDeclarations(node.attrs.style) : null;
524+
return inline?.important[name] ?? inline?.normal[name] ?? node.attrs[name];
525+
};
526+
const descendants = (node: XNode): XNode[] => {
527+
const list: XNode[] = [], stack = [...node.children];
528+
while (stack.length) {
529+
const n = stack.shift() as XNode;
530+
list.push(n);
531+
stack.unshift(...n.children);
532+
}
533+
return list;
534+
};
535+
const patterns = descendants(this.root).filter(node => node.name === 'pattern');
536+
for (const pattern of patterns) {
537+
for (const node of descendants(pattern)) {
538+
const linked = this.reference(paintOf(node));
539+
if (!linked) continue;
540+
if (linked === pattern) this.unpaint(node, name);
541+
else for (const inner of descendants(linked)) if (this.reference(paintOf(inner)) === pattern) this.unpaint(inner, name);
542+
}
543+
}
544+
}
545+
546+
unpaint(node: XNode, name: string): void {
547+
node.attrs[name] = 'none';
548+
if (node.attrs.style) node.attrs.style += `;${name}:none !important`;
514549
}
515550

516551
reference(value: string | undefined): XNode | null {
@@ -763,6 +798,10 @@ class Document {
763798
const gradient = this.gradient(server, bbox, matrix, opacity, ctx);
764799
if (gradient !== undefined || !fallback) return gradient || null;
765800
}
801+
if (server && server.name === 'pattern') {
802+
const pattern = this.pattern(server, bbox, matrix, opacity, ctx);
803+
if (pattern !== undefined || !fallback) return pattern || null;
804+
}
766805

767806
// missing, invalid or unsupported paint servers use the fallback color, if any
768807
color = fallback;
@@ -898,6 +937,123 @@ class Document {
898937
return solidPaint([c[0], c[1], c[2], c[3] * opacity]);
899938
}
900939

940+
// collects pattern attributes along href chains, and the first pattern with children
941+
patternAttributes(node: XNode): { attrs: Record<string, string>, content: XNode | null } {
942+
const attrs: Record<string, string> = {};
943+
let content: XNode | null = null;
944+
const names = ['x', 'y', 'width', 'height', 'patternUnits', 'patternContentUnits', 'patternTransform', 'viewBox', 'preserveAspectRatio'];
945+
for (let current: XNode | null = node, depth = 0; current && depth < 16 && current.name === 'pattern'; current = this.reference(this.href(current)), depth++) {
946+
for (const name of names)
947+
if (attrs[name] === undefined && current.attrs[name] !== undefined) attrs[name] = current.attrs[name];
948+
if (!content && current.children.some(child => child.svg)) content = current;
949+
}
950+
return { attrs, content };
951+
}
952+
953+
// renders one tile of a pattern in device resolution, painted repeating: undefined for an invalid pattern
954+
pattern(node: XNode, bbox: BBox | null, matrix: Matrix, opacity: number, ctx: Context): Paint | null | undefined {
955+
if (this.active.has(node)) return null;
956+
const { attrs, content } = this.patternAttributes(node);
957+
const objectBox = attrs.patternUnits !== 'userSpaceOnUse';
958+
const contentBox = attrs.patternContentUnits === 'objectBoundingBox';
959+
const viewBox = parseViewBox(attrs.viewBox);
960+
if ((objectBox || (contentBox && !viewBox)) && (!bbox || bbox.width <= 0 || bbox.height <= 0)) return null;
961+
const box = bbox || { x: 0, y: 0, width: 0, height: 0 };
962+
963+
const coordinate = (name: string, axis: 'x' | 'y'): number => {
964+
const value = attrs[name];
965+
if (!objectBox) return value === undefined ? 0 : parseLength(value, ctx.viewport, 16, axis);
966+
const fraction = value === undefined ? 0 : /%\s*$/.test(value) ? parseFloat(value) / 100 : parseNumber(value, 0);
967+
return axis === 'x' ? box.x + fraction * box.width : box.y + fraction * box.height;
968+
};
969+
const tx = coordinate('x', 'x'), ty = coordinate('y', 'y');
970+
const tw = coordinate('width', 'x') - (objectBox ? box.x : 0), th = coordinate('height', 'y') - (objectBox ? box.y : 0);
971+
if (!(tw > 0) || !(th > 0) || !content) return null;
972+
973+
// (a patternTransform that can't be inverted is ignored, like resvg does)
974+
let patternMatrix = matrix, rotated = matrix[1] !== 0 || matrix[2] !== 0;
975+
if (attrs.patternTransform) {
976+
const transform = parseTransform(attrs.patternTransform), transformed = multiply(patternMatrix, transform);
977+
if (invert(transformed)) {
978+
patternMatrix = transformed;
979+
rotated ||= transform[1] !== 0 || transform[2] !== 0;
980+
}
981+
}
982+
const inverse = invert(patternMatrix);
983+
if (!inverse) return null;
984+
985+
// the tile in device resolution, its size rounded like resvg's (lower for tiles of more than 4096 x 1024 pixels)
986+
let sx = Math.hypot(patternMatrix[0], patternMatrix[1]), sy = Math.hypot(patternMatrix[2], patternMatrix[3]);
987+
const reduce = Math.min(1, Math.sqrt(MAX_TILE / (tw * sx * th * sy)));
988+
sx *= reduce;
989+
sy *= reduce;
990+
const width = Math.max(1, Math.round(tw * sx)), height = Math.max(1, Math.round(th * sy));
991+
992+
let contentMatrix = [sx, 0, 0, sy, 0, 0];
993+
if (viewBox) contentMatrix = multiply(contentMatrix, viewBoxTransform(viewBox, tw, th, attrs.preserveAspectRatio));
994+
else if (contentBox) contentMatrix = multiply(contentMatrix, [box.width, 0, 0, box.height, 0, 0]);
995+
996+
const tile = new Layer(width, height);
997+
this.active.add(node);
998+
this.renderChildren(content, { ...ctx, matrix: contentMatrix, style: this.styleOf(content), layer: tile, depth: ctx.depth + 1,
999+
viewport: viewBox ? { width: viewBox[2], height: viewBox[3] } : ctx.viewport });
1000+
this.active.delete(node);
1001+
if (tile.x1 <= tile.x0) return null;
1002+
1003+
// the repeated tile sampled like resvg's (tiny-skia's): the nearest pixel where the tile is only translated, else
1004+
// bicubic (Mitchell-Netravali, B = C = 1/3); premultiplied, the result straight
1005+
const d = tile.data;
1006+
const color: Color = [0, 0, 0, 0];
1007+
// (a rotation and its inverse still filter: tiny-skia's single precision doesn't cancel them exactly)
1008+
const translated = !rotated && patternMatrix[0] > 0 && patternMatrix[3] > 0;
1009+
const wrap = (v: number, n: number): number => v - Math.floor(v / n) * n;
1010+
const wx = new Float64Array(4), wy = new Float64Array(4), xs = new Int32Array(4), ys = new Int32Array(4);
1011+
const weights = (t: number, w: Float64Array): void => {
1012+
const s = 1 - t;
1013+
w[0] = (7 / 18 * s - 6 / 18) * s * s;
1014+
w[1] = ((-21 / 18 * s + 27 / 18) * s + 9 / 18) * s + 1 / 18;
1015+
w[2] = ((-21 / 18 * t + 27 / 18) * t + 9 / 18) * t + 1 / 18;
1016+
w[3] = (7 / 18 * t - 6 / 18) * t * t;
1017+
};
1018+
return (px, py) => {
1019+
const u = (inverse[0] * px + inverse[2] * py + inverse[4] - tx) * sx - 0.5;
1020+
const v = (inverse[1] * px + inverse[3] * py + inverse[5] - ty) * sy - 0.5;
1021+
if (translated) {
1022+
const i = (Math.floor(wrap(v + 0.5, height)) * width + Math.floor(wrap(u + 0.5, width))) * 4;
1023+
const a = d[i + 3];
1024+
if (a <= 0) return TRANSPARENT;
1025+
color[0] = d[i] / a;
1026+
color[1] = d[i + 1] / a;
1027+
color[2] = d[i + 2] / a;
1028+
color[3] = a * opacity;
1029+
return color;
1030+
}
1031+
const x0 = Math.floor(u), y0 = Math.floor(v);
1032+
weights(u - x0, wx);
1033+
weights(v - y0, wy);
1034+
for (let k = 0; k < 4; k++) {
1035+
xs[k] = wrap(x0 - 1 + k, width);
1036+
ys[k] = wrap(y0 - 1 + k, height) * width;
1037+
}
1038+
let r = 0, g = 0, b = 0, a = 0;
1039+
for (let j = 0; j < 4; j++)
1040+
for (let k = 0; k < 4; k++) {
1041+
const f = wy[j] * wx[k], i = (ys[j] + xs[k]) * 4;
1042+
r += d[i] * f;
1043+
g += d[i + 1] * f;
1044+
b += d[i + 2] * f;
1045+
a += d[i + 3] * f;
1046+
}
1047+
a = clamp01(a);
1048+
if (a <= 1e-6) return TRANSPARENT;
1049+
color[0] = clamp01(r / a);
1050+
color[1] = clamp01(g / a);
1051+
color[2] = clamp01(b / a);
1052+
color[3] = a * opacity;
1053+
return color;
1054+
};
1055+
}
1056+
9011057
// renders a clipPath into a coverage mask
9021058
clipMask(clip: XNode, node: XNode, matrix: Matrix, context: Context): Float32Array {
9031059
const { width, height } = context.layer;
2.05 KB
Loading
Lines changed: 23 additions & 0 deletions
Loading
1.16 KB
Loading
Lines changed: 12 additions & 0 deletions
Loading
2.05 KB
Loading
Lines changed: 24 additions & 0 deletions
Loading
1.43 KB
Loading
Lines changed: 13 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)