Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,20 @@
about 2.5 steps. `fast: false` keeps the precise sampling.
- `Image.renderText` takes several fonts by priority (`[text, emoji]`): every character comes from the first font that
has it, the first font sets the line height. Color glyphs keep their colors: COLR version 0 layers with CPAL
palettes (e.g. Twemoji) and CBDT/CBLC bitmaps (e.g. Noto Color Emoji, which now loads at all, it has no outlines).
palettes (e.g. Twemoji), CBDT/CBLC bitmaps (e.g. Noto Color Emoji, which now loads at all, it has no outlines) and
sbix bitmaps (Apple's format).
Invisible characters like joiners and variation selectors take no room, as in browsers.
- `Image.renderText` applies the fonts' glyph substitutions (GSUB) that browsers apply by default (`ccmp`, `rlig`,
`liga`, `clig`, `calt`): emoji sequences (skin tones, flags, keycaps, families and other joined ones) become one
glyph, and text fonts use their ligatures (fi, ffi, ...) and contextual alternates. Emoji sequences come from one
font, the first that has all of their characters, and the emoji variation selector picks a color glyph (❤️ in an
emoji font rather than the text font's ❤).
- `Image.renderText` renders COLR version 1 color glyphs (e.g. Noto-COLRv1): solid colors, linear, radial and sweep
gradients (pad, repeat, reflect), glyph outlines as masks, transforms and all composite modes; variable paints with
their default values. They are rendered at four times the size and scaled down, so layers sharing an edge don't let the
ones below show through. Fonts with both prefer them over their other color glyphs, like browsers.
- `Image.renderText` composes combining sequences (e + ◌́ to é, as in text from macOS) where the font has the composed
character, and decomposes characters the fonts lack where one has their parts, like HarfBuzz.
- Fallback fonts are only loaded when the text needs them, and glyphs (also decoded and scaled emoji bitmaps) are
kept per font and size across calls.

Expand All @@ -41,6 +48,14 @@
glyphs (opaque text renders as before).
- The outline of `Image.renderText` follows the glyphs' antialiased edges instead of their pixels, so it is smooth
rather than stepped, and it reaches exactly `strokeWidth` beyond the edge.
- `Image.renderText` threw for text without anything to show (empty, only spaces), it now renders a transparent pixel
wide.
- Damaged fonts no longer take unbounded memory or time: glyphs with contour end points that don't increase render
empty (as in FreeType), composite glyphs can't contain themselves (also when hinting), and outlines have a size
limit. Reading past the end of damaged or truncated font data throws "Font: damaged or truncated font data" instead
of a RangeError, and text larger than 2^28 pixels throws.
- Glyph outlines reaching far outside their bitmap rasterize correctly (the parts outside used to change the slope of
the edges inside).

## 3.2.1

Expand Down
85 changes: 67 additions & 18 deletions src/render/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ export interface BitmapGlyph {
top: number;
}

/**
* The colors of the font's first CPAL palette as RGBA (0xrrggbbaa), null for the text color (index 0xffff) and
* indices outside the palette
*/
export function parsePalette(font: Font, cpal: number): (index: number) => number | null {
const entries = font.u16(cpal + 2), records = cpal + font.u32(cpal + 8), first = font.u16(cpal + 12);
return index => {
if (index === 0xffff || index >= entries) return null;
const o = records + (first + index) * 4; // blue, green, red, alpha
return (font.u8(o + 2) << 24 | font.u8(o + 1) << 16 | font.u8(o) << 8 | font.u8(o + 3)) >>> 0;
};
}

/**
* The layers of the font's color glyphs (COLR version 0 records, with the colors of the first CPAL palette), null if the
* font has none. The lookup returns null for glyphs without layers
Expand All @@ -32,14 +45,7 @@ export function parseColorLayers(font: Font): ((glyph: number) => ColorLayer[] |

const baseCount = font.u16(colr + 2), bases = colr + font.u32(colr + 4), layers = colr + font.u32(colr + 8);
if (!baseCount) return null;

// the first palette
const entries = font.u16(cpal + 2), records = cpal + font.u32(cpal + 8), first = font.u16(cpal + 12);
const color = (index: number): number | null => {
if (index === 0xffff || index >= entries) return null;
const o = records + (first + index) * 4; // blue, green, red, alpha
return (font.u8(o + 2) << 24 | font.u8(o + 1) << 16 | font.u8(o) << 8 | font.u8(o + 3)) >>> 0;
};
const color = parsePalette(font, cpal);

const cache = new Map<number, ColorLayer[] | null>();
return glyph => {
Expand Down Expand Up @@ -67,21 +73,64 @@ export function parseColorLayers(font: Font): ((glyph: number) => ColorLayer[] |
}

/**
* The font's bitmap glyphs (CBDT/CBLC with PNG images) from the strike closest to `size` (the smallest at least as
* large, else the largest), null if the font has none. The lookup returns null for glyphs without a bitmap
* The font's bitmap glyphs (CBDT/CBLC or sbix, with PNG images) from the strike closest to `size` (the smallest at least
* as large, else the largest), null if the font has none. The lookup returns null for glyphs without a bitmap
*/
export function parseBitmaps(font: Font, size: number): ((glyph: number) => BitmapGlyph | null) | null {
return parseCBDT(font, size) ?? parseSbix(font, size);
}

// the index of the strike closest to the size (the smallest at least as large, else the largest), -1 if none
function closestStrike(ppems: number[], size: number): number {
let best = -1;
for (const [i, ppem] of ppems.entries()) {
const current = best < 0 ? 0 : ppems[best];
if (best < 0 || (current < size ? ppem > current : ppem >= size && ppem < current)) best = i;
}
return best;
}

// Apple's sbix: per strike, glyph data offsets; per glyph, the origin offset, a graphic type and the data
function parseSbix(font: Font, size: number): ((glyph: number) => BitmapGlyph | null) | null {
const sbix = font.tables.sbix?.offset;
if (sbix === undefined) return null;
const strikes = Array.from({ length: font.u32(sbix + 4) }, (_, i) => sbix + font.u32(sbix + 8 + i * 4));
const index = closestStrike(strikes.map(strike => font.u16(strike)), size);
if (index < 0) return null;
const strike = strikes[index], ppem = font.u16(strike);

const cache = new Map<number, BitmapGlyph | null>();
const lookup = (glyph: number, depth: number): BitmapGlyph | null => {
if (glyph >= font.numGlyphs || depth > 4) return null;
const start = strike + font.u32(strike + 4 + glyph * 4), end = strike + font.u32(strike + 8 + glyph * 4);
if (end - start < 8) return null;
const type = font.u32(start + 4);
// 'dupe': the data of another glyph
if (type === 0x64757065) return lookup(font.u16(start + 8), depth + 1);
if (type !== 0x706e6720) return null; // 'png '
const png = font.data.subarray(start + 8, end);
// the size from the PNG header (the IHDR chunk follows the signature)
if (png.length < 24) return null;
const header = new DataView(png.buffer, png.byteOffset + 16, 8), height = header.getUint32(4);
// the origin offset places the bitmap's bottom left corner
return { png, ppem, width: header.getUint32(0), height, left: font.i16(start), top: font.i16(start + 2) + height };
};
return glyph => {
let result = cache.get(glyph);
if (result === undefined) cache.set(glyph, result = lookup(glyph, 0));
return result;
};
}

// CBDT/CBLC (Google's color bitmaps)
function parseCBDT(font: Font, size: number): ((glyph: number) => BitmapGlyph | null) | null {
const cblc = font.tables.CBLC?.offset, cbdt = font.tables.CBDT?.offset;
if (cblc === undefined || cbdt === undefined) return null;

const sizes = font.u32(cblc + 4);
let strike = -1, strikePpem = 0;
for (let i = 0; i < sizes; i++) {
const ppem = font.u8(cblc + 8 + i * 48 + 45);
const better = strike < 0 || (strikePpem < size ? ppem > strikePpem : ppem >= size && ppem < strikePpem);
if (better) { strike = cblc + 8 + i * 48; strikePpem = ppem; }
}
if (strike < 0) return null;
const ppems = Array.from({ length: font.u32(cblc + 4) }, (_, i) => font.u8(cblc + 8 + i * 48 + 45));
const index = closestStrike(ppems, size);
if (index < 0) return null;
const strike = cblc + 8 + index * 48, strikePpem = ppems[index];

const array = cblc + font.u32(strike), subtables = font.u32(strike + 8);

Expand Down
Loading
Loading