diff --git a/CHANGELOG.md b/CHANGELOG.md index 06125067..92904f27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,12 +20,21 @@ - **`colorToAlpha(color, options)`** (#39): makes a color transparent like GIMP's "Color to Alpha", antialiased edges become translucent instead of keeping a fringe of the removed color. Without a color, the top left pixel's is used. The color is removed everywhere in the image, not only from a connected background. +- **TrueType hinting** (#31): `renderText()` runs the hinting instructions of TrueType fonts, which align stems and + edges with the pixel grid for crisper small text, like Windows renders it. Hinted outlines match FreeType's classic + interpreter point for point, glyphs of hinted fonts are placed on whole pixels. Fonts without instructions (and + CFF/OpenType outlines) render as before; `renderText(..., {hinting: false})` turns it off. ### Deprecated - **`GIF`**: use `Animation`, which works the same and also handles APNG and WebP (`new GIF(frames).encode()` becomes `new Animation(frames).encodeGIF()`, `GIF.decode()` becomes `Animation.decode()`). `GIF` will be removed in 4.0. +### Fixes + +- Text rendering: glyph outlines touching the left edge of their bitmap could come out inverted (a rasterizer + rounding issue); hinting made it common + ## 3.0.0 ImageScript is now written in TypeScript and has no WebAssembly or native binaries anymore. Every codec and renderer diff --git a/README.md b/README.md index b8d92bd1..1547bd75 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ deno add jsr:@matmen/imagescript # Deno (npm:imagescript works too) frames, durations and disposal modes - [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 and outlines) +- [Rendering vector fonts](https://imagescript.matmen.dev/classes/Image.html#rendertext) (TrueType and OpenType, with kerning, + outlines and TrueType hinting) - [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) @@ -91,7 +92,9 @@ The deflate/inflate implementation is derived from [fflate](https://github.com/1 The JPEG codec is based in part on the work of the Independent JPEG Group (libjpeg) and on [pdf.js](https://github.com/mozilla/pdf.js), the glyph rasterizer is translated from [font-rs](https://github.com/raphlinus/font-rs). The WebP codecs follow [RFC 9649](https://www.rfc-editor.org/rfc/rfc9649) and [RFC 6386](https://www.rfc-editor.org/rfc/rfc6386) -and reproduces [libwebp](https://chromium.googlesource.com/webm/libwebp)'s color conversion. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for their licenses. +and reproduce [libwebp](https://chromium.googlesource.com/webm/libwebp)'s color conversion, the TrueType hinting follows +[FreeType](https://freetype.org)'s interpreter (portions of this software are copyright © 2026 The FreeType Project). +See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for their licenses. --- diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 65b279c5..2cbc7e6f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -13,6 +13,7 @@ following projects, under their own licenses. Each derived file says so in its h | [RFC 9649](https://www.rfc-editor.org/rfc/rfc9649) (WebP Image Format) | `src/codecs/vp8l.ts` (lossless bitstream, short distance code table), `src/codecs/webp.ts` (container) | BSD-3-Clause (Revised BSD) | | [RFC 6386](https://www.rfc-editor.org/rfc/rfc6386) (VP8 Data Format and Decoding Guide, with the libvpx reference decoder) | `src/codecs/vp8-tables.ts` (probability and quantizer tables), `src/codecs/vp8.ts` (decoding procedures) | BSD-3-Clause | | [libwebp](https://chromium.googlesource.com/webm/libwebp) | `src/codecs/vp8.ts` (YUV to RGB conversion, "fancy" chroma upsampling), `src/codecs/webp.ts` (animation frame blending) | BSD-3-Clause | +| [FreeType](https://freetype.org) | `src/render/hinting.ts` (behavior and parts of the logic of the TrueType interpreter) | FreeType License | ## fflate, Fast CRC32 in JavaScript @@ -142,3 +143,14 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSE WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` + +## FreeType + +Portions of this software are copyright © 2026 The FreeType Project (https://freetype.org). All rights reserved. + +The TrueType hinting interpreter in `src/render/hinting.ts` is written after the TrueType instruction specification. +Where the specification leaves details open, its behavior and parts of its logic follow FreeType's interpreter +(`src/truetype/ttinterp.c`): the fixed point rounding, measuring original distances on the unscaled outline, the +interpolation of untouched points (IUP), the handling of near-perpendicular freedom and projection vectors, and the +tolerance of invalid references. FreeType is used under the FreeType License, included as +[licenses/FTL.txt](licenses/FTL.txt). diff --git a/licenses/FTL.txt b/licenses/FTL.txt new file mode 100644 index 00000000..b9e8cbd6 --- /dev/null +++ b/licenses/FTL.txt @@ -0,0 +1,169 @@ + The FreeType Project LICENSE + ---------------------------- + + 2006-Jan-27 + + Copyright 1996-2002, 2006 by + David Turner, Robert Wilhelm, and Werner Lemberg + + + +Introduction +============ + + The FreeType Project is distributed in several archive packages; + some of them may contain, in addition to the FreeType font engine, + various tools and contributions which rely on, or relate to, the + FreeType Project. + + This license applies to all files found in such packages, and + which do not fall under their own explicit license. The license + affects thus the FreeType font engine, the test programs, + documentation and makefiles, at the very least. + + This license was inspired by the BSD, Artistic, and IJG + (Independent JPEG Group) licenses, which all encourage inclusion + and use of free software in commercial and freeware products + alike. As a consequence, its main points are that: + + o We don't promise that this software works. However, we will be + interested in any kind of bug reports. (`as is' distribution) + + o You can use this software for whatever you want, in parts or + full form, without having to pay us. (`royalty-free' usage) + + o You may not pretend that you wrote this software. If you use + it, or only parts of it, in a program, you must acknowledge + somewhere in your documentation that you have used the + FreeType code. (`credits') + + We specifically permit and encourage the inclusion of this + software, with or without modifications, in commercial products. + We disclaim all warranties covering The FreeType Project and + assume no liability related to The FreeType Project. + + + Finally, many people asked us for a preferred form for a + credit/disclaimer to use in compliance with this license. We thus + encourage you to use the following text: + + """ + Portions of this software are copyright © The FreeType + Project (https://freetype.org). All rights reserved. + """ + + Please replace with the value from the FreeType version you + actually use. + + +Legal Terms +=========== + +0. Definitions +-------------- + + Throughout this license, the terms `package', `FreeType Project', + and `FreeType archive' refer to the set of files originally + distributed by the authors (David Turner, Robert Wilhelm, and + Werner Lemberg) as the `FreeType Project', be they named as alpha, + beta or final release. + + `You' refers to the licensee, or person using the project, where + `using' is a generic term including compiling the project's source + code as well as linking it to form a `program' or `executable'. + This program is referred to as `a program using the FreeType + engine'. + + This license applies to all files distributed in the original + FreeType Project, including all source code, binaries and + documentation, unless otherwise stated in the file in its + original, unmodified form as distributed in the original archive. + If you are unsure whether or not a particular file is covered by + this license, you must contact us to verify this. + + The FreeType Project is copyright (C) 1996-2000 by David Turner, + Robert Wilhelm, and Werner Lemberg. All rights reserved except as + specified below. + +1. No Warranty +-------------- + + THE FREETYPE PROJECT IS PROVIDED `AS IS' WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. IN NO EVENT WILL ANY OF THE AUTHORS OR COPYRIGHT HOLDERS + BE LIABLE FOR ANY DAMAGES CAUSED BY THE USE OR THE INABILITY TO + USE, OF THE FREETYPE PROJECT. + +2. Redistribution +----------------- + + This license grants a worldwide, royalty-free, perpetual and + irrevocable right and license to use, execute, perform, compile, + display, copy, create derivative works of, distribute and + sublicense the FreeType Project (in both source and object code + forms) and derivative works thereof for any purpose; and to + authorize others to exercise some or all of the rights granted + herein, subject to the following conditions: + + o Redistribution of source code must retain this license file + (`FTL.TXT') unaltered; any additions, deletions or changes to + the original files must be clearly indicated in accompanying + documentation. The copyright notices of the unaltered, + original files must be preserved in all copies of source + files. + + o Redistribution in binary form must provide a disclaimer that + states that the software is based in part of the work of the + FreeType Team, in the distribution documentation. We also + encourage you to put an URL to the FreeType web page in your + documentation, though this isn't mandatory. + + These conditions apply to any software derived from or based on + the FreeType Project, not just the unmodified files. If you use + our work, you must acknowledge us. However, no fee need be paid + to us. + +3. Advertising +-------------- + + Neither the FreeType authors and contributors nor you shall use + the name of the other for commercial, advertising, or promotional + purposes without specific prior written permission. + + We suggest, but do not require, that you use one or more of the + following phrases to refer to this software in your documentation + or advertising materials: `FreeType Project', `FreeType Engine', + `FreeType library', or `FreeType Distribution'. + + As you have not signed this license, you are not required to + accept it. However, as the FreeType Project is copyrighted + material, only this license, or another one contracted with the + authors, grants you the right to use, distribute, and modify it. + Therefore, by using, distributing, or modifying the FreeType + Project, you indicate that you understand and accept all the terms + of this license. + +4. Contacts +----------- + + There are two mailing lists related to FreeType: + + o freetype@nongnu.org + + Discusses general use and applications of FreeType, as well as + future and wanted additions to the library and distribution. + If you are looking for support, start in this list if you + haven't found anything to help you in the documentation. + + o freetype-devel@nongnu.org + + Discusses bugs, as well as engine internals, design issues, + specific licenses, porting, etc. + + Our home page can be found at + + https://freetype.org + + +--- end of FTL.TXT --- diff --git a/src/image.ts b/src/image.ts index 2e3ae68e..e254785b 100644 --- a/src/image.ts +++ b/src/image.ts @@ -49,6 +49,12 @@ export interface TextStyle { strokeWidth?: number; /** Color of the outline. Default: `0x000000ff` */ strokeColor?: Color; + /** + * Whether to run the hinting instructions of TrueType fonts, which align stems and edges with the pixel grid for + * crisper text at small sizes (like Windows renders text); the glyphs of hinted fonts are then placed on whole + * pixels. Fonts without instructions (and CFF/OpenType outlines) aren't affected. Default: `true` + */ + hinting?: boolean; } /** Options of {@link Image.cropCircle} */ @@ -1754,11 +1760,11 @@ export class Image { * @return The rendered text */ static async renderText(font: BinaryData, size: number, text: string, color: Color = 0xffffffff, layout: TextLayout = new TextLayout(), style: TextStyle = {}): Promise { - const { strokeWidth = 0, strokeColor = 0x000000ff } = style; + const { strokeWidth = 0, strokeColor = 0x000000ff, hinting = true } = style; if (isNaN(strokeWidth) || strokeWidth < 0) throw new RangeError('Invalid stroke width'); - const rendered = textRenderer.render(view(font), size, text, layout); + const rendered = textRenderer.render(view(font), size, text, layout, hinting); const [r, g, b, a] = Image.colorToRGBA(color); // the outline needs room around the glyphs diff --git a/src/render/font.ts b/src/render/font.ts index b95a3e4e..292116d5 100644 --- a/src/render/font.ts +++ b/src/render/font.ts @@ -5,6 +5,21 @@ */ export type PathCommand = [string, ...number[]]; +/** a TrueType glyph's points (font units) and instructions, for hinting */ +export interface GlyfPoints { + xs: number[]; + ys: number[]; + /** on curve flags */ + on: boolean[]; + /** index of the last point of every contour */ + ends: number[]; + instructions: Uint8Array; + /** glyph bounding box minimum x (the left side bearing's reference) */ + xMin: number; + /** components of composite glyphs (their points are not included in xs/ys) */ + components: Array<{ glyph: number, dx: number, dy: number, matrix: [number, number, number, number], round: boolean, match: [number, number] | null, useMetrics: boolean }> | null; +} + interface CFF { charStrings: Uint8Array[]; globalSubrs: Uint8Array[]; @@ -106,6 +121,98 @@ export class Font { return this.u16(hmtx + index * 4); } + /** left side bearing in font units */ + leftSideBearing(glyph: number): number { + const hmtx = this.tables.hmtx.offset; + if (glyph < this.numberOfHMetrics) return this.i16(hmtx + glyph * 4 + 2); + return this.i16(hmtx + this.numberOfHMetrics * 4 + (glyph - this.numberOfHMetrics) * 2); + } + + /** + * A TrueType glyph's points and instructions (null for empty glyphs or CFF fonts) + */ + glyfPoints(glyph: number): GlyfPoints | null { + if (this.outlines !== 'glyf' || glyph >= this.numGlyphs) return null; + const loca = this.tables.loca.offset; + const start = this.indexToLocFormat ? this.u32(loca + glyph * 4) : this.u16(loca + glyph * 2) * 2; + const end = this.indexToLocFormat ? this.u32(loca + glyph * 4 + 4) : this.u16(loca + glyph * 2 + 2) * 2; + if (end <= start) return null; + + const offset = this.tables.glyf.offset + start; + const contourCount = this.i16(offset); + const xMin = this.i16(offset + 2); + + if (contourCount < 0) { + const components: NonNullable = []; + let p = offset + 10, flags = 0; + do { + flags = this.u16(p); + const component = this.u16(p + 2); + p += 4; + let arg1, arg2; + if (flags & 1) { + arg1 = flags & 2 ? this.i16(p) : this.u16(p); + arg2 = flags & 2 ? this.i16(p + 2) : this.u16(p + 2); + p += 4; + } else { + arg1 = flags & 2 ? this.i8(p) : this.u8(p); + arg2 = flags & 2 ? this.i8(p + 1) : this.u8(p + 1); + p += 2; + } + let matrix: [number, number, number, number] = [1, 0, 0, 1]; + const f2dot14 = (o: number): number => this.i16(o) / 16384; + if (flags & 8) { const s = f2dot14(p); matrix = [s, 0, 0, s]; p += 2; } + else if (flags & 0x40) { matrix = [f2dot14(p), 0, 0, f2dot14(p + 2)]; p += 4; } + else if (flags & 0x80) { matrix = [f2dot14(p), f2dot14(p + 2), f2dot14(p + 4), f2dot14(p + 6)]; p += 8; } + components.push({ + glyph: component, + dx: flags & 2 ? arg1 : 0, + dy: flags & 2 ? arg2 : 0, + matrix, + round: (flags & 4) !== 0, + match: flags & 2 ? null : [arg1, arg2], + useMetrics: (flags & 0x200) !== 0 + }); + } while (flags & 0x20); + const instructions = flags & 0x100 ? this.data.subarray(p + 2, p + 2 + this.u16(p)) : new Uint8Array(0); + return { xs: [], ys: [], on: [], ends: [], instructions, xMin, components }; + } + + const ends: number[] = []; + for (let i = 0; i < contourCount; i++) ends.push(this.u16(offset + 10 + i * 2)); + const pointCount = contourCount ? ends[contourCount - 1] + 1 : 0; + + let p = offset + 10 + contourCount * 2; + const instructionLength = this.u16(p); + const instructions = this.data.subarray(p + 2, p + 2 + instructionLength); + p += 2 + instructionLength; + + const flags = new Uint8Array(pointCount); + for (let i = 0; i < pointCount;) { + const flag = this.u8(p++); + flags[i++] = flag; + if (flag & 8) for (let repeat = this.u8(p++); repeat > 0 && i < pointCount; repeat--) flags[i++] = flag; + } + + const xs: number[] = [], ys: number[] = []; + for (const [coords, short, same] of [[xs, 2, 16], [ys, 4, 32]] as Array<[number[], number, number]>) { + let value = 0; + for (let i = 0; i < pointCount; i++) { + const flag = flags[i]; + if (flag & short) { + const delta = this.u8(p++); + value += flag & same ? delta : -delta; + } else if (!(flag & same)) { + value += this.i16(p); + p += 2; + } + coords.push(value); + } + } + + return { xs, ys, on: Array.from(flags, f => (f & 1) === 1), ends, instructions, xMin, components: null }; + } + /** * @param {number} glyph * @return {Array} path commands in font units @@ -239,10 +346,10 @@ export class Font { } // converts a TrueType contour (quadratic, with implied on-curve points) to path commands -function contourToPath(path: PathCommand[], xs: Int32Array, ys: Int32Array, flags: Uint8Array, first: number, last: number): void { +export function contourToPath(path: PathCommand[], xs: ArrayLike, ys: ArrayLike, flags: ArrayLike, first: number, last: number): void { if (last < first) return; const count = last - first + 1; - const on = (i: number): number => flags[first + (i % count)] & 1; + const on = (i: number): boolean => flags[first + (i % count)] === true || ((flags[first + (i % count)] as number) & 1) === 1; const x = (i: number): number => xs[first + (i % count)]; const y = (i: number): number => ys[first + (i % count)]; diff --git a/src/render/hinting.ts b/src/render/hinting.ts new file mode 100644 index 00000000..d624c89a --- /dev/null +++ b/src/render/hinting.ts @@ -0,0 +1,1116 @@ +/*! + * TrueType hinting interpreter. Where the TrueType specification leaves details open, its behavior and parts of its + * logic follow FreeType's TrueType interpreter (src/truetype/ttinterp.c): fixed point rounding, original distances + * on the unscaled outline (IP, IUP, MDRP, MD), the IUP interpolation, the movement along near-perpendicular vectors + * and the tolerance of invalid references. Portions of this software are copyright © 2026 The FreeType Project + * (https://freetype.org). All rights reserved. FreeType License, see THIRD_PARTY_NOTICES.md and licenses/FTL.txt + */ +// TrueType hinting: an interpreter for the glyph programs (instructions) of TrueType fonts, following the TrueType +// instruction set specification (Apple TrueType Reference Manual, Microsoft OpenType "TrueType instructions"). +// Hinting moves outline points so that stems and edges land on pixel boundaries at small sizes. +// Coordinates are 26.6 fixed point numbers (64 = one pixel), kept as JS numbers. The tests compare the results with +// FreeType's classic ("v35") interpreter point for point. +import { Font, GlyfPoints, PathCommand, contourToPath } from './font.ts'; + +class HintError extends Error { } + +/** + * Invalid references (points, zones, control values) only skip the instruction: fonts in the wild have such bugs and + * the rasterizers tolerate them (FreeType outside of its "pedantic" mode) + */ +class SkipInstruction extends Error { } + +// rounds halves away from zero, like the fixed point helpers of the TrueType rasterizers (FreeType's FT_MulFix and +// FT_MulDiv); Math.round rounds -31.5 to -31, which can tip a later grid rounding by a whole pixel +const roundHalfAway = (v: number): number => v < 0 ? -Math.round(-v) : Math.round(v); + +// 16.16 fixed point multiplication and division as the rasterizers do them: scaling by a 16.16 factor (rather than +// the exact ratio) decides which values land on .5 and thus how they round +const mulFix = (a: number, b: number): number => roundHalfAway(a * b / 65536); +const divFix = (a: number, b: number): number => b === 0 ? 0 : roundHalfAway(a * 65536 / b); + +interface Vector { x: number, y: number } + +interface GraphicsState { + pv: Vector; + fv: Vector; + dv: Vector; + rp0: number; + rp1: number; + rp2: number; + zp0: number; + zp1: number; + zp2: number; + loop: number; + minimumDistance: number; + cvtCutIn: number; + singleWidthCutIn: number; + singleWidth: number; + deltaBase: number; + deltaShift: number; + autoFlip: boolean; + /** null: rounding off */ + round: { period: number, phase: number, threshold: number } | null; + instructControl: number; +} + +const ROUND_GRID = { period: 64, phase: 0, threshold: 32 }; + +const defaultState = (): GraphicsState => ({ + pv: { x: 1, y: 0 }, fv: { x: 1, y: 0 }, dv: { x: 1, y: 0 }, + rp0: 0, rp1: 0, rp2: 0, zp0: 1, zp1: 1, zp2: 1, loop: 1, + minimumDistance: 64, cvtCutIn: 68, singleWidthCutIn: 0, singleWidth: 0, + deltaBase: 9, deltaShift: 3, autoFlip: true, round: ROUND_GRID, instructControl: 0 +}); + +interface Zone { + curX: Float64Array; + curY: Float64Array; + orgX: Float64Array; + orgY: Float64Array; + /** unscaled original positions (font units), measurements between original glyph points use them */ + orusX: Float64Array; + orusY: Float64Array; + touchX: Uint8Array; + touchY: Uint8Array; + on: Uint8Array; + ends: number[]; +} + +function makeZone(count: number): Zone { + return { + curX: new Float64Array(count), curY: new Float64Array(count), orgX: new Float64Array(count), orgY: new Float64Array(count), + orusX: new Float64Array(count), orusY: new Float64Array(count), + touchX: new Uint8Array(count), touchY: new Uint8Array(count), on: new Uint8Array(count), ends: [] + }; +} + +interface FunctionDef { + code: Uint8Array; + start: number; + end: number; +} + +/** state of a font at one size, after the font and control value programs ran */ +interface SizeState { + ppem: number; + /** 26.6 units per font unit */ + scale: number; + /** the same as 16.16 fixed point number */ + scaleFixed: number; + cvt: Float64Array; + storage: Int32Array; + state: GraphicsState; + twilight: Zone; + functions: Map; + instructions: Map; + /** set if the font and control value programs failed, the font is then rendered unhinted */ + broken: boolean; +} + +export interface HintedGlyph { + /** outline in pixels, relative to the pen position (y up) */ + path: PathCommand[]; + /** advance width in whole pixels */ + advance: number; +} + +const MAX_INSTRUCTIONS = 1_000_000; + +/** + * Executes TrueType instructions for one font + */ +export class Hinter { + font: Font; + fpgm: Uint8Array; + prep: Uint8Array; + cvt: Int16Array; + maxStack: number; + maxStorage: number; + maxTwilight: number; + sizes = new Map(); + /** 26.6 units per font unit (16.16 fixed point) of the glyph being loaded */ + private scaleFixed = 0; + + constructor(font: Font) { + this.font = font; + const table = (tag: string): Uint8Array => { + const t = font.tables[tag]; + return t ? font.data.subarray(t.offset, t.offset + t.length) : new Uint8Array(0); + }; + this.fpgm = table('fpgm'); + this.prep = table('prep'); + const cvt = table('cvt '); + this.cvt = new Int16Array(cvt.length >> 1); + for (let i = 0; i < this.cvt.length; i++) this.cvt[i] = font.i16(font.tables['cvt '].offset + i * 2); + + const maxp = font.tables.maxp.offset; + const version = font.u32(maxp); + this.maxTwilight = version >= 0x10000 ? font.u16(maxp + 16) : 0; + this.maxStorage = version >= 0x10000 ? font.u16(maxp + 18) : 0; + this.maxStack = version >= 0x10000 ? font.u16(maxp + 24) : 0; + } + + /** whether the font has hinting instructions (a font program, control value program or control values) */ + get hasInstructions(): boolean { + return this.font.outlines === 'glyf' && (this.fpgm.length > 0 || this.prep.length > 0 || this.cvt.length > 0); + } + + private size(ppem: number): SizeState { + let size = this.sizes.get(ppem); + if (size) return size; + + const scaleFixed = divFix(ppem * 64, this.font.unitsPerEm); + const scale = scaleFixed / 65536; + size = { + ppem, scale, scaleFixed, + cvt: Float64Array.from(this.cvt, v => mulFix(v, scaleFixed)), + storage: new Int32Array(Math.max(this.maxStorage, 64)), + state: defaultState(), + twilight: makeZone(Math.max(this.maxTwilight, 1)), + functions: new Map(), + instructions: new Map(), + broken: false + }; + + try { + const fpgm = new Execution(this, size, size.twilight, makeZone(0)); + fpgm.run(this.fpgm); + const prep = new Execution(this, size, size.twilight, makeZone(0)); + prep.run(this.prep); + // the control value program's graphics state is the default for every glyph + size.state = { ...prep.gs, pv: { ...prep.gs.pv }, fv: { ...prep.gs.fv }, dv: { ...prep.gs.dv } }; + } catch (err) { + if (!(err instanceof HintError)) throw err; + size.broken = true; + } + + this.sizes.set(ppem, size); + return size; + } + + /** + * Hints a glyph at a size + * @param size pixels per em + * @return the hinted outline, or null if the glyph can't be hinted (it is then rendered unhinted) + */ + glyph(glyph: number, size: number): HintedGlyph | null { + const ppem = Math.round(size); + if (ppem < 1 || ppem > 1000) return null; + const state = this.size(ppem); + if (state.broken) return null; + + try { + const outline = this.load(glyph, state, 0); + if (!outline) return { path: [], advance: Math.round(this.font.advance(glyph) * state.scale / 64) }; + const { zone, count } = outline; + + // relative to the (hinted) origin phantom point + const originX = zone.curX[count], advance = Math.round((zone.curX[count + 1] - originX) / 64); + const xs = new Float64Array(count), ys = new Float64Array(count); + for (let i = 0; i < count; i++) { + xs[i] = (zone.curX[i] - originX) / 64; + ys[i] = zone.curY[i] / 64; + } + + const path: PathCommand[] = []; + let first = 0; + for (const last of zone.ends) { + contourToPath(path, xs, ys, zone.on, first, last); + first = last + 1; + } + return { path, advance }; + } catch (err) { + if (!(err instanceof HintError)) throw err; + return null; + } + } + + /** + * Loads, scales and hints a glyph (recursively for composites) + * @return the glyph zone (with 4 phantom points after the outline points) + */ + private load(glyph: number, size: SizeState, depth: number): { zone: Zone, count: number } | null { + if (depth > 8) throw new HintError('composite glyphs nested too deep'); + const font = this.font; + const data: GlyfPoints | null = font.glyfPoints(glyph); + if (!data) return null; + + const scale = size.scale, scaleFixed = this.scaleFixed = size.scaleFixed; + const lsb = font.leftSideBearing(glyph), advance = font.advance(glyph); + let count: number, zone: Zone; + + if (data.components) { + // hint the components on their own, then place them + const parts: Array<{ zone: Zone, count: number }> = []; + const placed: Array<{ dx: number, dy: number }> = []; + let total = 0; + const xsAll: number[] = [], ysAll: number[] = [], on: number[] = [], ends: number[] = []; + let useMetrics: { zone: Zone, count: number } | null = null; + + for (const component of data.components) { + const part = this.load(component.glyph, size, depth + 1); + if (!part) continue; + const [a, b, c, d] = component.matrix; + const transformed = a !== 1 || b !== 0 || c !== 0 || d !== 1; + + let dx: number, dy: number; + if (component.match) { + // point matching: the component's point lands on the already placed point + const [parentPoint, childPoint] = component.match; + if (parentPoint >= total || childPoint >= part.count) throw new HintError('invalid point matching'); + const cx = part.zone.curX[childPoint], cy = part.zone.curY[childPoint]; + dx = xsAll[parentPoint] - (a * cx + c * cy); + dy = ysAll[parentPoint] - (b * cx + d * cy); + } else { + dx = mulFix(component.dx, scaleFixed); + dy = mulFix(component.dy, scaleFixed); + if (component.round) { dx = Math.round(dx / 64) * 64; dy = Math.round(dy / 64) * 64; } + } + placed.push({ dx, dy }); + + for (let i = 0; i < part.count; i++) { + const x = part.zone.curX[i], y = part.zone.curY[i]; + xsAll.push(transformed ? a * x + c * y + dx : x + dx); + ysAll.push(transformed ? b * x + d * y + dy : y + dy); + on.push(part.zone.on[i]); + } + for (const end of part.zone.ends) ends.push(end + total); + total += part.count; + parts.push(part); + if (component.useMetrics) useMetrics = part; + } + + count = total; + zone = makeZone(count + 4); + zone.ends = ends; + // the components are hinted already: their positions are the composite's original ones too + zone.curX.set(xsAll); + zone.curY.set(ysAll); + zone.orgX.set(xsAll); + zone.orgY.set(ysAll); + for (let i = 0; i < count; i++) { zone.orusX[i] = xsAll[i] / scale; zone.orusY[i] = ysAll[i] / scale; } + zone.on.set(on); + this.phantoms(zone, count, data.xMin - lsb, advance, useMetrics); + + if (data.instructions.length && !(size.state.instructControl & 1)) { + new Execution(this, size, size.twilight, zone).run(data.instructions, true); + } + return { zone, count }; + } + + count = data.xs.length; + zone = makeZone(count + 4); + zone.ends = data.ends; + for (let i = 0; i < count; i++) { + zone.orusX[i] = data.xs[i]; + zone.orusY[i] = data.ys[i]; + zone.orgX[i] = mulFix(data.xs[i], scaleFixed); + zone.orgY[i] = mulFix(data.ys[i], scaleFixed); + zone.on[i] = data.on[i] ? 1 : 0; + } + zone.curX.set(zone.orgX); + zone.curY.set(zone.orgY); + this.phantoms(zone, count, data.xMin - lsb, advance, null); + + if (data.instructions.length && !(size.state.instructControl & 1)) + new Execution(this, size, size.twilight, zone).run(data.instructions, true); + + return { zone, count }; + } + + // phantom points: origin, advance, top and bottom. Their current positions start on the pixel grid while the + // original ones don't, so programs can shift outline points by how much the grid fitting moved them + // (origin and advance in font units) + private phantoms(zone: Zone, count: number, origin: number, advance: number, metrics: { zone: Zone, count: number } | null): void { + const font = this.font; + const units = [origin, origin + advance, font.ascender, font.descender]; + const org = units.map(v => mulFix(v, this.scaleFixed)); + zone.orusX[count] = units[0]; + zone.orusX[count + 1] = units[1]; + zone.orusY[count + 2] = units[2]; + zone.orusY[count + 3] = units[3]; + const grid = (v: number): number => Math.round(v / 64) * 64; + zone.orgX[count] = org[0]; + zone.orgX[count + 1] = org[1]; + zone.curX[count] = grid(org[0]); + zone.curX[count + 1] = grid(org[1]); + zone.orgY[count + 2] = org[2]; + zone.orgY[count + 3] = org[3]; + zone.curY[count + 2] = grid(org[2]); + zone.curY[count + 3] = grid(org[3]); + + // USE_MY_METRICS: the component's (hinted) horizontal metrics + if (metrics) { + for (const k of [0, 1]) { + zone.orusX[count + k] = metrics.zone.orusX[metrics.count + k]; + zone.orgX[count + k] = metrics.zone.orgX[metrics.count + k]; + zone.curX[count + k] = metrics.zone.curX[metrics.count + k]; + } + } + } +} + +/** + * One run of a program (font program, control value program or a glyph program) + */ +class Execution { + hinter: Hinter; + size: SizeState; + gs: GraphicsState; + zones: [Zone, Zone]; + stack: number[] = []; + callDepth = 0; + steps = 0; + isGlyph = false; + + constructor(hinter: Hinter, size: SizeState, twilight: Zone, glyph: Zone) { + this.hinter = hinter; + this.size = size; + const s = size.state; + this.gs = { ...s, pv: { ...s.pv }, fv: { ...s.fv }, dv: { ...s.dv } }; + this.zones = [twilight, glyph]; + } + + run(code: Uint8Array, glyph = false): void { + this.isGlyph = glyph; + if (glyph) { + // these are reset for every glyph program (the rest keeps what the control value program set) + const gs = this.gs; + gs.pv = { x: 1, y: 0 }; gs.fv = { x: 1, y: 0 }; gs.dv = { x: 1, y: 0 }; + gs.rp0 = gs.rp1 = gs.rp2 = 0; + gs.zp0 = gs.zp1 = gs.zp2 = 1; + gs.loop = 1; + gs.round = ROUND_GRID; + // twilight points are reset for every glyph + const t = this.zones[0]; + t.curX.fill(0); t.curY.fill(0); t.orgX.fill(0); t.orgY.fill(0); t.touchX.fill(0); t.touchY.fill(0); + } + this.execute(code, 0, code.length); + } + + // stack + // missing arguments are zeros (like in the rasterizers) + private pop(): number { + return this.stack.length ? this.stack.pop() as number : 0; + } + + private push(v: number): void { + if (this.stack.length > Math.max(this.hinter.maxStack, 256) + 64) throw new HintError('stack overflow'); + this.stack.push(v); + } + + private zone(n: number): Zone { + if (n !== 0 && n !== 1) throw new SkipInstruction(); + return this.zones[n]; + } + + private point(zone: Zone, p: number): number { + if (!(p >= 0 && p < zone.curX.length)) throw new SkipInstruction(); + return p; + } + + // projections onto the projection vector (current positions) and the dual vector (original positions) + private project(dx: number, dy: number): number { + return dx * this.gs.pv.x + dy * this.gs.pv.y; + } + + private dualProject(dx: number, dy: number): number { + return dx * this.gs.dv.x + dy * this.gs.dv.y; + } + + /** + * How far a point moves (along the freedom vector) per unit of projected distance: fv / (fv . pv). + * Nearly perpendicular vectors would make points fly off, so like current FreeType they don't move points at all + * (|fv . pv| < 1/3, "avoids bad rendering in Palatino without negative consequences") + */ + private moveVector(): Vector { + const { fv, pv } = this.gs; + const dot = fv.x * pv.x + fv.y * pv.y; + if (dot >= 0x3ffe / 0x4000) return fv; + if (Math.abs(dot) < 0x1555 / 0x4000) return { x: 0, y: 0 }; + return { x: fv.x / dot, y: fv.y / dot }; + } + + /** moves a point along the freedom vector so its projection changes by distance, and touches it */ + private move(zone: Zone, p: number, distance: number, touch = true): void { + const { fv } = this.gs; + const mv = this.moveVector(); + // points are touched along the freedom vector's axes even if they can't move + if (fv.x !== 0) { + zone.curX[p] += distance * mv.x; + if (touch) zone.touchX[p] = 1; + } + if (fv.y !== 0) { + zone.curY[p] += distance * mv.y; + if (touch) zone.touchY[p] = 1; + } + } + + private round(d: number): number { + const r = this.gs.round; + if (!r) return d; + if (d >= 0) { + const v = Math.floor((d - r.phase + r.threshold) / r.period) * r.period + r.phase; + return v < 0 ? r.phase : v; + } + const v = -(Math.floor((-d - r.phase + r.threshold) / r.period) * r.period + r.phase); + return v > 0 ? -r.phase : v; + } + + private setVector(target: 'pv' | 'fv' | 'dv', x: number, y: number): void { + const length = Math.hypot(x, y); + if (length === 0) return; + this.gs[target] = { x: x / length, y: y / length }; + } + + // vector from two points (for SPVTL, SFVTL, SDPVTL) + private lineVector(p1: number, p2: number, perpendicular: boolean, original: boolean): Vector { + const z1 = this.zone(this.gs.zp1), z2 = this.zone(this.gs.zp2); + this.point(z1, p2); + this.point(z2, p1); + let x = (original ? z1.orgX[p2] - z2.orgX[p1] : z1.curX[p2] - z2.curX[p1]); + let y = (original ? z1.orgY[p2] - z2.orgY[p1] : z1.curY[p2] - z2.curY[p1]); + if (x === 0 && y === 0) { x = 1; y = 0; perpendicular = false; } + if (perpendicular) [x, y] = [-y, x]; + const length = Math.hypot(x, y); + return { x: x / length, y: y / length }; + } + + /** + * Projected original distance from point b (zone zb) to point a (zone za): measured on the unscaled outline + * (scaled afterwards) for glyph points, so rounding the scaled positions can't tip later rounding decisions + */ + private originalDistance(za: number, a: number, zb: number, b: number, twilight = za === 0 || zb === 0, round = true): number { + const A = this.zones[za], B = this.zones[zb]; + if (twilight) return this.dualProject(A.orgX[a] - B.orgX[b], A.orgY[a] - B.orgY[b]); + const d = this.dualProject(A.orusX[a] - B.orusX[b], A.orusY[a] - B.orusY[b]); + return round ? mulFix(d, this.size.scaleFixed) : d * this.size.scale; + } + + private loopCount(): number { + const n = this.gs.loop; + this.gs.loop = 1; + return n; + } + + // skips over the instructions of a block (IF/ELSE, FDEF/IDEF), returning the position of the matching end + private skip(code: Uint8Array, ip: number, end: number, stopAtElse: boolean): number { + let depth = 0; + while (ip < end) { + const op = code[ip]; + if (op === 0x58) depth++; + else if (op === 0x59) { + if (depth === 0) return ip; + depth--; + } else if (op === 0x1b && depth === 0 && stopAtElse) return ip; + ip += instructionLength(code, ip); + } + throw new HintError('unterminated block'); + } + + private execute(code: Uint8Array, start: number, end: number): void { + if (++this.callDepth > 64) throw new HintError('calls nested too deep'); + const gs = this.gs, size = this.size; + let ip = start; + + while (ip < end) { + if (++this.steps > MAX_INSTRUCTIONS) throw new HintError('too many instructions'); + const op = code[ip]; + const length = instructionLength(code, ip); + if (ip + length > end) throw new HintError('truncated instruction'); + let next = ip + length; + + // user defined instructions take precedence + const idef = size.instructions.get(op); + if (idef) { + this.execute(idef.code, idef.start, idef.end); + ip = next; + continue; + } + + try { + switch (op) { + // ---- vectors + case 0x00: case 0x01: { // SVTCA + const v = op & 1 ? { x: 1, y: 0 } : { x: 0, y: 1 }; + gs.pv = v; gs.fv = { ...v }; gs.dv = { ...v }; + break; + } + case 0x02: case 0x03: // SPVTCA + gs.pv = op & 1 ? { x: 1, y: 0 } : { x: 0, y: 1 }; + gs.dv = { ...gs.pv }; + break; + case 0x04: case 0x05: // SFVTCA + gs.fv = op & 1 ? { x: 1, y: 0 } : { x: 0, y: 1 }; + break; + case 0x06: case 0x07: { // SPVTL + const p2 = this.pop(), p1 = this.pop(); + gs.pv = this.lineVector(p1, p2, (op & 1) === 1, false); + gs.dv = { ...gs.pv }; + break; + } + case 0x08: case 0x09: { // SFVTL + const p2 = this.pop(), p1 = this.pop(); + gs.fv = this.lineVector(p1, p2, (op & 1) === 1, false); + break; + } + case 0x86: case 0x87: { // SDPVTL + const p2 = this.pop(), p1 = this.pop(); + gs.pv = this.lineVector(p1, p2, (op & 1) === 1, false); + gs.dv = this.lineVector(p1, p2, (op & 1) === 1, true); + break; + } + case 0x0a: case 0x0b: { // SPVFS, SFVFS + const y = this.pop() << 16 >> 16, x = this.pop() << 16 >> 16; + if (op === 0x0a) { this.setVector('pv', x, y); gs.dv = { ...gs.pv }; } + else this.setVector('fv', x, y); + break; + } + case 0x0c: case 0x0d: { // GPV, GFV + const v = op === 0x0c ? gs.pv : gs.fv; + this.push(Math.round(v.x * 16384)); + this.push(Math.round(v.y * 16384)); + break; + } + case 0x0e: // SFVTPV + gs.fv = { ...gs.pv }; + break; + case 0x0f: { // ISECT + const b1 = this.pop(), b0 = this.pop(), a1 = this.pop(), a0 = this.pop(), p = this.pop(); + const za = this.zone(gs.zp1), zb = this.zone(gs.zp0), z = this.zone(gs.zp2); + [a0, a1].forEach(i => this.point(za, i)); + [b0, b1].forEach(i => this.point(zb, i)); + this.point(z, p); + const ax0 = za.curX[a0], ay0 = za.curY[a0], ax1 = za.curX[a1], ay1 = za.curY[a1]; + const bx0 = zb.curX[b0], by0 = zb.curY[b0], bx1 = zb.curX[b1], by1 = zb.curY[b1]; + const dax = ax1 - ax0, day = ay1 - ay0, dbx = bx1 - bx0, dby = by1 - by0; + const det = dax * dby - day * dbx; + if (Math.abs(det) < 1e-9) { + z.curX[p] = (ax0 + ax1 + bx0 + bx1) / 4; + z.curY[p] = (ay0 + ay1 + by0 + by1) / 4; + } else { + const t = ((bx0 - ax0) * dby - (by0 - ay0) * dbx) / det; + z.curX[p] = ax0 + t * dax; + z.curY[p] = ay0 + t * day; + } + z.touchX[p] = z.touchY[p] = 1; + break; + } + + // ---- reference points, zones, loop + case 0x10: gs.rp0 = this.pop(); break; + case 0x11: gs.rp1 = this.pop(); break; + case 0x12: gs.rp2 = this.pop(); break; + case 0x13: gs.zp0 = this.zoneNumber(); break; + case 0x14: gs.zp1 = this.zoneNumber(); break; + case 0x15: gs.zp2 = this.zoneNumber(); break; + case 0x16: gs.zp0 = gs.zp1 = gs.zp2 = this.zoneNumber(); break; + case 0x17: { + const n = this.pop(); + if (n < 0) throw new HintError('invalid loop'); + gs.loop = Math.min(n, 65535) || 1; + break; + } + + // ---- rounding and other state + case 0x18: gs.round = ROUND_GRID; break; + case 0x19: gs.round = { period: 64, phase: 32, threshold: 32 }; break; + case 0x3d: gs.round = { period: 32, phase: 0, threshold: 16 }; break; + case 0x7d: gs.round = { period: 64, phase: 0, threshold: 0 }; break; + case 0x7c: gs.round = { period: 64, phase: 0, threshold: 63 }; break; + case 0x7a: gs.round = null; break; + case 0x76: case 0x77: { // SROUND, S45ROUND + const n = this.pop(); + const grid = op === 0x76 ? 64 : 64 / Math.SQRT2; + const period = [grid / 2, grid, grid * 2, grid][n >> 6 & 3]; + const phase = [0, period / 4, period / 2, period * 3 / 4][n >> 4 & 3]; + const t = n & 15; + gs.round = { period, phase, threshold: t === 0 ? period - 1 : (t - 4) * period / 8 }; + break; + } + case 0x1a: gs.minimumDistance = this.pop(); break; + case 0x1d: gs.cvtCutIn = this.pop(); break; + case 0x1e: gs.singleWidthCutIn = this.pop(); break; + case 0x1f: gs.singleWidth = mulFix(this.pop(), size.scaleFixed); break; + case 0x4d: gs.autoFlip = true; break; + case 0x4e: gs.autoFlip = false; break; + case 0x5e: gs.deltaBase = this.pop(); break; + case 0x5f: gs.deltaShift = this.pop(); break; + case 0x85: case 0x8d: case 0x7e: case 0x7f: case 0x4f: // SCANCTRL, SCANTYPE, SANGW, AA, DEBUG + this.pop(); + break; + case 0x8e: { // INSTCTRL (only meaningful in the control value program) + const selector = this.pop(), value = this.pop(); + if (!this.isGlyph && selector >= 1 && selector <= 3) { + const bit = 1 << selector - 1; + gs.instructControl = value ? gs.instructControl | bit : gs.instructControl & ~bit; + } + break; + } + + // ---- stack + case 0x20: { const v = this.pop(); this.push(v); this.push(v); break; } + case 0x21: this.pop(); break; + case 0x22: this.stack.length = 0; break; + case 0x23: { const b = this.pop(), a = this.pop(); this.push(b); this.push(a); break; } + case 0x24: this.push(this.stack.length); break; + case 0x25: { // CINDEX + const k = this.pop(); + if (k < 1 || k > this.stack.length) throw new HintError('invalid index'); + this.push(this.stack[this.stack.length - k]); + break; + } + case 0x26: { // MINDEX + const k = this.pop(); + if (k < 1 || k > this.stack.length) throw new HintError('invalid index'); + this.push(this.stack.splice(this.stack.length - k, 1)[0]); + break; + } + case 0x8a: { // ROLL + const a = this.pop(), b = this.pop(), c = this.pop(); + this.push(b); this.push(a); this.push(c); + break; + } + case 0x40: case 0x41: case 0xb0: case 0xb1: case 0xb2: case 0xb3: case 0xb4: case 0xb5: case 0xb6: case 0xb7: + case 0xb8: case 0xb9: case 0xba: case 0xbb: case 0xbc: case 0xbd: case 0xbe: case 0xbf: { + const words = op === 0x41 || op >= 0xb8; + const count = op === 0x40 || op === 0x41 ? code[ip + 1] : (op & 7) + 1; + let p = ip + (op === 0x40 || op === 0x41 ? 2 : 1); + for (let i = 0; i < count; i++) { + if (words) { this.push(code[p] << 24 >> 16 | code[p + 1]); p += 2; } + else this.push(code[p++]); + } + break; + } + + // ---- storage and control values + case 0x42: { const v = this.pop(), l = this.pop(); this.storageIndex(l); size.storage[l] = v; break; } + case 0x43: { const l = this.pop(); this.push(l >= 0 && l < size.storage.length ? size.storage[l] : 0); break; } + case 0x44: { const v = this.pop(), l = this.pop(); this.cvtIndex(l); size.cvt[l] = v; break; } + case 0x70: { const v = this.pop(), l = this.pop(); this.cvtIndex(l); size.cvt[l] = mulFix(v, size.scaleFixed); break; } + case 0x45: { const l = this.pop(); this.push(l >= 0 && l < size.cvt.length ? Math.round(size.cvt[l]) : 0); break; } + + // ---- measurements + case 0x46: case 0x47: { // GC + const p = this.pop(), z = this.zone(gs.zp2); + this.point(z, p); + this.push(Math.round(op === 0x46 ? this.project(z.curX[p], z.curY[p]) : this.dualProject(z.orgX[p], z.orgY[p]))); + break; + } + case 0x48: { // SCFS + const value = this.pop(), p = this.pop(), z = this.zone(gs.zp2); + this.point(z, p); + this.move(z, p, value - this.project(z.curX[p], z.curY[p])); + if (gs.zp2 === 0) { z.orgX[p] = z.curX[p]; z.orgY[p] = z.curY[p]; } + break; + } + case 0x49: case 0x4a: { // MD + const k = this.pop(), l = this.pop(); + const z0 = this.zone(gs.zp0), z1 = this.zone(gs.zp1); + this.point(z0, l); + this.point(z1, k); + const d = op & 1 ? this.project(z0.curX[l] - z1.curX[k], z0.curY[l] - z1.curY[k]) + : this.originalDistance(gs.zp0, l, gs.zp1, k); + this.push(Math.round(d)); + break; + } + case 0x4b: case 0x4c: this.push(size.ppem); break; // MPPEM, MPS + + // ---- arithmetic and logic + case 0x50: { const b = this.pop(), a = this.pop(); this.push(a < b ? 1 : 0); break; } + case 0x51: { const b = this.pop(), a = this.pop(); this.push(a <= b ? 1 : 0); break; } + case 0x52: { const b = this.pop(), a = this.pop(); this.push(a > b ? 1 : 0); break; } + case 0x53: { const b = this.pop(), a = this.pop(); this.push(a >= b ? 1 : 0); break; } + case 0x54: { const b = this.pop(), a = this.pop(); this.push(a === b ? 1 : 0); break; } + case 0x55: { const b = this.pop(), a = this.pop(); this.push(a !== b ? 1 : 0); break; } + case 0x56: case 0x57: { // ODD, EVEN (after rounding) + const v = this.round(this.pop()) >> 6 & 1; + this.push(op === 0x56 ? v : 1 - v); + break; + } + case 0x5a: { const b = this.pop(), a = this.pop(); this.push(a && b ? 1 : 0); break; } + case 0x5b: { const b = this.pop(), a = this.pop(); this.push(a || b ? 1 : 0); break; } + case 0x5c: this.push(this.pop() ? 0 : 1); break; + // (values are 64 bit wide in the rasterizers, intermediate results like 16.16 factors exceed 32 bits) + case 0x60: { const b = this.pop(), a = this.pop(); this.push(a + b); break; } + case 0x61: { const b = this.pop(), a = this.pop(); this.push(a - b); break; } + case 0x62: { // DIV + const b = this.pop(), a = this.pop(); + if (b === 0) throw new HintError('division by zero'); + this.push(Math.trunc(a * 64 / b)); + break; + } + case 0x63: { // MUL, rounded + const b = this.pop(), a = this.pop(); + const product = a * b; + this.push(product < 0 ? -Math.floor((-product + 32) / 64) : Math.floor((product + 32) / 64)); + break; + } + case 0x64: this.push(Math.abs(this.pop())); break; + case 0x65: this.push(-this.pop()); break; + case 0x66: this.push(Math.floor(this.pop() / 64) * 64); break; + case 0x67: this.push(Math.ceil(this.pop() / 64) * 64); break; + case 0x8b: { const b = this.pop(), a = this.pop(); this.push(Math.max(a, b)); break; } + case 0x8c: { const b = this.pop(), a = this.pop(); this.push(Math.min(a, b)); break; } + case 0x68: case 0x69: case 0x6a: case 0x6b: this.push(Math.round(this.round(this.pop()))); break; // ROUND + case 0x6c: case 0x6d: case 0x6e: case 0x6f: break; // NROUND (no engine compensation) + + // ---- flow control + case 0x58: { // IF + if (!this.pop()) { + const target = this.skip(code, next, end, true); + next = target + 1; + } + break; + } + case 0x1b: // ELSE (reached while executing the IF branch) + next = this.skip(code, next, end, false) + 1; + break; + case 0x59: break; // EIF + case 0x1c: next = ip + this.pop(); break; // JMPR + case 0x78: { const e = this.pop(), offset = this.pop(); if (e) next = ip + offset; break; } // JROT + case 0x79: { const e = this.pop(), offset = this.pop(); if (!e) next = ip + offset; break; } // JROF + case 0x2c: case 0x89: { // FDEF, IDEF + const n = this.pop(); + let p = next; + while (p < end && code[p] !== 0x2d) { + if (code[p] === 0x2c || code[p] === 0x89) throw new HintError('nested definition'); + p += instructionLength(code, p); + } + if (p >= end) throw new HintError('unterminated definition'); + (op === 0x2c ? size.functions : size.instructions).set(n, { code, start: next, end: p }); + next = p + 1; + break; + } + case 0x2d: return void this.callDepth--; // ENDF: return + case 0x2b: case 0x2a: { // CALL, LOOPCALL + const f = this.pop(); + const count = op === 0x2a ? this.pop() : 1; + const def = size.functions.get(f); + if (!def) throw new HintError('undefined function'); + for (let i = 0; i < count; i++) this.execute(def.code, def.start, def.end); + break; + } + + // ---- information + case 0x88: { // GETINFO: rasterizer version 35 (like FreeType's classic interpreter), grayscale + const selector = this.pop(); + let result = 0; + if (selector & 1) result |= 35; + if (selector & 32) result |= 1 << 12; + this.push(result); + break; + } + + // ---- moving points + case 0x2e: case 0x2f: { // MDAP + const p = this.pop(), z = this.zone(gs.zp0); + this.point(z, p); + const d = this.project(z.curX[p], z.curY[p]); + this.move(z, p, op & 1 ? this.round(d) - d : 0); + gs.rp0 = gs.rp1 = p; + break; + } + case 0x3e: case 0x3f: { // MIAP + const n = this.pop(), p = this.pop(), z = this.zone(gs.zp0); + this.point(z, p); + this.cvtIndex(n); + let distance = size.cvt[n]; + if (gs.zp0 === 0) { + z.orgX[p] = z.curX[p] = distance * gs.fv.x; + z.orgY[p] = z.curY[p] = distance * gs.fv.y; + } + const current = this.project(z.curX[p], z.curY[p]); + if (op & 1) { + if (Math.abs(distance - current) > gs.cvtCutIn) distance = current; + distance = this.round(distance); + } + this.move(z, p, distance - current); + gs.rp0 = gs.rp1 = p; + break; + } + case 0x3a: case 0x3b: { // MSIRP + const d = this.pop(), p = this.pop(); + const z0 = this.zone(gs.zp0), z1 = this.zone(gs.zp1); + this.point(z0, gs.rp0); + this.point(z1, p); + if (gs.zp1 === 0) { + z1.orgX[p] = z0.orgX[gs.rp0]; z1.orgY[p] = z0.orgY[gs.rp0]; + z1.curX[p] = z1.orgX[p]; z1.curY[p] = z1.orgY[p]; + } + const distance = this.project(z1.curX[p] - z0.curX[gs.rp0], z1.curY[p] - z0.curY[gs.rp0]); + this.move(z1, p, d - distance); + gs.rp1 = gs.rp0; + gs.rp2 = p; + if (op & 1) gs.rp0 = p; + break; + } + case 0x32: case 0x33: case 0x34: case 0x35: case 0x36: case 0x37: { // SHP, SHC, SHZ + // displacement of the reference point (rp2 in zp1, or rp1 in zp0 for [1]) + const refZoneNumber = op & 1 ? gs.zp0 : gs.zp1; + const refZone = this.zone(refZoneNumber), ref = op & 1 ? gs.rp1 : gs.rp2; + this.point(refZone, ref); + const d = this.project(refZone.curX[ref] - refZone.orgX[ref], refZone.curY[ref] - refZone.orgY[ref]); + const mv = this.moveVector(); + const dx = d * mv.x, dy = d * mv.y; + const shift = (z: Zone, p: number, touch: boolean): void => { + z.curX[p] += dx; z.curY[p] += dy; + if (touch) { if (gs.fv.x !== 0) z.touchX[p] = 1; if (gs.fv.y !== 0) z.touchY[p] = 1; } + }; + + if (op <= 0x33) { // SHP + const z = this.zone(gs.zp2); + for (let n = this.loopCount(); n > 0; n--) shift(z, this.point(z, this.pop()), true); + } else if (op <= 0x35) { // SHC + const c = this.pop(), z = this.zone(gs.zp2); + if (c < 0 || c >= z.ends.length) throw new HintError('invalid contour'); + const first = c === 0 ? 0 : z.ends[c - 1] + 1; + for (let p = first; p <= z.ends[c]; p++) + if (z !== refZone || p !== ref) shift(z, p, true); + } else { // SHZ + const z = this.zone(this.pop()); + const limit = z === this.zones[1] ? z.curX.length - 4 : z.curX.length; + for (let p = 0; p < limit; p++) if (z !== refZone || p !== ref) shift(z, p, false); + } + break; + } + case 0x38: { // SHPIX + const d = this.pop(), z = this.zone(gs.zp2); + for (let n = this.loopCount(); n > 0; n--) { + const p = this.point(z, this.pop()); + z.curX[p] += d * gs.fv.x; + z.curY[p] += d * gs.fv.y; + if (gs.fv.x !== 0) z.touchX[p] = 1; + if (gs.fv.y !== 0) z.touchY[p] = 1; + } + break; + } + case 0x39: { // IP + const z0 = this.zone(gs.zp0), z1 = this.zone(gs.zp1), z2 = this.zone(gs.zp2); + this.point(z0, gs.rp1); + this.point(z1, gs.rp2); + const curBaseX = z0.curX[gs.rp1], curBaseY = z0.curY[gs.rp1]; + // the unscaled outline is used unless any zone pointer refers to the twilight zone + const twilight = gs.zp0 === 0 || gs.zp1 === 0 || gs.zp2 === 0; + const oldRange = this.originalDistance(gs.zp1, gs.rp2, gs.zp0, gs.rp1, twilight, false); + const curRange = this.project(z1.curX[gs.rp2] - curBaseX, z1.curY[gs.rp2] - curBaseY); + for (let n = this.loopCount(); n > 0; n--) { + const p = this.point(z2, this.pop()); + const orgDistance = this.originalDistance(gs.zp2, p, gs.zp0, gs.rp1, twilight, false); + const curDistance = this.project(z2.curX[p] - curBaseX, z2.curY[p] - curBaseY); + // (with an empty original range the point keeps its original distance, like Microsoft's rasterizer) + const newDistance = orgDistance === 0 ? 0 : oldRange !== 0 ? roundHalfAway(orgDistance * curRange / oldRange) : roundHalfAway(orgDistance); + this.move(z2, p, newDistance - curDistance); + } + break; + } + case 0x3c: { // ALIGNRP + const z0 = this.zone(gs.zp0), z1 = this.zone(gs.zp1); + this.point(z0, gs.rp0); + for (let n = this.loopCount(); n > 0; n--) { + const p = this.point(z1, this.pop()); + this.move(z1, p, -this.project(z1.curX[p] - z0.curX[gs.rp0], z1.curY[p] - z0.curY[gs.rp0])); + } + break; + } + case 0x27: { // ALIGNPTS + const p2 = this.pop(), p1 = this.pop(); + const z0 = this.zone(gs.zp0), z1 = this.zone(gs.zp1); + this.point(z0, p2); + this.point(z1, p1); + const d = this.project(z0.curX[p2] - z1.curX[p1], z0.curY[p2] - z1.curY[p1]) / 2; + this.move(z1, p1, d); + this.move(z0, p2, -d); + break; + } + case 0x29: { // UTP + const p = this.pop(), z = this.zone(gs.zp0); + this.point(z, p); + if (gs.fv.x !== 0) z.touchX[p] = 0; + if (gs.fv.y !== 0) z.touchY[p] = 0; + break; + } + case 0x30: case 0x31: this.interpolateUntouched(op === 0x31); break; // IUP + case 0x80: { // FLIPPT + const z = this.zone(gs.zp0); + for (let n = this.loopCount(); n > 0; n--) { const p = this.point(z, this.pop()); z.on[p] ^= 1; } + break; + } + case 0x81: case 0x82: { // FLIPRGON, FLIPRGOFF + const high = this.pop(), low = this.pop(), z = this.zone(gs.zp0); + this.point(z, high); + this.point(z, low); + for (let p = low; p <= high; p++) z.on[p] = op === 0x81 ? 1 : 0; + break; + } + case 0x5d: case 0x71: case 0x72: // DELTAP1-3 + case 0x73: case 0x74: case 0x75: { // DELTAC1-3 + const n = this.pop(); + const range = op === 0x5d || op === 0x73 ? 0 : op === 0x71 || op === 0x74 ? 16 : 32; + for (let k = 0; k < n; k++) { + const target = this.pop(), arg = this.pop(); + if ((arg >> 4 & 15) + gs.deltaBase + range !== size.ppem) continue; + let steps = (arg & 15) - 8; + if (steps >= 0) steps++; + const amount = steps * 64 / (1 << gs.deltaShift); + if (op <= 0x72) { + const z = this.zone(gs.zp0); + this.move(z, this.point(z, target), amount); + } else { + this.cvtIndex(target); + size.cvt[target] += amount; + } + } + break; + } + + default: + if (op >= 0xc0) { this.relativePoint(op); break; } + throw new HintError(`unknown instruction 0x${op.toString(16)}`); + } + } catch (err) { + if (!(err instanceof SkipInstruction)) throw err; + } + + ip = next; + } + + this.callDepth--; + } + + private zoneNumber(): number { + const n = this.pop(); + if (n !== 0 && n !== 1) throw new SkipInstruction(); + return n; + } + + private storageIndex(l: number): void { + if (!(l >= 0 && l < this.size.storage.length)) throw new SkipInstruction(); + } + + private cvtIndex(l: number): void { + if (!(l >= 0 && l < this.size.cvt.length)) throw new SkipInstruction(); + } + + // MDRP (0xc0-0xdf) and MIRP (0xe0-0xff): moves a point relative to rp0 + private relativePoint(op: number): void { + const gs = this.gs, size = this.size; + const indirect = op >= 0xe0; + const setRp0 = (op & 16) !== 0, keepMinimum = (op & 8) !== 0, round = (op & 4) !== 0; + + const n = indirect ? this.pop() : -1; + const p = this.pop(); + const z0 = this.zone(gs.zp0), z1 = this.zone(gs.zp1); + if (!(p >= 0 && p < z1.curX.length && gs.rp0 >= 0 && gs.rp0 < z0.curX.length && (!indirect || n >= -1 && n < size.cvt.length))) { + // invalid references skip the move, the reference points update anyway + gs.rp1 = gs.rp0; + gs.rp2 = p; + if (setRp0) gs.rp0 = p; + return; + } + + // MIRP measures the scaled original positions, MDRP the unscaled outline + const orgDistance = indirect ? this.dualProject(z1.orgX[p] - z0.orgX[gs.rp0], z1.orgY[p] - z0.orgY[gs.rp0]) + : this.originalDistance(gs.zp1, p, gs.zp0, gs.rp0); + const curDistance = this.project(z1.curX[p] - z0.curX[gs.rp0], z1.curY[p] - z0.curY[gs.rp0]); + let distance: number; + + if (indirect) { + // (index -1 means 0) + let cvt = n < 0 ? 0 : size.cvt[n]; + // the single width wins over the control value if it is close + if (Math.abs(cvt - gs.singleWidth) < gs.singleWidthCutIn) cvt = cvt >= 0 ? gs.singleWidth : -gs.singleWidth; + + if (gs.zp1 === 0) { + z1.orgX[p] = z0.orgX[gs.rp0] + cvt * gs.fv.x; + z1.orgY[p] = z0.orgY[gs.rp0] + cvt * gs.fv.y; + z1.curX[p] = z1.orgX[p]; + z1.curY[p] = z1.orgY[p]; + } + if (gs.autoFlip && (orgDistance ^ cvt) < 0 && orgDistance !== 0 && cvt !== 0) cvt = -cvt; + + if (round) { + if (gs.zp0 === gs.zp1 && Math.abs(cvt - orgDistance) > gs.cvtCutIn) cvt = orgDistance; + distance = this.round(cvt); + } else distance = cvt; + } else { + let d = orgDistance; + if (Math.abs(d - gs.singleWidth) < gs.singleWidthCutIn) d = d >= 0 ? gs.singleWidth : -gs.singleWidth; + distance = round ? this.round(d) : d; + } + + if (keepMinimum) { + if (orgDistance >= 0) { if (distance < gs.minimumDistance) distance = gs.minimumDistance; } + else if (distance > -gs.minimumDistance) distance = -gs.minimumDistance; + } + + const current = indirect && gs.zp1 === 0 ? this.project(z1.curX[p] - z0.curX[gs.rp0], z1.curY[p] - z0.curY[gs.rp0]) : curDistance; + this.move(z1, p, distance - current); + + gs.rp1 = gs.rp0; + gs.rp2 = p; + if (setRp0) gs.rp0 = p; + } + + // IUP: points untouched along the axis are interpolated between the touched ones of their contour + private interpolateUntouched(x: boolean): void { + const z = this.zones[1]; + const cur = x ? z.curX : z.curY, org = x ? z.orgX : z.orgY, orus = x ? z.orusX : z.orusY, touched = x ? z.touchX : z.touchY; + let first = 0; + + // points from..to (inclusive, not wrapping) between the touched points a and b + const interpolate = (from: number, to: number, a: number, b: number): void => { + if (from > to) return; + if (orus[a] > orus[b]) [a, b] = [b, a]; + const org1 = org[a], org2 = org[b], cur1 = cur[a], cur2 = cur[b]; + const delta1 = cur1 - org1, delta2 = cur2 - org2; + const flat = cur1 === cur2 || orus[a] === orus[b]; + const factor = flat ? 0 : divFix(cur2 - cur1, orus[b] - orus[a]); + for (let p = from; p <= to; p++) { + // outside the touched points' (scaled) range a point moves like the nearer one, inside it is + // interpolated on the unscaled outline + const o = org[p]; + if (o <= org1) cur[p] = o + delta1; + else if (o >= org2) cur[p] = o + delta2; + else cur[p] = flat ? cur1 : cur1 + mulFix(orus[p] - orus[a], factor); + } + }; + + for (const last of z.ends) { + let p = first; + while (p <= last && !touched[p]) p++; + if (p > last) { first = last + 1; continue; } + + const firstTouched = p; + let current = p; + for (p++; p <= last; p++) { + if (!touched[p]) continue; + interpolate(current + 1, p - 1, current, p); + current = p; + } + + if (current === firstTouched) { + // a single touched point shifts the whole contour + const d = cur[current] - org[current]; + if (d !== 0) for (let i = first; i <= last; i++) if (i !== current) cur[i] += d; + } else { + // the stretch from the last touched point around to the first + interpolate(current + 1, last, current, firstTouched); + if (firstTouched > first) interpolate(first, firstTouched - 1, current, firstTouched); + } + + first = last + 1; + } + } +} + +// length of the instruction at ip in bytes (push instructions carry data) +function instructionLength(code: Uint8Array, ip: number): number { + const op = code[ip]; + if (op === 0x40) return 2 + code[ip + 1]; + if (op === 0x41) return 2 + 2 * code[ip + 1]; + if (op >= 0xb0 && op <= 0xb7) return 2 + (op & 7); + if (op >= 0xb8 && op <= 0xbf) return 1 + 2 * ((op & 7) + 1); + return 1; +} diff --git a/src/render/text.ts b/src/render/text.ts index fe168c1c..dd5de462 100644 --- a/src/render/text.ts +++ b/src/render/text.ts @@ -4,6 +4,7 @@ */ import { Font, PathCommand } from './font.ts'; +import { Hinter } from './hinting.ts'; export interface Layout { maxWidth: number; @@ -26,6 +27,9 @@ export interface GlyphBitmap { interface Glyph { index: number; + /** outline and its scale to pixels (1 for hinted outlines, which are in pixels) */ + path: PathCommand[]; + scale: number; /** advance width in pixels */ advance: number; /** horizontal ink extent relative to the pen position in pixels, null for glyphs without outline */ @@ -45,6 +49,7 @@ const SUBPIXELS = 4; // parsed fonts are cached per memory region, so new views of the same font data (e.g. from node Buffers) hit the cache const fonts = new WeakMap>(); +const hinters = new WeakMap(); function loadFont(data: Uint8Array): Font { let regions = fonts.get(data.buffer); @@ -65,11 +70,21 @@ function loadFont(data: Uint8Array): Font { * @param {number} size font size in pixels per em * @param {string} text * @param {Layout} layout + * @param hinting Whether to run TrueType fonts' hinting instructions, the glyphs of hinted fonts are then placed on + * whole pixels (fonts without instructions render as without hinting) * @return {{width: number, height: number, coverage: Uint8Array}} */ -export function render(data: Uint8Array, size: number, text: string, layout: Layout): { width: number, height: number, coverage: Uint8Array } { +export function render(data: Uint8Array, size: number, text: string, layout: Layout, hinting = false): { width: number, height: number, coverage: Uint8Array } { const font = loadFont(data); const scale = size / font.unitsPerEm; + let hinter: Hinter | null = null; + if (hinting && font.outlines === 'glyf') { + hinter = hinters.get(font) ?? null; + if (!hinter) hinters.set(font, hinter = new Hinter(font)); + if (!hinter.hasInstructions) hinter = null; + } + // whole pixel placement only makes sense for hinted outlines + hinting = hinter !== null; const ascent = Math.ceil(font.ascender * scale); const lineHeight = Math.ceil((font.ascender - font.descender + font.lineGap) * scale); @@ -78,15 +93,23 @@ export function render(data: Uint8Array, size: number, text: string, layout: Lay let g = glyphs.get(codepoint); if (!g) { const index = font.glyphIndex(codepoint); - const path = font.outline(index); - g = { index, advance: font.advance(index) * scale, ink: inkExtent(path, scale), bitmaps: [] }; + const hinted = hinter && hinter.glyph(index, size); + if (hinted) g = { index, path: hinted.path, scale: 1, advance: hinted.advance, ink: inkExtent(hinted.path, 1), bitmaps: [] }; + else { + const path = font.outline(index); + const advance = font.advance(index) * scale; + g = { index, path, scale, advance: hinting ? Math.round(advance) : advance, ink: inkExtent(path, scale), bitmaps: [] }; + } glyphs.set(codepoint, g); } return g; }; const kerning = font.kerning; - const kern = kerning ? (left: Glyph, right: Glyph): number => kerning(left.index, right.index) * scale : () => 0; + const kern = kerning ? (left: Glyph, right: Glyph): number => { + const k = kerning(left.index, right.index) * scale; + return hinting ? Math.round(k) : k; + } : () => 0; const lines = wrap(text, layout, glyph, kern); // lines that fit into maxHeight, chosen by the vertical alignment @@ -105,8 +128,9 @@ export function render(data: Uint8Array, size: number, text: string, layout: Lay const coverage = new Uint8Array(width * height); visible.forEach((line, n) => { - const offset = layout.horizontalAlign === 'right' ? width - line.extent + let offset = layout.horizontalAlign === 'right' ? width - line.extent : layout.horizontalAlign === 'center' ? (width - line.extent) / 2 : 0; + if (hinting) offset = Math.round(offset); const baseline = ascent + n * lineHeight; for (const { x, glyph: g } of line.glyphs) { @@ -117,7 +141,7 @@ export function render(data: Uint8Array, size: number, text: string, layout: Lay const pixel = Math.floor(position); const subpixel = Math.round((position - pixel) * SUBPIXELS); - if (g.bitmaps[subpixel] === undefined) g.bitmaps[subpixel] = rasterizeGlyph(font.outline(g.index), scale, subpixel / SUBPIXELS); + if (g.bitmaps[subpixel] === undefined) g.bitmaps[subpixel] = rasterizeGlyph(g.path, g.scale, subpixel / SUBPIXELS); const bitmap = g.bitmaps[subpixel]; if (bitmap) blit(coverage, width, height, bitmap, pixel + bitmap.left, baseline - bitmap.top); } @@ -307,6 +331,10 @@ export class Rasterizer { line(x0: number, y0: number, x1: number, y1: number): void { if (y0 === y1) return; + // outlines lie within the bitmap, but subdividing curves can land a hair outside (e.g. -1e-17 for points on the + // left edge, common with hinting); such segments would be dropped (their row index is negative) + x0 = x0 < 0 ? 0 : x0 > this.width ? this.width : x0; + x1 = x1 < 0 ? 0 : x1 > this.width ? this.width : x1; let dir = 1; if (y0 > y1) { @@ -324,7 +352,8 @@ export class Rasterizer { for (let y = Math.max(0, y0 | 0); y < yEnd; y++) { const lineStart = y * w; const dy = Math.min(y + 1, y1) - Math.max(y, y0); - const xNext = x + dxdy * dy; + let xNext = x + dxdy * dy; + xNext = xNext < 0 ? 0 : xNext > w ? w : xNext; const d = dy * dir; const xa = x < xNext ? x : xNext, xb = x < xNext ? xNext : x; diff --git a/tests/font.js b/tests/font.js index b000260f..99882bab 100644 --- a/tests/font.js +++ b/tests/font.js @@ -62,7 +62,8 @@ const panic = message => { { const font = await fs.readFile('./tests/fonts/opensans bold.ttf'); - const render = (text, options) => Image.renderText(font, 40, text, 0xffffffff, new TextLayout(options)); + // (unhinted: the layout places glyphs with subpixel precision) + const render = (text, options) => Image.renderText(font, 40, text, 0xffffffff, new TextLayout(options), {hinting: false}); // first column with ink in the given row const inkStart = (image, y) => { for (let x = 0; x < image.width; x++) @@ -83,7 +84,7 @@ const panic = message => { panic('layout: horizontal alignment has no effect'); // kerning pulls 'To' together (without it, the pair is wider than both glyphs' ink) - const big = text => Image.renderText(font, 200, text); + const big = text => Image.renderText(font, 200, text, 0xffffffff, undefined, {hinting: false}); if ((await big('To')).width > (await big('T')).width + (await big('o')).width - 5) panic('layout: kerning has no effect'); // wrapping @@ -98,4 +99,56 @@ const panic = message => { if (top.height !== 110 || bottom.height !== 110) panic('layout: maxHeight failed'); if (Buffer.from(top.bitmap).equals(Buffer.from(bottom.bitmap))) panic('layout: vertical alignment has no effect'); } + // TrueType hinting (#31): hinted outlines match FreeType's (v35 interpreter) point for point + { + const {Font} = await import('../dist/render/font.js'); + const {Hinter} = await import('../dist/render/hinting.js'); + // 26.6 coordinates and advances from FreeType + const expected = {"opensans bold.ttf":{"11:H":{"advance":512,"points":[[448,0],[342,0],[342,231],[170,231],[170,0],[63,0],[63,512],[170,512],[170,320],[342,320],[342,512],[448,512]]},"11:a":{"advance":448,"points":[[322,0],[302,59],[299,59],[270,26],[209,0],[160,0],[99,0],[30,66],[30,127],[30,190],[124,251],[218,254],[291,256],[291,267],[291,306],[226,306],[175,306],[107,278],[69,349],[139,384],[225,384],[307,384],[395,318],[395,251],[395,0],[291,195],[243,194],[190,192],[137,159],[137,125],[137,77],[197,77],[240,77],[291,123],[291,161]]},"11:g":{"advance":384,"points":[[375,384],[375,331],[316,315],[332,288],[332,255],[332,195],[249,128],[176,128],[159,129],[145,131],[131,119],[131,105],[131,84],[186,84],[248,84],[308,84],[371,24],[371,-35],[371,-110],[263,-192],[161,-192],[84,-192],[2,-128],[2,-70],[2,-31],[45,23],[87,34],[71,41],[46,71],[46,87],[46,108],[71,136],[94,150],[65,163],[31,219],[31,257],[31,317],[109,384],[182,384],[198,384],[240,384],[246,384],[93,-64],[93,-92],[132,-124],[166,-124],[219,-124],[278,-84],[278,-49],[278,-21],[243,0],[206,0],[156,0],[129,0],[93,-35],[131,255],[131,226],[156,192],[182,192],[208,192],[232,226],[232,255],[232,320],[182,320],[131,320]]},"16:e":{"advance":576,"points":[[297,467],[255,467],[207,390],[204,320],[389,320],[388,390],[339,467],[316,0],[189,0],[46,148],[46,284],[46,423],[179,576],[296,576],[407,576],[532,429],[532,299],[532,218],[201,218],[203,169],[269,114],[328,114],[374,114],[456,130],[501,147],[501,37],[463,18],[378,0]]},"16:&":{"advance":768,"points":[[768,0],[580,0],[522,57],[427,0],[306,0],[184,0],[41,109],[41,202],[41,269],[102,363],[175,404],[137,450],[103,535],[103,585],[103,668],[220,768],[317,768],[410,768],[522,675],[522,597],[522,533],[453,427],[376,379],[518,243],[553,298],[579,384],[738,384],[720,318],[657,192],[617,144],[202,217],[202,176],[266,128],[317,128],[380,128],[430,157],[264,313],[235,292],[202,247],[381,583],[381,614],[345,650],[316,650],[283,650],[244,612],[244,578],[244,526],[292,464],[335,492],[381,547]]}},"ethnocentric rg.ttf":{"12:R":{"advance":768,"points":[[777,0],[564,0],[450,128],[206,128],[206,0],[47,0],[47,260],[494,260],[531,260],[581,291],[581,319],[581,349],[535,380],[494,380],[47,380],[151,512],[481,512],[525,512],[618,496],[695,453],[745,382],[745,325],[745,295],[727,240],[692,197],[642,166],[611,160],[624,150],[656,121],[683,95]]},"12:S":{"advance":768,"points":[[525,320],[619,320],[717,248],[717,174],[717,134],[693,68],[639,24],[555,0],[497,0],[18,0],[121,132],[509,132],[538,132],[564,145],[564,160],[564,175],[538,188],[509,188],[230,188],[180,188],[108,211],[60,251],[37,309],[37,344],[37,382],[62,444],[115,488],[199,512],[257,512],[703,512],[601,380],[245,380],[217,380],[190,367],[190,350],[190,334],[217,320],[245,320]]}}}; + + for (const [file, glyphs] of Object.entries(expected)) { + const font = new Font(await fs.readFile(`./tests/fonts/${file}`)); + const hinter = new Hinter(font); + for (const [key, {advance, points}] of Object.entries(glyphs)) { + const [size, char] = key.split(':'); + const hinted = hinter.glyph(font.glyphIndex(char.codePointAt(0)), +size); + if (!hinted) panic(`hinting ${file} ${key}: not hinted`); + if (hinted.advance * 64 !== advance) panic(`hinting ${file} ${key}: advance ${hinted.advance * 64}, expected ${advance}`); + // the path's on and off curve points in contour order: compare them as a set + const ours = new Set(); + for (const command of hinted.path) + for (let i = 1; i < command.length; i += 2) ours.add(`${Math.round(command[i] * 64)},${Math.round(command[i + 1] * 64)}`); + for (const [x, y] of points) + if (!ours.has(`${x},${y}`)) panic(`hinting ${file} ${key}: point ${x},${y} missing`); + } + } + + // rendering: hinted glyphs sit on whole pixels, so repeated letters look identical (without hinting they are + // placed with subpixel precision and differ), and there are fewer blurry pixels + const data = await fs.readFile('./tests/fonts/opensans bold.ttf'); + const blurry = image => { + let count = 0; + for (let i = 3; i < image.bitmap.length; i += 4) if (image.bitmap[i] > 20 && image.bitmap[i] < 235) count++; + return count; + }; + // the alpha of each column, as a string per column + const columns = image => Array.from({length: image.width}, (_, x) => Array.from({length: image.height}, (_, y) => image.getRGBAAt(x, y)[3]).join()); + const repeats = image => { + const cols = columns(image).filter(c => c.split(',').some(v => +v)); + return new Set(cols).size; + }; + const plain = await Image.renderText(data, 11, 'lllll', 0x000000ff, undefined, {hinting: false}); + const hinted = await Image.renderText(data, 11, 'lllll', 0x000000ff); + if (repeats(hinted) >= repeats(plain)) panic(`hinting: repeated glyphs should render identically (${repeats(hinted)} vs ${repeats(plain)} distinct columns)`); + const word = await Image.renderText(data, 11, 'Hamburg', 0x000000ff); + if (blurry(word) >= blurry(await Image.renderText(data, 11, 'Hamburg', 0x000000ff, undefined, {hinting: false}))) panic('hinting: text should be crisper'); + + // fonts without TrueType instructions (CFF, or TrueType without them) render as without hinting + for (const file of ['sourcesans3 regular.otf', 'carbon phyber.ttf']) { + const font = await fs.readFile(`./tests/fonts/${file}`); + const on = await Image.renderText(font, 11, 'Hamburg', 0x000000ff); + const off = await Image.renderText(font, 11, 'Hamburg', 0x000000ff, undefined, {hinting: false}); + if (!Buffer.from(on.bitmap).equals(Buffer.from(off.bitmap))) panic(`hinting: ${file} has no instructions and should render unchanged`); + } + } })(); diff --git a/tests/targets/effects.png b/tests/targets/effects.png index 5e6cd7ea..134c175a 100644 Binary files a/tests/targets/effects.png and b/tests/targets/effects.png differ diff --git a/tests/targets/font-2.png b/tests/targets/font-2.png index 48664e9d..72a5ebd3 100644 Binary files a/tests/targets/font-2.png and b/tests/targets/font-2.png differ diff --git a/tests/targets/font-3.png b/tests/targets/font-3.png index c8b44504..448c987e 100644 Binary files a/tests/targets/font-3.png and b/tests/targets/font-3.png differ