Skip to content

Commit abd2f41

Browse files
matmenclaude
andcommitted
truetype hinting (#31)
An interpreter for TrueType instructions: font and control value programs per size, glyph programs with phantom points, composites, the twilight zone and the complete instruction set. renderText() runs it by default for fonts that have instructions and places their glyphs on whole pixels; fonts without instructions (and CFF outlines) render as before, {hinting: false} turns it off. Checked against FreeType's classic (v35) interpreter on 7 fonts (manual hints like DejaVu, ttfautohint like Noto/Open Sans, FontForge like FreeMono), all ASCII glyphs at 31 sizes: no glyph is off by half a pixel or more, 99.8-100% are within 1/16 px, and Noto, Noto Symbols and both Open Sans fonts match exactly. The details follow FreeType where the spec leaves them open: 16.16 scaling with halves rounded away from zero, original distances measured on the unscaled outline, no movement for near-perpendicular vectors, invalid references skip the instruction. Random bytecode never crashes or hangs (instruction and recursion limits). Also fixes the rasterizer dropping segments that touch the bitmap's left edge (float noise from curve subdivision gave x < 0), which inverted the glyph; hinted outlines hit that a lot. The targets of text in hinted fonts are regenerated (at 56-128 px the difference is barely visible); the layout test checks subpixel placement and runs unhinted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ljnxm1Qjrocq1izGajLx8J
1 parent c50cc03 commit abd2f41

10 files changed

Lines changed: 1334 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## 3.1.0 (unreleased)
4+
5+
### New
6+
7+
- **TrueType hinting** (#31): `renderText()` runs the hinting instructions of TrueType fonts, which align stems and
8+
edges with the pixel grid for crisper small text, like Windows renders it. Hinted outlines match FreeType's classic
9+
interpreter point for point, glyphs of hinted fonts are placed on whole pixels. Fonts without instructions (and
10+
CFF/OpenType outlines) render as before; `renderText(..., {hinting: false})` turns it off.
11+
12+
### Fixes
13+
14+
- Text rendering: glyph outlines touching the left edge of their bitmap could come out inverted (a rasterizer
15+
rounding issue); hinting made it common
16+
317
## 3.0.0
418

519
ImageScript is now written in TypeScript and has no WebAssembly or native binaries anymore. Every codec and renderer

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too)
2929
- [Decoding GIFs](https://imagescript.matmen.dev/classes/GIF.html#decode) with all frames, durations and disposal modes
3030
- [Rendering SVGs](https://imagescript.matmen.dev/classes/Image.html#rendersvg) (shapes, paths, gradients, strokes, clip paths,
3131
masks, CSS; no text, images or filters)
32-
- [Rendering vector fonts](https://imagescript.matmen.dev/classes/Image.html#rendertext) (TrueType and OpenType, with kerning and outlines)
32+
- [Rendering vector fonts](https://imagescript.matmen.dev/classes/Image.html#rendertext) (TrueType and OpenType, with kerning,
33+
outlines and TrueType hinting)
3334
- [Resizing](https://imagescript.matmen.dev/classes/Image.html#resize) (bilinear, bicubic, area averaging and nearest neighbor)
3435
- Image manipulation functions ([crop](https://imagescript.matmen.dev/classes/Image.html#crop)
3536
, [rotate](https://imagescript.matmen.dev/classes/Image.html#rotate)

‎src/image.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ export interface TextStyle {
4747
strokeWidth?: number;
4848
/** Color of the outline. Default: `0x000000ff` */
4949
strokeColor?: Color;
50+
/**
51+
* Whether to run the hinting instructions of TrueType fonts, which align stems and edges with the pixel grid for
52+
* crisper text at small sizes (like Windows renders text); the glyphs of hinted fonts are then placed on whole
53+
* pixels. Fonts without instructions (and CFF/OpenType outlines) aren't affected. Default: `true`
54+
*/
55+
hinting?: boolean;
5056
}
5157

5258
/** Options of {@link Image.cropCircle} */
@@ -1688,11 +1694,11 @@ export class Image {
16881694
* @return The rendered text
16891695
*/
16901696
static async renderText(font: BinaryData, size: number, text: string, color: Color = 0xffffffff, layout: TextLayout = new TextLayout(), style: TextStyle = {}): Promise<Image> {
1691-
const { strokeWidth = 0, strokeColor = 0x000000ff } = style;
1697+
const { strokeWidth = 0, strokeColor = 0x000000ff, hinting = true } = style;
16921698
if (isNaN(strokeWidth) || strokeWidth < 0)
16931699
throw new RangeError('Invalid stroke width');
16941700

1695-
const rendered = textRenderer.render(view(font), size, text, layout);
1701+
const rendered = textRenderer.render(view(font), size, text, layout, hinting);
16961702
const [r, g, b, a] = Image.colorToRGBA(color);
16971703

16981704
// the outline needs room around the glyphs

‎src/render/font.ts‎

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@
55
*/
66
export type PathCommand = [string, ...number[]];
77

8+
/** a TrueType glyph's points (font units) and instructions, for hinting */
9+
export interface GlyfPoints {
10+
xs: number[];
11+
ys: number[];
12+
/** on curve flags */
13+
on: boolean[];
14+
/** index of the last point of every contour */
15+
ends: number[];
16+
instructions: Uint8Array;
17+
/** glyph bounding box minimum x (the left side bearing's reference) */
18+
xMin: number;
19+
/** components of composite glyphs (their points are not included in xs/ys) */
20+
components: Array<{ glyph: number, dx: number, dy: number, matrix: [number, number, number, number], round: boolean, match: [number, number] | null, useMetrics: boolean }> | null;
21+
}
22+
823
interface CFF {
924
charStrings: Uint8Array[];
1025
globalSubrs: Uint8Array[];
@@ -106,6 +121,98 @@ export class Font {
106121
return this.u16(hmtx + index * 4);
107122
}
108123

124+
/** left side bearing in font units */
125+
leftSideBearing(glyph: number): number {
126+
const hmtx = this.tables.hmtx.offset;
127+
if (glyph < this.numberOfHMetrics) return this.i16(hmtx + glyph * 4 + 2);
128+
return this.i16(hmtx + this.numberOfHMetrics * 4 + (glyph - this.numberOfHMetrics) * 2);
129+
}
130+
131+
/**
132+
* A TrueType glyph's points and instructions (null for empty glyphs or CFF fonts)
133+
*/
134+
glyfPoints(glyph: number): GlyfPoints | null {
135+
if (this.outlines !== 'glyf' || glyph >= this.numGlyphs) return null;
136+
const loca = this.tables.loca.offset;
137+
const start = this.indexToLocFormat ? this.u32(loca + glyph * 4) : this.u16(loca + glyph * 2) * 2;
138+
const end = this.indexToLocFormat ? this.u32(loca + glyph * 4 + 4) : this.u16(loca + glyph * 2 + 2) * 2;
139+
if (end <= start) return null;
140+
141+
const offset = this.tables.glyf.offset + start;
142+
const contourCount = this.i16(offset);
143+
const xMin = this.i16(offset + 2);
144+
145+
if (contourCount < 0) {
146+
const components: NonNullable<GlyfPoints['components']> = [];
147+
let p = offset + 10, flags = 0;
148+
do {
149+
flags = this.u16(p);
150+
const component = this.u16(p + 2);
151+
p += 4;
152+
let arg1, arg2;
153+
if (flags & 1) {
154+
arg1 = flags & 2 ? this.i16(p) : this.u16(p);
155+
arg2 = flags & 2 ? this.i16(p + 2) : this.u16(p + 2);
156+
p += 4;
157+
} else {
158+
arg1 = flags & 2 ? this.i8(p) : this.u8(p);
159+
arg2 = flags & 2 ? this.i8(p + 1) : this.u8(p + 1);
160+
p += 2;
161+
}
162+
let matrix: [number, number, number, number] = [1, 0, 0, 1];
163+
const f2dot14 = (o: number): number => this.i16(o) / 16384;
164+
if (flags & 8) { const s = f2dot14(p); matrix = [s, 0, 0, s]; p += 2; }
165+
else if (flags & 0x40) { matrix = [f2dot14(p), 0, 0, f2dot14(p + 2)]; p += 4; }
166+
else if (flags & 0x80) { matrix = [f2dot14(p), f2dot14(p + 2), f2dot14(p + 4), f2dot14(p + 6)]; p += 8; }
167+
components.push({
168+
glyph: component,
169+
dx: flags & 2 ? arg1 : 0,
170+
dy: flags & 2 ? arg2 : 0,
171+
matrix,
172+
round: (flags & 4) !== 0,
173+
match: flags & 2 ? null : [arg1, arg2],
174+
useMetrics: (flags & 0x200) !== 0
175+
});
176+
} while (flags & 0x20);
177+
const instructions = flags & 0x100 ? this.data.subarray(p + 2, p + 2 + this.u16(p)) : new Uint8Array(0);
178+
return { xs: [], ys: [], on: [], ends: [], instructions, xMin, components };
179+
}
180+
181+
const ends: number[] = [];
182+
for (let i = 0; i < contourCount; i++) ends.push(this.u16(offset + 10 + i * 2));
183+
const pointCount = contourCount ? ends[contourCount - 1] + 1 : 0;
184+
185+
let p = offset + 10 + contourCount * 2;
186+
const instructionLength = this.u16(p);
187+
const instructions = this.data.subarray(p + 2, p + 2 + instructionLength);
188+
p += 2 + instructionLength;
189+
190+
const flags = new Uint8Array(pointCount);
191+
for (let i = 0; i < pointCount;) {
192+
const flag = this.u8(p++);
193+
flags[i++] = flag;
194+
if (flag & 8) for (let repeat = this.u8(p++); repeat > 0 && i < pointCount; repeat--) flags[i++] = flag;
195+
}
196+
197+
const xs: number[] = [], ys: number[] = [];
198+
for (const [coords, short, same] of [[xs, 2, 16], [ys, 4, 32]] as Array<[number[], number, number]>) {
199+
let value = 0;
200+
for (let i = 0; i < pointCount; i++) {
201+
const flag = flags[i];
202+
if (flag & short) {
203+
const delta = this.u8(p++);
204+
value += flag & same ? delta : -delta;
205+
} else if (!(flag & same)) {
206+
value += this.i16(p);
207+
p += 2;
208+
}
209+
coords.push(value);
210+
}
211+
}
212+
213+
return { xs, ys, on: Array.from(flags, f => (f & 1) === 1), ends, instructions, xMin, components: null };
214+
}
215+
109216
/**
110217
* @param {number} glyph
111218
* @return {Array} path commands in font units
@@ -239,10 +346,10 @@ export class Font {
239346
}
240347

241348
// converts a TrueType contour (quadratic, with implied on-curve points) to path commands
242-
function contourToPath(path: PathCommand[], xs: Int32Array, ys: Int32Array, flags: Uint8Array, first: number, last: number): void {
349+
export function contourToPath(path: PathCommand[], xs: ArrayLike<number>, ys: ArrayLike<number>, flags: ArrayLike<number | boolean>, first: number, last: number): void {
243350
if (last < first) return;
244351
const count = last - first + 1;
245-
const on = (i: number): number => flags[first + (i % count)] & 1;
352+
const on = (i: number): boolean => flags[first + (i % count)] === true || ((flags[first + (i % count)] as number) & 1) === 1;
246353
const x = (i: number): number => xs[first + (i % count)];
247354
const y = (i: number): number => ys[first + (i % count)];
248355

0 commit comments

Comments
 (0)