Skip to content

Commit d18d4db

Browse files
matmenclaude
andcommitted
SVG: mix-blend-mode, isolation, transform-origin, paint-order, context paints
mix-blend-mode (16 modes and plus-lighter) and isolation, with the blend modes of feBlend moved to src/render/blend.ts; transform-origin on elements, clip paths, gradients and patterns; paint-order; context-fill and context-stroke in markers and <use>; shape-rendering crispEdges and optimizeSpeed; the root's background-color. Fixes: zero length dashes at the start of a path, square caps of zero length dashes along the path, fallback colors for objectBoundingBox paints on shapes without a bounding box, display: none in group bounding boxes, overflow="inherit", path data ending at invalid characters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent 874127b commit d18d4db

194 files changed

Lines changed: 1809 additions & 132 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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
# Changelog
22

3+
## 3.9.0
4+
5+
### Features
6+
7+
- `Image.renderSVG()` blends elements with `mix-blend-mode` (all 16 modes and `plus-lighter`, in CSS) and isolates
8+
groups with `isolation: isolate`, like resvg (the blend modes are the ones feBlend already used). All of resvg's 21
9+
mix-blend-mode and 2 isolation tests pass, up from 4 and 1.
10+
- `Image.renderSVG()` applies `transform-origin` (keywords, lengths and percentages) to transforms of elements, clip
11+
paths, gradients and patterns; the root's transform turns around the center of its viewport, like resvg's. 21 of
12+
resvg's 23 transform-origin tests pass (the others have text), up from 3.
13+
- `Image.renderSVG()` paints in the order of `paint-order` (fill, stroke and markers). 12 of resvg's 14 tests pass (the
14+
others have text), up from 6.
15+
- `Image.renderSVG()` paints with `context-fill` and `context-stroke`: inside markers the paint of the shape they're on,
16+
inside `<use>` that of the element, in their coordinates and bounding boxes (gradients and patterns too). 13 of
17+
resvg's 16 tests pass, up from 2.
18+
- `Image.renderSVG()` draws shapes without antialiasing for `shape-rendering: crispEdges` and `optimizeSpeed`, and fills
19+
the root's viewBox with its `background-color` (in CSS).
20+
21+
### Fixes
22+
23+
- `Image.renderSVG()`: zero length dashes (`stroke-dasharray="0 40"`) at the start of a path get caps too, and square
24+
ones are turned along the path.
25+
- `Image.renderSVG()`: shapes without a bounding box (horizontal and vertical lines and curves) painted with an
26+
`objectBoundingBox` gradient or pattern use its fallback color, and curves along an axis no longer count as having
27+
one.
28+
- `Image.renderSVG()`: elements with `display: none` don't count towards the bounding boxes of groups (clip paths,
29+
masks, gradients and filters in `objectBoundingBox` units).
30+
- `Image.renderSVG()`: `overflow="inherit"` on markers and nested viewports takes the parent's value.
31+
- `Image.renderSVG()`: path data ends at the first character that isn't part of it, instead of skipping it.
32+
333
## 3.8.1
434

535
### Performance

‎THIRD_PARTY_NOTICES.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ 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/filter.ts` (the filter effects: regions, inputs, color spaces, box blur (based on [fastblur](https://github.com/fschutt/fastblur)), IIR blur, offset, flood, composite, merge, drop shadow, color matrix, component transfer, tile, morphology, convolution), `src/render/svg.ts` (the filters' attributes like usvg; 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; how images are fitted, clipped and sampled), `tests/svgs/resvg` (test cases with resvg's renderings) | MIT (or Apache-2.0) |
29-
| [tiny-skia](https://github.com/linebender/tiny-skia) by Yevhenii Reizner (from [Skia](https://skia.org) by Google) | `src/render/filter.ts` (feBlend's blend modes) | BSD-3-Clause |
28+
| [resvg](https://github.com/linebender/resvg) by the Resvg Authors | `src/render/filter.ts` (the filter effects: regions, inputs, color spaces, box blur (based on [fastblur](https://github.com/fschutt/fastblur)), IIR blur, offset, flood, composite, merge, drop shadow, color matrix, component transfer, tile, morphology, convolution), `src/render/svg.ts` (the filters' attributes like usvg; 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; how images are fitted, clipped and sampled; transform-origin, paint-order, context-fill and context-stroke, mix-blend-mode and isolation, following usvg), `tests/svgs/resvg` (test cases with resvg's renderings) | MIT (or Apache-2.0) |
29+
| [tiny-skia](https://github.com/linebender/tiny-skia) by Yevhenii Reizner (from [Skia](https://skia.org) by Google) | `src/render/blend.ts` (the blend modes of feBlend and mix-blend-mode) | BSD-3-Clause |
3030
| [gaussianiir](http://www.getreuer.info/home/gaussianiir) by Pascal Getreuer | `src/render/filter.ts` (the IIR blur for small deviations, through resvg) | BSD-2-Clause (Simplified BSD) |
3131
| [Unicode Character Database](https://www.unicode.org/ucd/) | `src/render/unicode-data.ts` (character properties, generated by `scripts/unicode.js`) | Unicode License v3 |
3232

‎src/image.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2284,8 +2284,8 @@ export class Image {
22842284
/**
22852285
* Creates a new image from the given SVG
22862286
* Supports shapes, paths, transforms, use/symbol, CSS styles, gradients, patterns, strokes, markers, clip paths,
2287-
* masks, opacity, filters (all primitives and the filter functions) and images embedded as data URIs (PNG, JPEG,
2288-
* GIF, WebP, SVG); text is not rendered
2287+
* masks, opacity, blend modes, filters (all primitives and the filter functions) and images embedded as data URIs
2288+
* (PNG, JPEG, GIF, WebP, SVG); text is not rendered
22892289
* @param svg The SVG content
22902290
* @param options See {@link SVGOptions}
22912291
* @return The rendered SVG graphic

‎src/render/blend.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*!
2+
* Blend modes on premultiplied colors, ported from tiny-skia (https://github.com/linebender/tiny-skia,
3+
* src/pipeline/highp.rs; Copyright 2011 Google Inc., 2020 Yevhenii Reizner, BSD-3-Clause License, itself from Skia).
4+
* See THIRD_PARTY_NOTICES.md
5+
*/
6+
7+
/** the modes of feBlend and mix-blend-mode (plus-lighter only exists for mix-blend-mode) */
8+
export const BLEND_MODES = new Set(['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light',
9+
'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity', 'plus-lighter']);
10+
11+
const inv = (v: number): number => 1 - v;
12+
const lum = (r: number, g: number, b: number): number => r * 0.3 + g * 0.59 + b * 0.11;
13+
const sat = (r: number, g: number, b: number): number => Math.max(r, g, b) - Math.min(r, g, b);
14+
15+
function setSat(c: number[], value: number): void {
16+
const mn = Math.min(c[0], c[1], c[2]), mx = Math.max(c[0], c[1], c[2]), range = mx - mn;
17+
for (let k = 0; k < 3; k++) c[k] = range === 0 ? 0 : (c[k] - mn) * value / range;
18+
}
19+
20+
function setLum(c: number[], l: number): void {
21+
const diff = l - lum(c[0], c[1], c[2]);
22+
for (let k = 0; k < 3; k++) c[k] += diff;
23+
}
24+
25+
function clipColor(c: number[], a: number): void {
26+
const mn = Math.min(c[0], c[1], c[2]), mx = Math.max(c[0], c[1], c[2]), l = lum(c[0], c[1], c[2]);
27+
for (let k = 0; k < 3; k++) {
28+
let v = c[k];
29+
if (!(mx >= 0)) v = l + (v - l) * l / (l - mn);
30+
if (mx > a) v = l + (v - l) * (a - l) / (mx - l);
31+
c[k] = Math.max(v, 0);
32+
}
33+
}
34+
35+
function separable(mode: string, sc: number, dc: number, sa: number, da: number): number {
36+
switch (mode) {
37+
case 'multiply': return sc * inv(da) + dc * inv(sa) + sc * dc;
38+
case 'screen': return sc + dc - sc * dc;
39+
case 'darken': return sc + dc - Math.max(sc * da, dc * sa);
40+
case 'lighten': return sc + dc - Math.min(sc * da, dc * sa);
41+
case 'difference': return sc + dc - 2 * Math.min(sc * da, dc * sa);
42+
case 'exclusion': return sc + dc - 2 * sc * dc;
43+
case 'color-burn':
44+
return dc === da ? dc + sc * inv(da) : sc === 0 ? dc * inv(sa) : sa * (da - Math.min(da, (da - dc) * sa / sc)) + sc * inv(da) + dc * inv(sa);
45+
case 'color-dodge':
46+
return dc === 0 ? sc * inv(da) : sc === sa ? sc + dc * inv(sa) : sa * Math.min(da, dc * sa / (sa - sc)) + sc * inv(da) + dc * inv(sa);
47+
case 'hard-light':
48+
return sc * inv(da) + dc * inv(sa) + (2 * sc <= sa ? 2 * sc * dc : sa * da - 2 * (da - dc) * (sa - sc));
49+
case 'overlay':
50+
return sc * inv(da) + dc * inv(sa) + (2 * dc <= da ? 2 * sc * dc : sa * da - 2 * (da - dc) * (sa - sc));
51+
case 'soft-light': {
52+
const m = da > 0 ? dc / da : 0, s2 = 2 * sc, m4 = 4 * m;
53+
const darkSrc = dc * (sa + (s2 - sa) * (1 - m));
54+
const darkDst = (m4 * m4 + m4) * (m - 1) + 7 * m;
55+
const liteDst = Math.sqrt(m) - m;
56+
const liteSrc = dc * sa + da * (s2 - sa) * (4 * dc <= da ? darkDst : liteDst);
57+
return sc * inv(da) + dc * inv(sa) + (s2 <= sa ? darkSrc : liteSrc);
58+
}
59+
case 'plus-lighter': return Math.min(sc + dc, 1);
60+
default: return sc + dc * inv(sa);
61+
}
62+
}
63+
64+
const c = [0, 0, 0];
65+
66+
/** blends the premultiplied source (r, g, b, a) over the destination d[i..i + 3] in place (values 0-1, unclamped) */
67+
export function blendPixel(mode: string, r: number, g: number, b: number, a: number, d: ArrayLike<number> & { [i: number]: number }, i: number): void {
68+
const dr = d[i], dg = d[i + 1], db = d[i + 2], da = d[i + 3];
69+
if (mode === 'hue' || mode === 'saturation' || mode === 'color' || mode === 'luminosity') {
70+
if (mode === 'hue') {
71+
c[0] = r * a; c[1] = g * a; c[2] = b * a;
72+
setSat(c, sat(dr, dg, db) * a);
73+
setLum(c, lum(dr, dg, db) * a);
74+
} else if (mode === 'saturation') {
75+
c[0] = dr * a; c[1] = dg * a; c[2] = db * a;
76+
setSat(c, sat(r, g, b) * da);
77+
setLum(c, lum(dr, dg, db) * a);
78+
} else if (mode === 'color') {
79+
c[0] = r * da; c[1] = g * da; c[2] = b * da;
80+
setLum(c, lum(dr, dg, db) * a);
81+
} else {
82+
c[0] = dr * a; c[1] = dg * a; c[2] = db * a;
83+
setLum(c, lum(r, g, b) * da);
84+
}
85+
clipColor(c, a * da);
86+
d[i] = r * inv(da) + dr * inv(a) + c[0];
87+
d[i + 1] = g * inv(da) + dg * inv(a) + c[1];
88+
d[i + 2] = b * inv(da) + db * inv(a) + c[2];
89+
d[i + 3] = a + da - a * da;
90+
} else {
91+
d[i] = separable(mode, r, dr, a, da);
92+
d[i + 1] = separable(mode, g, dg, a, da);
93+
d[i + 2] = separable(mode, b, db, a, da);
94+
d[i + 3] = mode === 'plus-lighter' ? Math.min(a + da, 1) : da * inv(a) + a;
95+
}
96+
}

‎src/render/filter.ts‎

Lines changed: 7 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
* crates/resvg/src/filter: mod.rs, box_blur.rs (itself based on https://github.com/fschutt/fastblur), iir_blur.rs,
44
* color_matrix.rs, component_transfer.rs, composite.rs, morphology.rs, convolve_matrix.rs, turbulence.rs (itself from
55
* the reference implementation in the SVG 1.1 specification), lighting.rs, displacement_map.rs; Copyright 2017 the Resvg
6-
* Authors, MIT License) and the blend modes of tiny-skia (https://github.com/linebender/tiny-skia,
7-
* src/pipeline/highp.rs; Copyright 2011 Google Inc., 2020 Yevhenii Reizner, BSD-3-Clause License,
8-
* itself from Skia). The IIR blur
6+
* Authors, MIT License). The IIR blur
97
* follows Pascal Getreuer's gaussianiir (http://www.getreuer.info/home/gaussianiir; Simplified BSD License), an
108
* implementation of Alvarez and Mazorra's fast Gaussian convolution. See THIRD_PARTY_NOTICES.md
119
*/
1210

11+
import { blendPixel } from './blend.ts';
12+
1313
/** premultiplied RGBA pixels */
1414
export interface Pixmap {
1515
width: number;
@@ -643,80 +643,13 @@ function transfer(f: Exclude<Transfer, null>, value: number): number {
643643
return u8(clamp(0, v, 1) * 255);
644644
}
645645

646-
// feBlend's modes, like tiny-skia's (premultiplied, the source over the destination)
646+
// feBlend's modes (the source over the destination)
647647
function blend(dst: Pixmap, src: Pixmap, mode: string): void {
648648
const s = src.data, d = dst.data;
649-
const inv = (v: number): number => 1 - v;
650-
const lum = (r: number, g: number, b: number): number => r * 0.3 + g * 0.59 + b * 0.11;
651-
const sat = (r: number, g: number, b: number): number => Math.max(r, g, b) - Math.min(r, g, b);
652-
const setSat = (c: number[], value: number): void => {
653-
const mn = Math.min(c[0], c[1], c[2]), mx = Math.max(c[0], c[1], c[2]), range = mx - mn;
654-
for (let k = 0; k < 3; k++) c[k] = range === 0 ? 0 : (c[k] - mn) * value / range;
655-
};
656-
const setLum = (c: number[], l: number): void => {
657-
const diff = l - lum(c[0], c[1], c[2]);
658-
for (let k = 0; k < 3; k++) c[k] += diff;
659-
};
660-
const clipColor = (c: number[], a: number): void => {
661-
const mn = Math.min(c[0], c[1], c[2]), mx = Math.max(c[0], c[1], c[2]), l = lum(c[0], c[1], c[2]);
662-
for (let k = 0; k < 3; k++) {
663-
let v = c[k];
664-
if (!(mx >= 0)) v = l + (v - l) * l / (l - mn);
665-
if (mx > a) v = l + (v - l) * (a - l) / (mx - l);
666-
c[k] = Math.max(v, 0);
667-
}
668-
};
669-
const separable = (sc: number, dc: number, sa: number, da: number): number => {
670-
switch (mode) {
671-
case 'multiply': return sc * inv(da) + dc * inv(sa) + sc * dc;
672-
case 'screen': return sc + dc - sc * dc;
673-
case 'darken': return sc + dc - Math.max(sc * da, dc * sa);
674-
case 'lighten': return sc + dc - Math.min(sc * da, dc * sa);
675-
case 'difference': return sc + dc - 2 * Math.min(sc * da, dc * sa);
676-
case 'exclusion': return sc + dc - 2 * sc * dc;
677-
case 'color-burn':
678-
return dc === da ? dc + sc * inv(da) : sc === 0 ? dc * inv(sa) : sa * (da - Math.min(da, (da - dc) * sa / sc)) + sc * inv(da) + dc * inv(sa);
679-
case 'color-dodge':
680-
return dc === 0 ? sc * inv(da) : sc === sa ? sc + dc * inv(sa) : sa * Math.min(da, dc * sa / (sa - sc)) + sc * inv(da) + dc * inv(sa);
681-
case 'hard-light':
682-
return sc * inv(da) + dc * inv(sa) + (2 * sc <= sa ? 2 * sc * dc : sa * da - 2 * (da - dc) * (sa - sc));
683-
case 'overlay':
684-
return sc * inv(da) + dc * inv(sa) + (2 * dc <= da ? 2 * sc * dc : sa * da - 2 * (da - dc) * (sa - sc));
685-
case 'soft-light': {
686-
const m = da > 0 ? dc / da : 0, s2 = 2 * sc, m4 = 4 * m;
687-
const darkSrc = dc * (sa + (s2 - sa) * (1 - m));
688-
const darkDst = (m4 * m4 + m4) * (m - 1) + 7 * m;
689-
const liteDst = Math.sqrt(m) - m;
690-
const liteSrc = dc * sa + da * (s2 - sa) * (4 * dc <= da ? darkDst : liteDst);
691-
return sc * inv(da) + dc * inv(sa) + (s2 <= sa ? darkSrc : liteSrc);
692-
}
693-
default: return sc + dc * inv(sa);
694-
}
695-
};
696-
const c = [0, 0, 0];
649+
const out = [0, 0, 0, 0];
697650
for (let i = 0; i < d.length; i += 4) {
698-
const r = s[i] / 255, g = s[i + 1] / 255, b = s[i + 2] / 255, a = s[i + 3] / 255;
699-
const dr = d[i] / 255, dg = d[i + 1] / 255, db = d[i + 2] / 255, da = d[i + 3] / 255;
700-
let out: number[];
701-
if (mode === 'hue' || mode === 'saturation' || mode === 'color' || mode === 'luminosity') {
702-
if (mode === 'hue') {
703-
c[0] = r * a; c[1] = g * a; c[2] = b * a;
704-
setSat(c, sat(dr, dg, db) * a);
705-
setLum(c, lum(dr, dg, db) * a);
706-
} else if (mode === 'saturation') {
707-
c[0] = dr * a; c[1] = dg * a; c[2] = db * a;
708-
setSat(c, sat(r, g, b) * da);
709-
setLum(c, lum(dr, dg, db) * a);
710-
} else if (mode === 'color') {
711-
c[0] = r * da; c[1] = g * da; c[2] = b * da;
712-
setLum(c, lum(dr, dg, db) * a);
713-
} else {
714-
c[0] = dr * a; c[1] = dg * a; c[2] = db * a;
715-
setLum(c, lum(r, g, b) * da);
716-
}
717-
clipColor(c, a * da);
718-
out = [r * inv(da) + dr * inv(a) + c[0], g * inv(da) + dg * inv(a) + c[1], b * inv(da) + db * inv(a) + c[2], a + da - a * da];
719-
} else out = [separable(r, dr, a, da), separable(g, dg, a, da), separable(b, db, a, da), da * inv(a) + a];
651+
for (let k = 0; k < 4; k++) out[k] = d[i + k] / 255;
652+
blendPixel(mode, s[i] / 255, s[i + 1] / 255, s[i + 2] / 255, s[i + 3] / 255, out, 0);
720653
for (let k = 0; k < 4; k++) d[i + k] = u8(clamp(0, out[k], 1) * 255 + 0.5);
721654
}
722655
}

0 commit comments

Comments
 (0)