Skip to content
Open
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
2 changes: 1 addition & 1 deletion MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Instead of using factory functions, we now export classes with constructors.

## Automatic memory cleanup

Instead of manually calling `.destroy()`, WASM objects are now reclaimed automatically using `FinalizationRegistry`. There are no longer any `.destroy()` methods.
Instead of manually calling `.destroy()`, WASM objects are now reclaimed automatically using `FinalizationRegistry`. There are no longer any `.destroy()` methods. Objects can be freed early with `[Symbol.dispose]()` or `using`. Runtimes without `Symbol.dispose` need `Symbol.dispose ??= Symbol("Symbol.dispose");` run before importing harfbuzzjs.

## String/number arguments replaced with enums

Expand Down
14 changes: 12 additions & 2 deletions src/blob.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Module, exports, freeFuncPtr, track } from "./helpers";
import { Module, exports, freeFuncPtr, track, untrack } from "./helpers";

/**
* An object representing a {@link https://harfbuzz.github.io/harfbuzz-hb-blob.html | HarfBuzz blob}.
* A blob wraps a chunk of binary data, typically the contents of a font file.
*/
export class Blob {
export class Blob implements Disposable {
readonly ptr: number;

/**
Expand All @@ -23,4 +23,14 @@ export class Blob {
);
track(this, exports.hb_blob_destroy);
}

/**
* Free the blob now instead of when it is garbage collected, as at the end
* of a `using` declaration's scope. Idempotent; using the blob afterwards
* throws. A {@link Face} created from the blob keeps the font data alive
* until it is freed too.
*/
[Symbol.dispose](): void {
untrack(this);
}
}
12 changes: 11 additions & 1 deletion src/buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Module,
exports,
track,
untrack,
hb_tag,
utf8_ptr_to_string,
string_to_ascii_ptr,
Expand Down Expand Up @@ -74,7 +75,7 @@ export type BufferSerializeFormat = ValueOf<typeof BufferSerializeFormat>;
* A buffer holds the input text and its properties before shaping, and the
* output glyphs and their information after shaping.
*/
export class Buffer {
export class Buffer implements Disposable {
readonly ptr: number;

/**
Expand All @@ -89,6 +90,15 @@ export class Buffer {
track(this, exports.hb_buffer_destroy);
}

/**
* Free the buffer now instead of when it is garbage collected, as at the
* end of a `using` declaration's scope. Idempotent; using the buffer
* afterwards throws.
*/
[Symbol.dispose](): void {
untrack(this);
}

/**
* Appends a character with the Unicode value of `codePoint` to the buffer,
* and gives it the initial cluster value of `cluster`. Clusters can be any
Expand Down
17 changes: 13 additions & 4 deletions src/draw-funcs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
Module,
exports,
registry,
track,
untrack,
register_callback_data_pointer,
get_callback_data,
remove_callback_data_pointer,
Expand Down Expand Up @@ -104,23 +105,31 @@ export type DrawClosePathFunc = (drawData: unknown, userData: unknown) => void;
* defined, but we translate {@link DrawFuncs.setQuadraticToFunc | quadratic-to}
* calls to cubic-to if the callback isn't defined.
*/
export class DrawFuncs {
export class DrawFuncs implements Disposable {
readonly ptr: number;
private funcPtrs: number[] = [];
private userDataPtrs: number[] = [];

constructor() {
this.ptr = exports.hb_draw_funcs_create();
const ptr = this.ptr;
const funcPtrs = this.funcPtrs;
const userDataPtrs = this.userDataPtrs;
registry.register(this, () => {
track(this, (ptr) => {
exports.hb_draw_funcs_destroy(ptr);
for (const ptr of funcPtrs) Module.removeFunction(ptr);
for (const ptr of userDataPtrs) remove_callback_data_pointer(ptr);
});
}

/**
* Free the draw functions now instead of when they are garbage collected,
* as at the end of a `using` declaration's scope. Idempotent; using them
* afterwards throws.
*/
[Symbol.dispose](): void {
untrack(this);
}

/**
* Sets move-to callback to the draw functions object.
* @param func The move-to callback.
Expand Down
13 changes: 12 additions & 1 deletion src/face.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Module,
exports,
track,
untrack,
STATIC_ARRAY_SIZE,
hb_tag,
hb_untag,
Expand Down Expand Up @@ -38,7 +39,7 @@ export type GlyphClass = ValueOf<typeof GlyphClass>;
* A face represents a single face in a binary font file and is the
* foundation for creating Font objects used in text shaping.
*/
export class Face {
export class Face implements Disposable {
readonly ptr: number;
readonly upem: number;

Expand All @@ -60,6 +61,16 @@ export class Face {
track(this, exports.hb_face_destroy);
}

/**
* Free the face now instead of when it is garbage collected, as at the end
* of a `using` declaration's scope. Idempotent; using the face afterwards
* throws. A {@link Font} created from the face keeps it alive until it is
* freed too.
*/
[Symbol.dispose](): void {
untrack(this);
}

/**
* Return the binary contents of an OpenType table.
* @param table Table name
Expand Down
13 changes: 11 additions & 2 deletions src/font-funcs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Module, exports, track, utf8_ptr_to_string } from "./helpers";
import { Module, exports, track, untrack, utf8_ptr_to_string } from "./helpers";
import type { FontExtents, GlyphExtents } from "./types";
import { Font } from "./font";

Expand All @@ -8,14 +8,23 @@ import { Font } from "./font";
* like glyph advances and extents. HarfBuzz provides built-in default
* implementations which can be selectively overridden.
*/
export class FontFuncs {
export class FontFuncs implements Disposable {
readonly ptr: number;

constructor() {
this.ptr = exports.hb_font_funcs_create();
track(this, exports.hb_font_funcs_destroy);
}

/**
* Free the font functions now instead of when they are garbage collected,
* as at the end of a `using` declaration's scope. Idempotent; using them
* afterwards throws. A {@link Font} they were set on keeps them alive.
*/
[Symbol.dispose](): void {
untrack(this);
}

/**
* Set the font's glyph extents function.
* @param func The callback receives a Font and glyph ID. It should return
Expand Down
12 changes: 11 additions & 1 deletion src/font.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Module,
exports,
track,
untrack,
STATIC_ARRAY_SIZE,
hb_tag,
utf8_ptr_to_string,
Expand Down Expand Up @@ -141,7 +142,7 @@ function getJsonDrawFuncs(): DrawFuncs {
* parameters (pixels-per-em, variation settings) specified. Fonts are the
* primary input to the shaping process.
*/
export class Font {
export class Font implements Disposable {
readonly ptr: number;
private _face?: Face;

Expand All @@ -161,6 +162,15 @@ export class Font {
track(this, exports.hb_font_destroy);
}

/**
* Free the font now instead of when it is garbage collected, as at the end
* of a `using` declaration's scope. Idempotent; using the font afterwards
* throws. Its {@link Face} is not freed.
*/
[Symbol.dispose](): void {
untrack(this);
}

/** The {@link Face} associated with this font. */
get face(): Face {
if (!this._face) {
Expand Down
25 changes: 23 additions & 2 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,37 @@ const utf8Encoder = new TextEncoder();

export const STATIC_ARRAY_SIZE = 128;

export const registry = new FinalizationRegistry<() => void>((cleanup) => {
const registry = new FinalizationRegistry<() => void>((cleanup) => {
cleanup();
});

const cleanups = new WeakMap<object, () => void>();

export function track(
obj: { ptr: number },
destroy: (ptr: number) => void,
): void {
const ptr = obj.ptr;
registry.register(obj, () => destroy(ptr));
const cleanup = () => destroy(ptr);
registry.register(obj, cleanup, obj);
cleanups.set(obj, cleanup);
}

/**
* Free a tracked object now instead of when it is garbage collected.
* Idempotent; any use of the object's `ptr` afterwards throws.
*/
export function untrack(obj: { ptr: number }): void {
const cleanup = cleanups.get(obj);
if (!cleanup) return;
cleanups.delete(obj);
registry.unregister(obj);
Object.defineProperty(obj, "ptr", {
get() {
throw new Error(`${obj.constructor.name} has been disposed`);
},
});
cleanup();
}

/**
Expand Down
17 changes: 13 additions & 4 deletions src/paint-funcs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
Module,
exports,
registry,
track,
untrack,
STATIC_ARRAY_SIZE,
hb_untag,
register_callback_data_pointer,
Expand Down Expand Up @@ -392,23 +393,31 @@ export type PaintCustomPaletteColorFunc = (
* The custom-palette-color callback is only necessary if you want to override
* colors from the font palette with custom colors.
*/
export class PaintFuncs {
export class PaintFuncs implements Disposable {
readonly ptr: number;
private funcPtrs: number[] = [];
private userDataPtrs: number[] = [];

constructor() {
this.ptr = exports.hb_paint_funcs_create();
const ptr = this.ptr;
const funcPtrs = this.funcPtrs;
const userDataPtrs = this.userDataPtrs;
registry.register(this, () => {
track(this, (ptr) => {
exports.hb_paint_funcs_destroy(ptr);
for (const ptr of funcPtrs) Module.removeFunction(ptr);
for (const ptr of userDataPtrs) remove_callback_data_pointer(ptr);
});
}

/**
* Free the paint functions now instead of when they are garbage collected,
* as at the end of a `using` declaration's scope. Idempotent; using them
* afterwards throws.
*/
[Symbol.dispose](): void {
untrack(this);
}

/**
* Sets the push-transform callback on the paint functions object.
* @param func The push-transform callback.
Expand Down
76 changes: 76 additions & 0 deletions test/dispose-polyfill.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { expect, it } from "vitest";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Run `fn` with a global Symbol that lacks Symbol.dispose until it is
// polyfilled, as in runtimes without it.
async function withoutSymbolDispose(fn) {
const OriginalSymbol = globalThis.Symbol;
globalThis.Symbol = new Proxy(function () {}, {
apply: (target, thisArg, args) => OriginalSymbol(...args),
get: (target, key) =>
key === "dispose" ? target.dispose : Reflect.get(OriginalSymbol, key),
});
try {
await fn();
} finally {
globalThis.Symbol = OriginalSymbol;
}
}

// Compile `using` declarations the way TypeScript does for runtimes without
// them; its helper reads Symbol.dispose when the declaration runs.
function transpileUsing(body) {
const { outputText } = ts.transpileModule(
`export function run(hb, data) { ${body} }`,
{
compilerOptions: {
target: ts.ScriptTarget.ES2021,
module: ts.ModuleKind.CommonJS,
},
},
);
const exports = {};
new Function("exports", outputText)(exports);
return exports.run;
}

const data = fs.readFileSync(
path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf"),
);

it("works with Symbol.dispose polyfilled before the import", async function () {
await withoutSymbolDispose(async () => {
Symbol.dispose ??= Symbol("Symbol.dispose");
// A query string gives a fresh copy of the module.
const hb = await import("../dist/index.mjs?polyfilled-before-import");

const blob = new hb.Blob(data);
blob[Symbol.dispose]();
expect(() => blob.ptr).to.throw("Blob has been disposed");

const usingDeclarations = transpileUsing(
`using blob = new hb.Blob(data);
using face = new hb.Face(blob);
return { blob, face, unicodes: [...face.collectUnicodes()] };`,
);
const result = usingDeclarations(hb, data);
expect(result.unicodes).to.include("a".codePointAt(0));
expect(() => result.blob.ptr).to.throw("Blob has been disposed");
expect(() => result.face.ptr).to.throw("Face has been disposed");
});
});

it("does not work with Symbol.dispose polyfilled after the import", async function () {
await withoutSymbolDispose(async () => {
const hb = await import("../dist/index.mjs?polyfilled-after-import");
Symbol.dispose ??= Symbol("Symbol.dispose");

const blob = new hb.Blob(data);
expect(blob[Symbol.dispose]).to.equal(undefined);
});
});
Loading