Skip to content

Commit 986a85d

Browse files
matmenclaude
andcommitted
SVG: text
Text, tspan, tref and textPath elements render with the fonts given as SVGOptions.fonts and the ones the SVG embeds with @font-face (TrueType, OpenType, WOFF in data: URIs). Fonts are matched by CSS Fonts' algorithm from the names, weights and styles in them; generic families take the first font fitting them. The layout follows usvg (src/render/svg-text.ts): whitespace, position and rotation lists, chunks shaped as a whole with each span's font, fallback fonts per grapheme, letter and word spacing, textLength, anchors, baselines, decorations, text paths with kurbo's arc lengths, vertical text, and COLR, CBDT, sbix and SVG color glyphs. Text is painted like shapes with the text's bounding box, and counts in clip paths and group bounding boxes. Also: font-size resolves relative values against the parent's size, font-weight bolder and lighter, the font shorthand, declarations keep semicolons in parentheses, and a synchronous PNG decode for bitmap glyphs. 359 of resvg's 431 text tests pass with its test fonts (subset into tests/fonts/resvg by scripts/resvg-test-fonts.py), and 41 more of its other tests with text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent 4a4d6c8 commit 986a85d

843 files changed

Lines changed: 8738 additions & 44 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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@
44

55
### Features
66

7+
- `Image.renderSVG()` renders text (`<text>`, `<tspan>`, `<tref>`, `<textPath>`) with the fonts given as
8+
`SVGOptions.fonts` and the ones the SVG embeds with `@font-face` (TrueType, OpenType and WOFF in data: URIs, which
9+
win for their family names). Fonts are matched like browsers match installed ones: `font-family` by the names in
10+
the fonts, then `font-stretch`, `font-style` and `font-weight` by CSS Fonts' algorithm; generic families take the
11+
first font fitting them (by its panose and fixed pitch flag), other families and text without one the first font,
12+
and characters a face doesn't have come from the other fonts. Laid out like resvg's (ported from usvg): chunks
13+
shaped as a whole (kerning and ligatures across spans, with each span's own font), the bidirectional algorithm,
14+
`x`/`y`/`dx`/`dy`/`rotate` lists, `text-anchor`, `letter-spacing`, `word-spacing`, `textLength` and `lengthAdjust`,
15+
`baseline-shift` (`sub`, `super`, lengths), `dominant-baseline` and `alignment-baseline`, `text-decoration` in the
16+
colors of the elements declaring it, text along paths (`startOffset`, arc lengths measured like kurbo's), vertical
17+
text (`writing-mode`), `font-variant: small-caps`, `font-kerning`, the `font` shorthand, `xml:space`, and color
18+
glyphs (COLR versions 0 and 1, CBDT and sbix bitmaps, SVG glyphs). Text is painted like shapes (gradients and
19+
patterns with the bounding box of the whole text, strokes, `paint-order`), and counts in clip paths and in bounding
20+
boxes. Variable fonts aren't supported yet. 359 of resvg's 431 text tests pass (with its test fonts), and 41 more of
21+
its other tests that have text; the others need variable fonts (14), resvg's default font size of 12 pixels (instead
22+
of 16), its cursive and fantasy fonts, or render as resvg doesn't (in ways browsers don't either, like conjuncts
23+
broken at a font change).
24+
- `font-size` resolves `em`, `ex`, percentages and keywords against the parent's size for all elements (lengths in `em`
25+
used the default 16 pixels when an ancestor's size was relative), and `font-weight` `bolder` and `lighter`.
726
- `Image.renderSVG()` blends elements with `mix-blend-mode` (all 16 modes and `plus-lighter`, in CSS) and isolates
827
groups with `isolation: isolate`, like resvg (the blend modes are the ones feBlend already used). All of resvg's 21
928
mix-blend-mode and 2 isolation tests pass, up from 4 and 1.

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ encode as GIF, APNG or WebP, also [a frame at a time](https://imagescript.matmen
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)
57+
- [SVG rendering](https://imagescript.matmen.dev/classes/Image.html#rendersvg), text included (with the fonts you
58+
pass or the SVG embeds)
5859
- [Resizing](https://imagescript.matmen.dev/classes/Image.html#resize),
5960
[cropping](https://imagescript.matmen.dev/classes/Image.html#crop),
6061
[rotating](https://imagescript.matmen.dev/classes/Image.html#rotate),

‎THIRD_PARTY_NOTICES.md‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ 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; 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) |
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; how text elements are read: whitespace, positions, chunks, spans, decorations, baseline shifts, text paths, as in usvg), `src/render/svg-text.ts` (text layout ported from usvg: shaping across spans, spacing, text length, anchors, positions along paths, baselines, decorations, vertical text, color glyphs' placement), `tests/svgs/resvg` (test cases with resvg's renderings) | MIT (or Apache-2.0) |
29+
| resvg's test fonts: [Noto](https://notofonts.github.io) fonts by Google, [Amiri](https://github.com/aliftype/amiri) by Khaled Hosny, [M+](https://mplusfonts.github.io) by the M+ Fonts Project, [Sedgwick Ave Display](https://github.com/googlefonts/sedgwickave) by Kevin Burke, [Source Sans Pro](https://github.com/adobe-fonts/source-sans) by Adobe, [Yellowtail](https://fonts.google.com/specimen/Yellowtail) by Astigmatic, [Twemoji](https://github.com/twitter/twemoji) by Twitter, a CFF and sbix test font of resvg | `tests/fonts/resvg` (subsets, see `scripts/resvg-test-fonts.py`; their licenses are next to them) | OFL-1.1, Apache-2.0, MIT, CC-BY 4.0 (Twemoji's graphics) |
30+
| [fontdb](https://github.com/RazrFalcon/fontdb) by Yevhenii Reizner | `src/render/svg-text.ts` (font matching: CSS Fonts' algorithm as fontdb's queries do it, the fallback's style check) | MIT |
31+
| [kurbo](https://github.com/linebender/kurbo) by Raph Levien | `src/render/svg-text.ts` (arc lengths of cubics: adaptive Legendre-Gauss quadrature; parameters at arc lengths: the ITP method) | MIT (or Apache-2.0) |
2932
| [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 |
3033
| [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) |
3134
| [Unicode Character Database](https://www.unicode.org/ucd/) | `src/render/unicode-data.ts` (character properties, generated by `scripts/unicode.js`) | Unicode License v3 |
@@ -523,6 +526,62 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
523526
DEALINGS IN THE SOFTWARE.
524527
```
525528

529+
## fontdb
530+
531+
```
532+
The MIT License (MIT)
533+
534+
Copyright (c) 2020 Yevhenii Reizner
535+
536+
Permission is hereby granted, free of charge, to any person obtaining a copy
537+
of this software and associated documentation files (the "Software"), to deal
538+
in the Software without restriction, including without limitation the rights
539+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
540+
copies of the Software, and to permit persons to whom the Software is
541+
furnished to do so, subject to the following conditions:
542+
543+
The above copyright notice and this permission notice shall be included in all
544+
copies or substantial portions of the Software.
545+
546+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
547+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
548+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
549+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
550+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
551+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
552+
SOFTWARE.
553+
```
554+
555+
## kurbo
556+
557+
```
558+
Copyright (c) 2018 Raph Levien
559+
560+
Permission is hereby granted, free of charge, to any
561+
person obtaining a copy of this software and associated
562+
documentation files (the "Software"), to deal in the
563+
Software without restriction, including without
564+
limitation the rights to use, copy, modify, merge,
565+
publish, distribute, sublicense, and/or sell copies of
566+
the Software, and to permit persons to whom the Software
567+
is furnished to do so, subject to the following
568+
conditions:
569+
570+
The above copyright notice and this permission notice
571+
shall be included in all copies or substantial portions
572+
of the Software.
573+
574+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
575+
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
576+
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
577+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
578+
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
579+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
580+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
581+
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
582+
DEALINGS IN THE SOFTWARE.
583+
```
584+
526585
## tiny-skia
527586

528587
```

‎scripts/resvg-test-fonts.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Writes tests/fonts/resvg: resvg's test fonts (github.com/linebender/resvg, crates/resvg/tests/fonts, with their
2+
# licenses) subset to the characters of the test cases in tests/svgs/resvg and the ones their letters and
3+
# marks compose to (the shaper composes them where a font has them), with all names and layout features. Its small
4+
# subsets (emoji, CFF and sbix) are copied as they are, Roboto Flex (variable fonts aren't supported) left out. Needs
5+
# fontTools:
6+
# RESVG_FONTS=path/to/resvg/crates/resvg/tests/fonts python scripts/resvg-test-fonts.py
7+
import os, shutil, unicodedata
8+
from pathlib import Path
9+
from fontTools import subset
10+
from fontTools.ttLib import TTFont
11+
12+
root = Path(__file__).resolve().parent.parent
13+
source = Path(os.environ['RESVG_FONTS'])
14+
target = root / 'tests' / 'fonts' / 'resvg'
15+
target.mkdir(parents=True, exist_ok=True)
16+
17+
used = {chr(c) for c in range(0x20, 0x7f)}
18+
for case in (root / 'tests' / 'svgs' / 'resvg').rglob('*.svg'):
19+
content = case.read_text(encoding='utf-8')
20+
if '<text' in content:
21+
used |= set(content)
22+
marks = [c for c in used if unicodedata.combining(c)]
23+
composed = {unicodedata.normalize('NFC', b + m) for b in used for m in marks}
24+
characters = sorted(ord(c) for c in used | {c for c in composed if len(c) == 1})
25+
26+
for path in sorted(source.iterdir()):
27+
name = path.name
28+
if name.startswith('RobotoFlex'):
29+
continue
30+
if path.suffix not in ('.ttf', '.otf'):
31+
if 'LICENSE' in name or 'OFL' in name:
32+
shutil.copy(path, target / name)
33+
continue
34+
if '.subset.' in name or name.startswith('CFF-and-SBIX'):
35+
shutil.copy(path, target / name)
36+
continue
37+
options = subset.Options()
38+
options.layout_features = ['*']
39+
options.name_IDs = ['*']
40+
options.name_languages = ['*']
41+
options.name_legacy = True
42+
options.notdef_outline = True
43+
options.hinting = True
44+
options.legacy_kern = True
45+
font = TTFont(path)
46+
subsetter = subset.Subsetter(options)
47+
subsetter.populate(unicodes=characters)
48+
subsetter.subset(font)
49+
font.save(target / name)

‎src/codecs/png.ts‎

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { concat } from '../util/mem.ts';
44
import { littleEndian } from '../util/alpha.ts';
55
import { encode as utf8, decode as utf8Decode } from '../util/utf8.ts';
66
import { orientation as exifOrientation, xmpOrientation } from './exif.ts';
7-
import { compressAsync, decompressAsync } from './zlib.ts';
7+
import { compressAsync, decompressAsync, decompress } from './zlib.ts';
88

99
const HEAD = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
1010
const IHDR = 0x49484452;
@@ -599,22 +599,31 @@ function parse(array: Uint8Array): Parsed {
599599
return { header, palette, transparency, idat, exif, text, animation, frames, animationError };
600600
}
601601

602-
// inflates and converts one image (the default image or an APNG frame) to RGBA
603-
async function decodeImageData(p: Parsed, chunks: Uint8Array[], width: number, height: number): Promise<Uint8Array> {
604-
const { depth, colorType, interlace } = p.header;
605-
const channels = COLOR_TYPE_CHANNELS[colorType];
606-
const bitsPerPixel = channels * depth;
607-
const bpp = Math.max(1, bitsPerPixel >> 3);
608-
const passes = interlace ? ADAM7.map(([xs, ys, dx, dy]) => [xs, ys, dx, dy, Math.ceil((width - xs) / dx), Math.ceil((height - ys) / dy)]) : [[0, 0, 1, 1, width, height]];
609-
602+
// the interlace passes of an image ([x start, y start, x step, y step, width, height]) and its inflated size
603+
function passesOf(p: Parsed, width: number, height: number): { passes: number[][], expected: number } {
604+
const bitsPerPixel = COLOR_TYPE_CHANNELS[p.header.colorType] * p.header.depth;
605+
const passes = p.header.interlace ? ADAM7.map(([xs, ys, dx, dy]) => [xs, ys, dx, dy, Math.ceil((width - xs) / dx), Math.ceil((height - ys) / dy)]) : [[0, 0, 1, 1, width, height]];
610606
let expected = 0;
611607
for (const pass of passes) {
612608
const [, , , , pw, ph] = pass;
613609
if (pw > 0 && ph > 0) expected += ph * (1 + Math.ceil(pw * bitsPerPixel / 8));
614610
}
611+
return { passes, expected };
612+
}
615613

614+
// inflates and converts one image (the default image or an APNG frame) to RGBA
615+
async function decodeImageData(p: Parsed, chunks: Uint8Array[], width: number, height: number): Promise<Uint8Array> {
616+
const { expected } = passesOf(p, width, height);
616617
if (!chunks.length) throw new Error('PNG: missing image data');
617-
const data = await decompressAsync(chunks.length === 1 ? chunks[0] : concat(chunks), expected);
618+
return imagePixels(p, await decompressAsync(chunks.length === 1 ? chunks[0] : concat(chunks), expected), width, height);
619+
}
620+
621+
// an image's RGBA pixels from its inflated data
622+
function imagePixels(p: Parsed, data: Uint8Array, width: number, height: number): Uint8Array {
623+
const { depth, colorType, interlace } = p.header;
624+
const bitsPerPixel = COLOR_TYPE_CHANNELS[colorType] * depth;
625+
const bpp = Math.max(1, bitsPerPixel >> 3);
626+
const { passes, expected } = passesOf(p, width, height);
618627
if (data.length < expected) throw new Error('PNG: image data is truncated');
619628

620629
const rgba = new Uint8Array(width * height * 4);
@@ -649,6 +658,16 @@ export async function decode(array: Uint8Array, maxPixels = MAX_PIXELS): Promise
649658
return { width, height, pixels, orientation: p.exif ? exifOrientation(p.exif) : await textOrientation(p.text) };
650659
}
651660

661+
/** Decodes a PNG into 8-bit RGBA at once (inflated without the native, asynchronous streams; for small images) */
662+
export function decodeSync(array: Uint8Array, maxPixels = MAX_PIXELS): { width: number, height: number, pixels: Uint8Array } {
663+
const p = parse(array);
664+
const { width, height } = p.header;
665+
checkPixels(width, height, maxPixels);
666+
if (!p.idat.length) throw new Error('PNG: missing image data');
667+
const { expected } = passesOf(p, width, height);
668+
return { width, height, pixels: imagePixels(p, decompress(p.idat.length === 1 ? p.idat[0] : concat(p.idat), expected), width, height) };
669+
}
670+
652671
export interface APNGFrame {
653672
x: number;
654673
y: number;

‎src/image.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,15 @@ export interface SVGOptions {
160160
* Default: {@link Image.SVG_MODE_SCALE}
161161
*/
162162
mode?: SVGScaleMode;
163+
/**
164+
* Fonts for `<text>` (TrueType, OpenType): each text gets the face its `font-family`, `font-weight`, `font-style`
165+
* and `font-stretch` match best among them, by the names, weights and styles in the fonts (like browsers match
166+
* installed fonts). Generic families (`serif`, `sans-serif`, `monospace`, ...) are the first font that fits them,
167+
* other families and text without one get the first font's family; characters a face doesn't have come from the
168+
* other fonts. Fonts the SVG embeds with `@font-face` (in data: URIs, also WOFF) are used too, before these for
169+
* their family names. Without any fonts text isn't rendered. Default: `[]`
170+
*/
171+
fonts?: BinaryData[];
163172
}
164173

165174
/** Options of {@link Image.renderText}: the text color and layout, and its style (see {@link TextStyle}) */
@@ -2289,7 +2298,7 @@ export class Image {
22892298
* Creates a new image from the given SVG
22902299
* Supports shapes, paths, transforms, use/symbol, CSS styles, gradients, patterns, strokes, markers, clip paths,
22912300
* masks, opacity, blend modes, filters (all primitives and the filter functions) and images embedded as data URIs
2292-
* (PNG, JPEG, GIF, WebP, SVG); text is not rendered
2301+
* (PNG, JPEG, GIF, WebP, SVG), and text with the given or embedded fonts (see {@link SVGOptions.fonts})
22932302
* @param svg The SVG content
22942303
* @param options See {@link SVGOptions}
22952304
* @return The rendered SVG graphic
@@ -2304,7 +2313,7 @@ export class Image {
23042313
*/
23052314
static renderSVG(svg: string | BinaryData, size?: number, mode?: SVGScaleMode): Promise<Image>;
23062315
static async renderSVG(svg: string | BinaryData, sizeOrOptions: number | SVGOptions = 1, modeArgument: SVGScaleMode = Image.SVG_MODE_SCALE): Promise<Image> {
2307-
const { size = 1, mode = Image.SVG_MODE_SCALE } = isOptions(sizeOrOptions) ? sizeOrOptions : { size: sizeOrOptions, mode: modeArgument };
2316+
const { size = 1, mode = Image.SVG_MODE_SCALE, fonts = [] } = isOptions(sizeOrOptions) ? sizeOrOptions : { size: sizeOrOptions, mode: modeArgument };
23082317
if (![1, 2, 3].includes(mode))
23092318
throw new Error('Invalid SVG scaling mode');
23102319

@@ -2332,7 +2341,7 @@ export class Image {
23322341
}
23332342
}
23342343

2335-
const {width, height, pixels} = svgRenderer.render(source, mode, size, images);
2344+
const {width, height, pixels} = svgRenderer.render(source, mode, size, images, fonts.map(font => view(font)));
23362345
const image = new Image(width, height);
23372346
image.bitmap.set(pixels);
23382347

0 commit comments

Comments
 (0)