Skip to content

Commit c3ccdf2

Browse files
matmenclaude
andcommitted
SVG: images in data URIs
renderSVG draws <image> with data URIs: rasters are decoded first (the decoders may be asynchronous; GIFs' first frame, the rest through Image.decode), then passed to the synchronous renderer by href; SVGs (gzipped too, via a new synchronous gunzip) render as nested documents. Fitted like usvg: missing or auto width/height from the image, preserveAspectRatio (clipped with slice), image-rendering; sampled like tiny-skia (bicubic, nearest where only translated, padded edges), sharing the pattern's sampler. An image's bounding box is its rectangle for objectBoundingBox clips and masks. External files and URLs aren't loaded. Nested SVG elements count towards MAX_INSTANCES (shared budget), each href is collected once: 7 levels of 20 gzipped self-copies (1.28e9 elements) stop after 1.3 s. resvg's image tests: 17/56 by the suite's threshold (was 0), 37/56 with the frame's pixels ignored (tiny-skia rounds edges to quarter pixels, the frame's 3.75 px strokes differ by more than 24); most others load files. Patterns 32/33, markers 61/64, filters 71 unchanged; a test checks PNG, WebP, GIF, JPEG and SVGZ data URIs pixel-exactly at 1:1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent 02ec220 commit c3ccdf2

41 files changed

Lines changed: 719 additions & 89 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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919
`markerHeight`, `viewBox`, clipped unless `overflow` is visible; placed and oriented like resvg's (ported from usvg).
2020
Arcs are split into as many curves as resvg splits them into (more for large radii), which places the mid markers
2121
alike. 61 of resvg's 64 marker tests pass (the others have text or images), up from 11.
22+
- `Image.renderSVG()` draws images (`<image>`) in data URIs: PNG, JPEG, GIF (the first frame), WebP and the other
23+
formats `Image.decode()` reads, and SVGs (also gzipped, and with their own embedded images). They're fitted like
24+
resvg's: `width`/`height` from the image where they're missing or `auto`, `preserveAspectRatio` (clipped with
25+
`slice`), `image-rendering`, sampled bicubically (the nearest pixel at their size). External files and URLs aren't
26+
loaded. Elements of embedded SVGs count towards the limit of 100000 elements rendered through references. Of
27+
resvg's 56 image tests, 17 pass the test suite's threshold, 37 without the frame around them, whose antialiasing
28+
differs (tiny-skia rounds edges to quarter pixels); most of the others load files.
2229
- Optimized lossy WebP animations are smaller: like libwebp's, they leave out pixels that changed by up to a tolerance
2330
from the quality (3 per channel at quality 85, 1 at 100), and make unchanged pixels transparent only in whole 8x8
2431
blocks. Making each unchanged pixel transparent gave speckled alpha masks where most pixels change a little

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ encode as GIF, APNG or WebP.
5454

5555
- [Text rendering](https://imagescript.matmen.dev/classes/Image.html#rendertext) with TrueType and OpenType fonts,
5656
in every script, with color emoji
57-
- [SVG rendering](https://imagescript.matmen.dev/classes/Image.html#rendersvg) (no text, images or filters)
57+
- [SVG rendering](https://imagescript.matmen.dev/classes/Image.html#rendersvg) (no text or filters)
5858
- [Resizing](https://imagescript.matmen.dev/classes/Image.html#resize),
5959
[cropping](https://imagescript.matmen.dev/classes/Image.html#crop),
6060
[rotating](https://imagescript.matmen.dev/classes/Image.html#rotate),

‎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; 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) |
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; how images are fitted, clipped and sampled), `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/codecs/zlib.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,23 @@ export function decompress(data: Uint8Array, limit: number): Uint8Array {
630630
return inflate(data.subarray(2), new u8(limit));
631631
}
632632

633+
/**
634+
* Decompresses a gzip stream (its first member) into at most limit bytes
635+
*/
636+
export function gunzip(data: Uint8Array, limit: number): Uint8Array {
637+
if (data.length < 18 || data[0] !== 0x1f || data[1] !== 0x8b || data[2] !== 8) throw new Error('gzip: invalid header');
638+
const flags = data[3];
639+
let o = 10;
640+
if (flags & 4) o += 2 + (data[o] | data[o + 1] << 8);
641+
if (flags & 8) while (o < data.length && data[o++]);
642+
if (flags & 16) while (o < data.length && data[o++]);
643+
if (flags & 2) o += 2;
644+
// (the size of the decompressed data, modulo 2^32, ends the stream)
645+
const end = data.length - 4, size = (data[end] | data[end + 1] << 8 | data[end + 2] << 16 | data[end + 3] << 24) >>> 0;
646+
if (o > end - 4 || size > limit) throw new Error('gzip: invalid or too large');
647+
return inflate(data.subarray(o, end - 4), new u8(size));
648+
}
649+
633650
// native zlib, accessed through feature detection with minimal local types instead of depending on DOM or Node type
634651
// definitions: node:zlib (Node, Bun and Deno expose it through process.getBuiltinModule, without an import) is the
635652
// fastest and honors compression levels; DecompressionStream is part of the web platform

‎src/image.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as png from './codecs/png.ts';
22
import * as jpeg from './codecs/jpeg.ts';
33
import * as tiff from './codecs/tiff.ts';
44
import * as webp from './codecs/webp.ts';
5+
import * as gif from './codecs/gif.ts';
56
import * as tga from './codecs/tga.ts';
67
import * as bmp from './codecs/bmp.ts';
78
import * as ico from './codecs/ico.ts';
@@ -2258,8 +2259,8 @@ export class Image {
22582259

22592260
/**
22602261
* Creates a new image from the given SVG
2261-
* Supports shapes, paths, transforms, use/symbol, CSS styles, gradients, strokes, clip paths, masks and opacity;
2262-
* text, images, filters and patterns are not rendered
2262+
* Supports shapes, paths, transforms, use/symbol, CSS styles, gradients, patterns, strokes, markers, clip paths,
2263+
* masks, opacity and images embedded as data URIs (PNG, JPEG, GIF, WebP, SVG); text and filters are not rendered
22632264
* @param svg The SVG content
22642265
* @param options See {@link SVGOptions}
22652266
* @return The rendered SVG graphic
@@ -2283,7 +2284,26 @@ export class Image {
22832284
if (mode !== Image.SVG_MODE_SCALE && size < 1)
22842285
throw new RangeError('SVG size must be >= 1');
22852286

2286-
const {width, height, pixels} = svgRenderer.render(typeof svg === 'string' ? svg : view(svg), mode, size);
2287+
// the raster images it embeds are decoded first (decoding can be asynchronous), the SVG is rendered at once
2288+
const source = typeof svg === 'string' ? svg : view(svg);
2289+
const images = new Map<string, svgRenderer.RasterImage>();
2290+
for (const { href, data } of svgRenderer.embeddedImages(source)) {
2291+
if (images.has(href)) continue;
2292+
try {
2293+
if (ImageType.getType(data) === 'gif') {
2294+
// (a GIF's first frame)
2295+
const decoded = gif.decode(data, true);
2296+
images.set(href, { width: decoded.width, height: decoded.height, pixels: gif.composite(decoded)[0].pixels });
2297+
} else {
2298+
const image = await Image.decode(data);
2299+
images.set(href, { width: image.width, height: image.height, pixels: image.bitmap });
2300+
}
2301+
} catch {
2302+
// (images that can't be decoded aren't drawn, like resvg's)
2303+
}
2304+
}
2305+
2306+
const {width, height, pixels} = svgRenderer.render(source, mode, size, images);
22872307
const image = new Image(width, height);
22882308
image.bitmap.set(pixels);
22892309

0 commit comments

Comments
 (0)