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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
Rotating a 1920×1080 photo by 30° takes 51 instead of 119 ms (61 instead of 114 ms with alpha). Opaque pixels stay
within 1 of 3.2.1 (0.5-2% of the values differ), what shows of translucent ones (also the faded edges) within
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).
Invisible characters like joiners and variation selectors take no room, as in browsers. Emoji sequences (skin tones,
flags, families) still show as their parts.

### Performance

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
- [Rendering SVGs](https://imagescript.matmen.dev/classes/Image.html#rendersvg) (shapes, paths, gradients, strokes, clip paths,
masks, CSS; no text, images or filters)
- [Rendering vector fonts](https://imagescript.matmen.dev/classes/Image.html#rendertext) (TrueType and OpenType, with kerning,
outlines and TrueType hinting)
outlines, TrueType hinting, fallback fonts and color emoji)
- [Resizing](https://imagescript.matmen.dev/classes/Image.html#resize) (bilinear, bicubic, area averaging and nearest neighbor)
- Image manipulation functions ([crop](https://imagescript.matmen.dev/classes/Image.html#crop)
, [rotate](https://imagescript.matmen.dev/classes/Image.html#rotate)
Expand Down
66 changes: 49 additions & 17 deletions src/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2031,45 +2031,58 @@ export class Image {

/**
* Creates a new image containing the rendered text.
* @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use
* @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use, or several by priority: every character
* comes from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height. Color
* glyphs (COLR version 0 layers and CBDT bitmaps, as in emoji fonts) keep their colors
* @param size Font size in pixels (per em)
* @param text Text to render
* @param options Color, layout and style, see {@link TextOptions}; the image grows by the outline width on every
* side
* @return The rendered text
*/
static renderText(font: BinaryData, size: number, text: string, options?: TextOptions): Promise<Image>;
static renderText(font: BinaryData | BinaryData[], size: number, text: string, options?: TextOptions): Promise<Image>;
/**
* Creates a new image containing the rendered text.
* @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use
* @param font TrueType (ttf/ttc) or OpenType (otf) font buffer to use, or several by priority: every character
* comes from the first font that has it (e.g. `[text, emoji]`), the first one sets the line height. Color
* glyphs (COLR version 0 layers and CBDT bitmaps, as in emoji fonts) keep their colors
* @param size Font size in pixels (per em)
* @param text Text to render
* @param [color=0xffffffff] Text color to use
* @param [layout] The text layout to use
* @param style Outline, see {@link TextStyle}; the image grows by the outline width on every side
* @return The rendered text
*/
static renderText(font: BinaryData, size: number, text: string, color?: Color, layout?: TextLayout, style?: TextStyle): Promise<Image>;
static async renderText(font: BinaryData, size: number, text: string, colorOrOptions: Color | TextOptions = 0xffffffff, layoutArgument?: TextLayout, styleArgument: TextStyle = {}): Promise<Image> {
static renderText(font: BinaryData | BinaryData[], size: number, text: string, color?: Color, layout?: TextLayout, style?: TextStyle): Promise<Image>;
static async renderText(font: BinaryData | BinaryData[], size: number, text: string, colorOrOptions: Color | TextOptions = 0xffffffff, layoutArgument?: TextLayout, styleArgument: TextStyle = {}): Promise<Image> {
const options: TextOptions = isOptions(colorOrOptions) ? colorOrOptions : { ...styleArgument, color: colorOrOptions, layout: layoutArgument };
const { color = 0xffffffff, layout = new TextLayout(), strokeWidth = 0, strokeColor = 0x000000ff, hinting = true } = options;
if (isNaN(strokeWidth) || strokeWidth < 0)
throw new RangeError('Invalid stroke width');
const fonts = Array.isArray(font) ? font : [font];
if (!fonts.length) throw new RangeError('No font given');

const rendered = textRenderer.render(view(font), size, text, layout, hinting);
const [r, g, b, a] = Image.colorToRGBA(color);
// color glyph layers in the text color take it without its alpha, which applies to the whole color glyph
const rendered = await textRenderer.render(fonts.map(view), size, text, layout, hinting, (r << 24 | g << 16 | b << 8 | 255) >>> 0);

// the outline needs room around the glyphs
const pad = Math.ceil(strokeWidth);
const width = rendered.width + 2 * pad, height = rendered.height + 2 * pad;
const coverage = new Uint8Array(width * height);
for (let y = 0; y < rendered.height; y++)
coverage.set(rendered.coverage.subarray(y * rendered.width, (y + 1) * rendered.width), (y + pad) * width + pad);
let colored: Uint8ClampedArray | null = null;
if (rendered.color) {
colored = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < rendered.height; y++)
colored.set(rendered.color.subarray(y * rendered.width * 4, (y + 1) * rendered.width * 4), ((y + pad) * width + pad) * 4);
}

const image = new Image(width, height);
const bitmap = image.bitmap;

if (!strokeWidth) {
if (!strokeWidth && !colored) {
for (let i = 0; i < coverage.length; i++) {
if (!coverage[i]) continue;
bitmap[i * 4] = r;
Expand All @@ -2080,19 +2093,38 @@ export class Image {
return image;
}

const stroke = outline(coverage, width, height, strokeWidth);
// the outline goes around the color glyphs as well
let shape = coverage;
if (colored && strokeWidth) {
shape = coverage.slice();
for (let i = 0; i < shape.length; i++) shape[i] = Math.max(shape[i], colored[i * 4 + 3]);
}
const stroke = strokeWidth ? outline(shape, width, height, strokeWidth) : null;
const [sr, sg, sb, sa] = Image.colorToRGBA(strokeColor);

// the glyphs drawn over their outline
// color glyphs over the text (premultiplied, with the text's alpha), both over their outline
for (let i = 0; i < coverage.length; i++) {
const af = coverage[i] / 255 * a / 255, as = stroke[i] * sa / 255;
const ao = af + as * (1 - af);
if (ao <= 0) continue;

bitmap[i * 4] = (r * af + sr * as * (1 - af)) / ao;
bitmap[i * 4 + 1] = (g * af + sg * as * (1 - af)) / ao;
bitmap[i * 4 + 2] = (b * af + sb * as * (1 - af)) / ao;
bitmap[i * 4 + 3] = ao * 255;
let af = coverage[i] / 255 * a / 255, pr = r * af, pg = g * af, pb = b * af;
if (colored && colored[i * 4 + 3]) {
const ca = colored[i * 4 + 3] / 255 * a / 255, keep = 1 - ca, f = a / 255;
pr = colored[i * 4] * f + pr * keep;
pg = colored[i * 4 + 1] * f + pg * keep;
pb = colored[i * 4 + 2] * f + pb * keep;
af = ca + af * keep;
}
if (stroke) {
const as = stroke[i] * sa / 255 * (1 - af);
pr += sr * as;
pg += sg * as;
pb += sb * as;
af += as;
}
if (af <= 0) continue;

bitmap[i * 4] = pr / af;
bitmap[i * 4 + 1] = pg / af;
bitmap[i * 4 + 2] = pb / af;
bitmap[i * 4 + 3] = af * 255;
}

return image;
Expand Down
148 changes: 148 additions & 0 deletions src/render/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// color glyphs: layers of outlines with palette colors (COLR version 0 with CPAL), and bitmaps (CBDT/CBLC, PNG)

import { Font } from './font.ts';

/** an outline glyph drawn in a color, RGBA as 0xrrggbbaa (null: the text color) */
export interface ColorLayer {
glyph: number;
color: number | null;
}

/** a glyph's PNG and where it goes, in pixels of its strike */
export interface BitmapGlyph {
png: Uint8Array;
/** pixels per em of the strike */
ppem: number;
/** size of the bitmap */
width: number;
height: number;
/** offset of the bitmap's left edge from the pen position */
left: number;
/** offset of the bitmap's top edge above the baseline */
top: number;
}

/**
* 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
*/
export function parseColorLayers(font: Font): ((glyph: number) => ColorLayer[] | null) | null {
const colr = font.tables.COLR?.offset, cpal = font.tables.CPAL?.offset;
if (colr === undefined || cpal === undefined) return null;

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 cache = new Map<number, ColorLayer[] | null>();
return glyph => {
let result = cache.get(glyph);
if (result !== undefined) return result;
result = null;
// base glyph records are sorted by glyph
for (let lo = 0, hi = baseCount - 1; lo <= hi;) {
const mid = lo + hi >> 1, record = bases + mid * 6, id = font.u16(record);
if (id < glyph) lo = mid + 1;
else if (id > glyph) hi = mid - 1;
else {
const start = font.u16(record + 2), count = font.u16(record + 4);
result = [];
for (let i = 0; i < count; i++) {
const layer = layers + (start + i) * 4;
result.push({ glyph: font.u16(layer), color: color(font.u16(layer + 2)) });
}
break;
}
}
cache.set(glyph, result);
return result;
};
}

/**
* 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
*/
export function parseBitmaps(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 array = cblc + font.u32(strike), subtables = font.u32(strike + 8);

// where a glyph's data starts in CBDT, its image format, and its metrics if the index holds them (image format 19)
const locate = (glyph: number): { offset: number, format: number, metrics: number } | null => {
for (let i = 0; i < subtables; i++) {
const entry = array + i * 8, firstGlyph = font.u16(entry), lastGlyph = font.u16(entry + 2);
if (glyph < firstGlyph || glyph > lastGlyph) continue;

const header = array + font.u32(entry + 4);
const indexFormat = font.u16(header), format = font.u16(header + 2), data = cbdt + font.u32(header + 4), k = glyph - firstGlyph;
switch (indexFormat) {
case 1: {
const start = font.u32(header + 8 + k * 4), end = font.u32(header + 12 + k * 4);
return end > start ? { offset: data + start, format, metrics: -1 } : null;
}
case 2: return { offset: data + k * font.u32(header + 8), format, metrics: header + 12 };
case 3: {
const start = font.u16(header + 8 + k * 2), end = font.u16(header + 10 + k * 2);
return end > start ? { offset: data + start, format, metrics: -1 } : null;
}
case 4: {
const count = font.u32(header + 8);
for (let j = 0; j < count; j++) {
const pair = header + 12 + j * 4;
if (font.u16(pair) === glyph) return { offset: data + font.u16(pair + 2), format, metrics: -1 };
}
return null;
}
case 5: {
const size = font.u32(header + 8), count = font.u32(header + 20);
for (let j = 0; j < count; j++)
if (font.u16(header + 24 + j * 2) === glyph) return { offset: data + j * size, format, metrics: header + 12 };
return null;
}
default: return null;
}
}
return null;
};

const cache = new Map<number, BitmapGlyph | null>();
return glyph => {
let result = cache.get(glyph);
if (result !== undefined) return result;
result = null;

const location = locate(glyph);
if (location) {
const { format } = location;
let o = location.offset, metrics = location.metrics;
// small metrics (height, width, bearingX, bearingY, advance) and big ones start alike
if (format === 17) { metrics = o; o += 5; }
else if (format === 18) { metrics = o; o += 8; }
if ((format === 17 || format === 18 || format === 19) && metrics >= 0) {
const length = font.u32(o);
result = { png: font.data.subarray(o + 4, o + 4 + length), ppem: strikePpem, width: font.u8(metrics + 1), height: font.u8(metrics), left: font.i8(metrics + 2), top: font.i8(metrics + 3) };
}
}

cache.set(glyph, result);
return result;
};
}
6 changes: 4 additions & 2 deletions src/render/font.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export class Font {
lineGap: number;
numberOfHMetrics: number;
numGlyphs: number;
outlines: 'glyf' | 'cff';
/** where the glyph outlines come from ('none' for fonts with only bitmap glyphs) */
outlines: 'glyf' | 'cff' | 'none';
cff: CFF | null = null;
cmap: (codepoint: number) => number;
outlineCache: Map<number, PathCommand[]>;
Expand Down Expand Up @@ -89,6 +90,7 @@ export class Font {
this.outlines = 'cff';
this.cff = parseCFF(this, this.tables['CFF '].offset);
} else if (this.tables.CFF2) throw new Error('Font: CFF2 outlines are not supported');
else if (this.tables.CBDT && this.tables.CBLC) this.outlines = 'none';
else throw new Error('Font: no supported glyph outlines');

this.cmap = parseCmap(this, this.tables.cmap.offset);
Expand Down Expand Up @@ -221,7 +223,7 @@ export class Font {
if (glyph >= this.numGlyphs) glyph = 0;
let path = this.outlineCache.get(glyph);
if (!path) {
path = this.outlines === 'glyf' ? this.glyfOutline(glyph, 0) : this.cffOutline(glyph);
path = this.outlines === 'glyf' ? this.glyfOutline(glyph, 0) : this.outlines === 'cff' ? this.cffOutline(glyph) : [];
this.outlineCache.set(glyph, path);
}
return path;
Expand Down
Loading
Loading