From 54236f5034040c891260781d0ae97711ed49e551 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Tue, 23 Jun 2026 18:58:36 +0300 Subject: [PATCH 01/11] Add DrawFuncs and Font.drawGlyph() (hb-draw API) --- harfbuzz.symbols | 1 + src/draw-funcs.ts | 288 +++++++++++++++++++++++++++++++++++++++++++++ src/font.ts | 52 ++++++++ src/helpers.ts | 20 ++++ src/index.ts | 1 + test/index.test.js | 126 ++++++++++++++++++++ 6 files changed, 488 insertions(+) create mode 100644 src/draw-funcs.ts diff --git a/harfbuzz.symbols b/harfbuzz.symbols index 642b207..f0a8fe2 100644 --- a/harfbuzz.symbols +++ b/harfbuzz.symbols @@ -67,6 +67,7 @@ _hb_font_funcs_set_font_h_extents_func _hb_font_funcs_set_font_v_extents_func _hb_font_funcs_set_glyph_name_func _hb_font_draw_glyph +_hb_font_draw_glyph_or_fail _hb_draw_funcs_create _hb_draw_funcs_destroy _hb_draw_funcs_set_move_to_func diff --git a/src/draw-funcs.ts b/src/draw-funcs.ts new file mode 100644 index 0000000..84378e7 --- /dev/null +++ b/src/draw-funcs.ts @@ -0,0 +1,288 @@ +import { + Module, + exports, + registry, + register_callback_data_pointer, + get_callback_data, + remove_callback_data_pointer, +} from "./helpers"; + +/** + * A callback to perform a `move-to` draw operation. + * @param x X component of target point + * @param y Y component of target point + * @param drawData the data accompanying the draw functions in + * {@link Font.drawGlyph} + * @param userData the user data pointer passed to + * {@link DrawFuncs.setMoveToFunc} + */ +export type DrawMoveToFunc = ( + x: number, + y: number, + drawData: unknown, + userData: unknown, +) => void; + +/** + * A callback to perform a `line-to` draw operation. + * @param x X component of target point + * @param y Y component of target point + * @param drawData the data accompanying the draw functions in + * {@link Font.drawGlyph} + * @param userData the user data pointer passed to + * {@link DrawFuncs.setLineToFunc} + */ +export type DrawLineToFunc = ( + x: number, + y: number, + drawData: unknown, + userData: unknown, +) => void; + +/** + * A callback to perform a `quadratic-to` draw operation. + * @param cx X component of control point + * @param cy Y component of control point + * @param x X component of target point + * @param y Y component of target point + * @param drawData the data accompanying the draw functions in + * {@link Font.drawGlyph} + * @param userData the user data pointer passed to + * {@link DrawFuncs.setQuadraticToFunc} + */ +export type DrawQuadraticToFunc = ( + cx: number, + cy: number, + x: number, + y: number, + drawData: unknown, + userData: unknown, +) => void; + +/** + * A callback to perform a `cubic-to` draw operation. + * @param c1x X component of first control point + * @param c1y Y component of first control point + * @param c2x X component of second control point + * @param c2y Y component of second control point + * @param x X component of target point + * @param y Y component of target point + * @param drawData the data accompanying the draw functions in + * {@link Font.drawGlyph} + * @param userData the user data pointer passed to + * {@link DrawFuncs.setCubicToFunc} + */ +export type DrawCubicToFunc = ( + c1x: number, + c1y: number, + c2x: number, + c2y: number, + x: number, + y: number, + drawData: unknown, + userData: unknown, +) => void; + +/** + * A callback to perform a `close-path` draw operation. + * @param drawData the data accompanying the draw functions in + * {@link Font.drawGlyph} + * @param userData the user data pointer passed to + * {@link DrawFuncs.setClosePathFunc} + */ +export type DrawClosePathFunc = (drawData: unknown, userData: unknown) => void; + +/** + * An object representing + * {@link https://harfbuzz.github.io/harfbuzz-hb-draw.html | HarfBuzz draw functions}. + * + * Glyph draw callbacks. + * + * The {@link DrawFuncs.setMoveToFunc | move-to}, + * {@link DrawFuncs.setLineToFunc | line-to} and + * {@link DrawFuncs.setCubicToFunc | cubic-to} callbacks are necessary to be + * defined, but we translate {@link DrawFuncs.setQuadraticToFunc | quadratic-to} + * calls to cubic-to if the callback isn't defined. + */ +export class DrawFuncs { + 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, () => { + exports.hb_draw_funcs_destroy(ptr); + for (const ptr of funcPtrs) Module.removeFunction(ptr); + for (const ptr of userDataPtrs) remove_callback_data_pointer(ptr); + }); + } + + /** + * Sets move-to callback to the draw functions object. + * @param func The move-to callback. + * @param userData Data to pass to `func`. + */ + setMoveToFunc(func: DrawMoveToFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + ( + dfuncs: number, + draw_data: number, + draw_state: number, + toX: number, + toY: number, + user_data: number, + ) => { + func( + toX, + toY, + get_callback_data(draw_data), + get_callback_data(user_data), + ); + }, + "viiiffi", + ); + this.funcPtrs.push(funcPtr); + exports.hb_draw_funcs_set_move_to_func(this.ptr, funcPtr, userDataPtr, 0); + } + + /** + * Sets line-to callback to the draw functions object. + * @param func The line-to callback. + * @param userData Data to pass to `func`. + */ + setLineToFunc(func: DrawLineToFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + ( + dfuncs: number, + draw_data: number, + draw_state: number, + toX: number, + toY: number, + user_data: number, + ) => { + func( + toX, + toY, + get_callback_data(draw_data), + get_callback_data(user_data), + ); + }, + "viiiffi", + ); + this.funcPtrs.push(funcPtr); + exports.hb_draw_funcs_set_line_to_func(this.ptr, funcPtr, userDataPtr, 0); + } + + /** + * Sets quadratic-to callback to the draw functions object. + * @param func The quadratic-to callback. + * @param userData Data to pass to `func`. + */ + setQuadraticToFunc(func: DrawQuadraticToFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + ( + dfuncs: number, + draw_data: number, + draw_state: number, + cX: number, + cY: number, + toX: number, + toY: number, + user_data: number, + ) => { + func( + cX, + cY, + toX, + toY, + get_callback_data(draw_data), + get_callback_data(user_data), + ); + }, + "viiiffffi", + ); + this.funcPtrs.push(funcPtr); + exports.hb_draw_funcs_set_quadratic_to_func( + this.ptr, + funcPtr, + userDataPtr, + 0, + ); + } + + /** + * Sets cubic-to callback to the draw functions object. + * @param func The cubic-to callback. + * @param userData Data to pass to `func`. + */ + setCubicToFunc(func: DrawCubicToFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + ( + dfuncs: number, + draw_data: number, + draw_state: number, + c1X: number, + c1Y: number, + c2X: number, + c2Y: number, + toX: number, + toY: number, + user_data: number, + ) => { + func( + c1X, + c1Y, + c2X, + c2Y, + toX, + toY, + get_callback_data(draw_data), + get_callback_data(user_data), + ); + }, + "viiiffffffi", + ); + this.funcPtrs.push(funcPtr); + exports.hb_draw_funcs_set_cubic_to_func(this.ptr, funcPtr, userDataPtr, 0); + } + + /** + * Sets close-path callback to the draw functions object. + * @param func The close-path callback. + * @param userData Data to pass to `func`. + */ + setClosePathFunc(func: DrawClosePathFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + ( + dfuncs: number, + draw_data: number, + draw_state: number, + user_data: number, + ) => { + func(get_callback_data(draw_data), get_callback_data(user_data)); + }, + "viiii", + ); + this.funcPtrs.push(funcPtr); + exports.hb_draw_funcs_set_close_path_func( + this.ptr, + funcPtr, + userDataPtr, + 0, + ); + } +} diff --git a/src/font.ts b/src/font.ts index c5141fe..d58a751 100644 --- a/src/font.ts +++ b/src/font.ts @@ -6,12 +6,15 @@ import { hb_tag, utf8_ptr_to_string, string_to_utf8_ptr, + register_callback_data_pointer, + remove_callback_data_pointer, type ValueOf, } from "./helpers"; import type { FontExtents, GlyphExtents, SvgPathCommand } from "./types"; import type { Direction } from "./buffer"; import { Face } from "./face"; import type { FontFuncs } from "./font-funcs"; +import type { DrawFuncs } from "./draw-funcs"; import type { Variation } from "./variation"; /** @@ -194,6 +197,55 @@ export class Font { return name; } + /** + * Draws the outline that corresponds to a glyph in the specified font. + * + * The outline is returned by way of calls to the callbacks of the `drawFuncs` + * object, with `drawData` passed to them. + * @param glyphId The glyph ID. + * @param drawFuncs The {@link DrawFuncs} to draw to. + * @param drawData User data to pass to draw callbacks. + */ + drawGlyph(glyphId: number, drawFuncs: DrawFuncs, drawData?: unknown): void { + const drawDataPtr = register_callback_data_pointer(drawData); + try { + exports.hb_font_draw_glyph(this.ptr, glyphId, drawFuncs.ptr, drawDataPtr); + } finally { + remove_callback_data_pointer(drawDataPtr); + } + } + + /** + * Draws the outline that corresponds to a glyph in the specified font. + * + * This is a newer name for {@link Font.drawGlyph}, that returns `false` if the + * font has no outlines for the glyph. + * + * The outline is returned by way of calls to the callbacks of the `drawFuncs` + * object, with `drawData` passed to them. + * @param glyphId The glyph ID. + * @param drawFuncs The {@link DrawFuncs} to draw to. + * @param drawData User data to pass to draw callbacks. + * @returns `true` if the glyph was drawn, `false` otherwise. + */ + drawGlyphOrFail( + glyphId: number, + drawFuncs: DrawFuncs, + drawData?: unknown, + ): boolean { + const drawDataPtr = register_callback_data_pointer(drawData); + try { + return !!exports.hb_font_draw_glyph_or_fail( + this.ptr, + glyphId, + drawFuncs.ptr, + drawDataPtr, + ); + } finally { + remove_callback_data_pointer(drawDataPtr); + } + } + /** * Return a glyph as an SVG path string. * @param glyphId ID of the requested glyph in the font. diff --git a/src/helpers.ts b/src/helpers.ts index 3348935..09f014a 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -149,3 +149,23 @@ export function typed_array_from_set(setPtr: number): Uint32Array { const array = Module.HEAPU32.subarray(arrayOffset, arrayOffset + setCount); return array; } + +const callbackData: unknown[] = [undefined]; +const freeCallbackData: number[] = []; + +export function register_callback_data_pointer(data: unknown): number { + if (data === undefined) return 0; + const dataPtr = freeCallbackData.pop() ?? callbackData.length; + callbackData[dataPtr] = data; + return dataPtr; +} + +export function get_callback_data(dataPtr: number): unknown { + return callbackData[dataPtr]; +} + +export function remove_callback_data_pointer(dataPtr: number): void { + if (dataPtr === 0) return; + callbackData[dataPtr] = undefined; + freeCallbackData.push(dataPtr); +} diff --git a/src/index.ts b/src/index.ts index ffaa2a5..bbdf502 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export * from "./blob"; export * from "./face"; export * from "./font"; export * from "./font-funcs"; +export * from "./draw-funcs"; export * from "./buffer"; export * from "./feature"; export * from "./variation"; diff --git a/test/index.test.js b/test/index.test.js index 56f49ae..dfcbe35 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -700,6 +700,132 @@ describe("Font", function () { }); }); +describe("DrawFuncs", function () { + function recordGlyph(font, gid) { + const ops = []; + const funcs = new hb.DrawFuncs(); + funcs.setMoveToFunc((x, y) => ops.push(["M", x, y])); + funcs.setLineToFunc((x, y) => ops.push(["L", x, y])); + funcs.setQuadraticToFunc((cx, cy, x, y) => ops.push(["Q", cx, cy, x, y])); + funcs.setCubicToFunc((c1x, c1y, c2x, c2y, x, y) => + ops.push(["C", c1x, c1y, c2x, c2y, x, y]), + ); + funcs.setClosePathFunc(() => ops.push(["Z"])); + font.drawGlyph(gid, funcs); + return ops; + } + + it("drawGlyph reports the outline as draw operations", function () { + let blob = new hb.Blob( + fs.readFileSync(path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf")), + ); + let face = new hb.Face(blob); + let font = new hb.Font(face); + expect(recordGlyph(font, font.glyphFromName("A"))).to.deep.equal([ + ["M", 545, 0], + ["L", 459, 221], + ["L", 176, 221], + ["L", 91, 0], + ["L", 0, 0], + ["L", 279, 717], + ["L", 360, 717], + ["L", 638, 0], + ["L", 545, 0], + ["Z"], + ["M", 352, 517], + ["Q", 349, 525, 342, 546], + ["Q", 335, 567, 328.5, 589.5], + ["Q", 322, 612, 318, 624], + ["Q", 311, 593, 302, 563.5], + ["Q", 293, 534, 287, 517], + ["L", 206, 301], + ["L", 432, 301], + ["L", 352, 517], + ["Z"], + ]); + }); + + it("drawGlyph reports cubic curves for a CFF font", function () { + let blob = new hb.Blob( + fs.readFileSync(path.join(__dirname, "fonts/noto/NotoSans-Regular.otf")), + ); + let face = new hb.Face(blob); + let font = new hb.Font(face); + const ops = recordGlyph(font, font.glyphFromName("o")); + expect(ops.some((op) => op[0] === "C")).to.equal(true); + }); + + it("drawGlyphOrFail reports whether the glyph has an outline", function () { + let blob = new hb.Blob( + fs.readFileSync(path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf")), + ); + let face = new hb.Face(blob); + let font = new hb.Font(face); + const funcs = new hb.DrawFuncs(); + funcs.setMoveToFunc(() => {}); + funcs.setLineToFunc(() => {}); + funcs.setQuadraticToFunc(() => {}); + funcs.setCubicToFunc(() => {}); + funcs.setClosePathFunc(() => {}); + expect(font.drawGlyphOrFail(font.glyphFromName("A"), funcs)).to.equal(true); + expect(font.drawGlyphOrFail(99999, funcs)).to.equal(false); + }); + + it("drawGlyph forwards drawData to the callbacks", function () { + let blob = new hb.Blob( + fs.readFileSync(path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf")), + ); + let face = new hb.Face(blob); + let font = new hb.Font(face); + const funcs = new hb.DrawFuncs(); + const seen = new Set(); + funcs.setMoveToFunc((x, y, data) => seen.add(data)); + funcs.setLineToFunc((x, y, data) => seen.add(data)); + funcs.setClosePathFunc((data) => seen.add(data)); + + const pen = { name: "pen" }; + font.drawGlyph(font.glyphFromName("A"), funcs, pen); + expect([...seen]).to.deep.equal([pen]); + + seen.clear(); + font.drawGlyph(font.glyphFromName("A"), funcs); + expect([...seen]).to.deep.equal([undefined]); + }); + + it("set*Func forwards per-callback userData", function () { + let blob = new hb.Blob( + fs.readFileSync(path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf")), + ); + let face = new hb.Face(blob); + let font = new hb.Font(face); + const funcs = new hb.DrawFuncs(); + const moveUser = { cb: "move" }; + const lineUser = { cb: "line" }; + const pen = { pen: 1 }; + const records = []; + funcs.setMoveToFunc( + (x, y, drawData, userData) => records.push(["M", drawData, userData]), + moveUser, + ); + funcs.setLineToFunc( + (x, y, drawData, userData) => records.push(["L", drawData, userData]), + lineUser, + ); + + font.drawGlyph(font.glyphFromName("A"), funcs, pen); + const moves = records.filter((r) => r[0] === "M"); + const lines = records.filter((r) => r[0] === "L"); + expect(moves.length).to.be.greaterThan(0); + expect(lines.length).to.be.greaterThan(0); + expect(moves.every((r) => r[1] === pen && r[2] === moveUser)).to.equal( + true, + ); + expect(lines.every((r) => r[1] === pen && r[2] === lineUser)).to.equal( + true, + ); + }); +}); + describe("FontFuncs", function () { it("setGlyphExtentsFunc", function () { let blob = new hb.Blob( From 5162626a2381283ae6d03aae583fb227f555998e Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Tue, 23 Jun 2026 20:39:32 +0300 Subject: [PATCH 02/11] Reimplement glyphToPath() and glyphToJson() on DrawFuncs --- src/font.ts | 192 ++++++++++++++------------------------------- test/index.test.js | 20 +++++ 2 files changed, 79 insertions(+), 133 deletions(-) diff --git a/src/font.ts b/src/font.ts index d58a751..f16e71f 100644 --- a/src/font.ts +++ b/src/font.ts @@ -1,7 +1,7 @@ import { Module, exports, - registry, + track, STATIC_ARRAY_SIZE, hb_tag, utf8_ptr_to_string, @@ -14,7 +14,7 @@ import type { FontExtents, GlyphExtents, SvgPathCommand } from "./types"; import type { Direction } from "./buffer"; import { Face } from "./face"; import type { FontFuncs } from "./font-funcs"; -import type { DrawFuncs } from "./draw-funcs"; +import { DrawFuncs } from "./draw-funcs"; import type { Variation } from "./variation"; /** @@ -81,14 +81,56 @@ export const MetricsTag = { } as const; export type MetricsTag = ValueOf; -interface DrawPtrs { - drawFuncsPtr?: number; - moveToPtr?: number; - lineToPtr?: number; - cubicToPtr?: number; - quadToPtr?: number; - closePathPtr?: number; - pathBuffer: string; +let pathDrawFuncs: DrawFuncs | undefined; +function getPathDrawFuncs(): DrawFuncs { + if (!pathDrawFuncs) { + pathDrawFuncs = new DrawFuncs(); + pathDrawFuncs.setMoveToFunc((x, y, path) => { + (path as string[]).push(`M${x},${y}`); + }); + pathDrawFuncs.setLineToFunc((x, y, path) => { + (path as string[]).push(`L${x},${y}`); + }); + pathDrawFuncs.setCubicToFunc((c1x, c1y, c2x, c2y, x, y, path) => { + (path as string[]).push(`C${c1x},${c1y} ${c2x},${c2y} ${x},${y}`); + }); + pathDrawFuncs.setQuadraticToFunc((cx, cy, x, y, path) => { + (path as string[]).push(`Q${cx},${cy} ${x},${y}`); + }); + pathDrawFuncs.setClosePathFunc((path) => { + (path as string[]).push("Z"); + }); + } + return pathDrawFuncs; +} + +let jsonDrawFuncs: DrawFuncs | undefined; +function getJsonDrawFuncs(): DrawFuncs { + if (!jsonDrawFuncs) { + jsonDrawFuncs = new DrawFuncs(); + jsonDrawFuncs.setMoveToFunc((x, y, commands) => { + (commands as SvgPathCommand[]).push({ type: "M", values: [x, y] }); + }); + jsonDrawFuncs.setLineToFunc((x, y, commands) => { + (commands as SvgPathCommand[]).push({ type: "L", values: [x, y] }); + }); + jsonDrawFuncs.setCubicToFunc((c1x, c1y, c2x, c2y, x, y, commands) => { + (commands as SvgPathCommand[]).push({ + type: "C", + values: [c1x, c1y, c2x, c2y, x, y], + }); + }); + jsonDrawFuncs.setQuadraticToFunc((cx, cy, x, y, commands) => { + (commands as SvgPathCommand[]).push({ + type: "Q", + values: [cx, cy, x, y], + }); + }); + jsonDrawFuncs.setClosePathFunc((commands) => { + (commands as SvgPathCommand[]).push({ type: "Z", values: [] }); + }); + } + return jsonDrawFuncs; } /** @@ -100,9 +142,6 @@ interface DrawPtrs { export class Font { readonly ptr: number; private _face?: Face; - private drawPtrs: DrawPtrs = { - pathBuffer: "", - }; /** * @param face A Face to create the font from. @@ -117,19 +156,7 @@ export class Font { this.ptr = exports.hb_font_create(arg.ptr); this._face = arg; } - const ptr = this.ptr; - const drawState = this.drawPtrs; - registry.register(this, () => { - exports.hb_font_destroy(ptr); - if (drawState.drawFuncsPtr) { - exports.hb_draw_funcs_destroy(drawState.drawFuncsPtr); - Module.removeFunction(drawState.moveToPtr!); - Module.removeFunction(drawState.lineToPtr!); - Module.removeFunction(drawState.cubicToPtr!); - Module.removeFunction(drawState.quadToPtr!); - Module.removeFunction(drawState.closePathPtr!); - } - }); + track(this, exports.hb_font_destroy); } /** The {@link Face} associated with this font. */ @@ -252,104 +279,9 @@ export class Font { * @returns SVG path data string. */ glyphToPath(glyphId: number): string { - const ds = this.drawPtrs; - if (!ds.drawFuncsPtr) { - const moveTo = ( - dfuncs: number, - draw_data: number, - draw_state: number, - to_x: number, - to_y: number, - user_data: number, - ) => { - ds.pathBuffer += `M${to_x},${to_y}`; - }; - const lineTo = ( - dfuncs: number, - draw_data: number, - draw_state: number, - to_x: number, - to_y: number, - user_data: number, - ) => { - ds.pathBuffer += `L${to_x},${to_y}`; - }; - const cubicTo = ( - dfuncs: number, - draw_data: number, - draw_state: number, - c1_x: number, - c1_y: number, - c2_x: number, - c2_y: number, - to_x: number, - to_y: number, - user_data: number, - ) => { - ds.pathBuffer += `C${c1_x},${c1_y} ${c2_x},${c2_y} ${to_x},${to_y}`; - }; - const quadTo = ( - dfuncs: number, - draw_data: number, - draw_state: number, - c_x: number, - c_y: number, - to_x: number, - to_y: number, - user_data: number, - ) => { - ds.pathBuffer += `Q${c_x},${c_y} ${to_x},${to_y}`; - }; - const closePath = ( - dfuncs: number, - draw_data: number, - draw_state: number, - user_data: number, - ) => { - ds.pathBuffer += "Z"; - }; - - ds.moveToPtr = Module.addFunction(moveTo, "viiiffi"); - ds.lineToPtr = Module.addFunction(lineTo, "viiiffi"); - ds.cubicToPtr = Module.addFunction(cubicTo, "viiiffffffi"); - ds.quadToPtr = Module.addFunction(quadTo, "viiiffffi"); - ds.closePathPtr = Module.addFunction(closePath, "viiii"); - ds.drawFuncsPtr = exports.hb_draw_funcs_create(); - exports.hb_draw_funcs_set_move_to_func( - ds.drawFuncsPtr, - ds.moveToPtr, - 0, - 0, - ); - exports.hb_draw_funcs_set_line_to_func( - ds.drawFuncsPtr, - ds.lineToPtr, - 0, - 0, - ); - exports.hb_draw_funcs_set_cubic_to_func( - ds.drawFuncsPtr, - ds.cubicToPtr, - 0, - 0, - ); - exports.hb_draw_funcs_set_quadratic_to_func( - ds.drawFuncsPtr, - ds.quadToPtr, - 0, - 0, - ); - exports.hb_draw_funcs_set_close_path_func( - ds.drawFuncsPtr, - ds.closePathPtr, - 0, - 0, - ); - } - - ds.pathBuffer = ""; - exports.hb_font_draw_glyph(this.ptr, glyphId, ds.drawFuncsPtr, 0); - return ds.pathBuffer; + const path: string[] = []; + this.drawGlyph(glyphId, getPathDrawFuncs(), path); + return path.join(""); } /** @@ -539,15 +471,9 @@ export class Font { * @returns An array of path segment objects with type and values. */ glyphToJson(glyphId: number): SvgPathCommand[] { - const path = this.glyphToPath(glyphId); - return path - .replace(/([MLQCZ])/g, "|$1 ") - .split("|") - .filter((x) => x.length) - .map((x) => { - const [type, ...values] = x.split(/[ ,]/g).filter((s) => s.length); - return { type, values: values.map(Number) }; - }); + const commands: SvgPathCommand[] = []; + this.drawGlyph(glyphId, getJsonDrawFuncs(), commands); + return commands; } /** diff --git a/test/index.test.js b/test/index.test.js index dfcbe35..86d877e 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -698,6 +698,26 @@ describe("Font", function () { 64 105,81 45,111L45,29C104,0 166,-10 241,-10C430,-10 515,78 515,203C515,297 459,358 345,372L345,376C435,394 493,451 493,547Z"; expect(font.glyphToPath(22)).to.equal(expected22); }); + + it("glyphToJson converts a glyph to path commands", function () { + let blob = new hb.Blob( + fs.readFileSync(path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf")), + ); + let face = new hb.Face(blob); + let font = new hb.Font(face); + expect(font.glyphToJson(font.glyphFromName("period"))).to.deep.equal([ + { type: "M", values: [72, 54] }, + { type: "Q", values: [72, 91, 90, 106] }, + { type: "Q", values: [108, 121, 133, 121] }, + { type: "Q", values: [159, 121, 177.5, 106] }, + { type: "Q", values: [196, 91, 196, 54] }, + { type: "Q", values: [196, 18, 177.5, 2] }, + { type: "Q", values: [159, -14, 133, -14] }, + { type: "Q", values: [108, -14, 90, 2] }, + { type: "Q", values: [72, 18, 72, 54] }, + { type: "Z", values: [] }, + ]); + }); }); describe("DrawFuncs", function () { From 6683ccb62b5bb4dc19c46d9a0478d8fd2fcad637 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Wed, 24 Jun 2026 18:36:07 +0300 Subject: [PATCH 03/11] Add PaintFuncs and Font.paintGlyph() (hb-paint API) --- config-override.h | 2 + harfbuzz.symbols | 20 + src/font.ts | 84 ++- src/helpers.ts | 21 +- src/index.ts | 1 + src/paint-funcs.ts | 824 +++++++++++++++++++++++++ src/types.ts | 159 +++++ test/fonts/test_glyphs-glyf_colr_1.ttf | Bin 0 -> 21568 bytes test/index.test.js | 92 +++ 9 files changed, 1201 insertions(+), 2 deletions(-) create mode 100644 src/paint-funcs.ts create mode 100644 test/fonts/test_glyphs-glyf_colr_1.ttf diff --git a/config-override.h b/config-override.h index aa6413a..072d1f6 100644 --- a/config-override.h +++ b/config-override.h @@ -1,6 +1,8 @@ #undef HB_NO_CFF #undef HB_NO_OT_FONT_CFF #undef HB_NO_DRAW +#undef HB_NO_PAINT +#undef HB_NO_COLOR #undef HB_NO_BUFFER_MESSAGE #undef HB_NO_BUFFER_SERIALIZE #undef HB_NO_VAR diff --git a/harfbuzz.symbols b/harfbuzz.symbols index f0a8fe2..537f4a5 100644 --- a/harfbuzz.symbols +++ b/harfbuzz.symbols @@ -75,6 +75,26 @@ _hb_draw_funcs_set_line_to_func _hb_draw_funcs_set_quadratic_to_func _hb_draw_funcs_set_cubic_to_func _hb_draw_funcs_set_close_path_func +_hb_paint_funcs_create +_hb_paint_funcs_destroy +_hb_paint_funcs_set_push_transform_func +_hb_paint_funcs_set_pop_transform_func +_hb_paint_funcs_set_color_glyph_func +_hb_paint_funcs_set_push_clip_glyph_func +_hb_paint_funcs_set_push_clip_rectangle_func +_hb_paint_funcs_set_pop_clip_func +_hb_paint_funcs_set_color_func +_hb_paint_funcs_set_image_func +_hb_paint_funcs_set_linear_gradient_func +_hb_paint_funcs_set_radial_gradient_func +_hb_paint_funcs_set_sweep_gradient_func +_hb_paint_funcs_set_push_group_func +_hb_paint_funcs_set_pop_group_func +_hb_paint_funcs_set_custom_palette_color_func +_hb_font_paint_glyph +_hb_font_paint_glyph_or_fail +_hb_color_line_get_color_stops +_hb_color_line_get_extend _hb_glyph_info_get_glyph_flags _hb_language_from_string _hb_ot_layout_table_get_script_tags diff --git a/src/font.ts b/src/font.ts index f16e71f..ad43292 100644 --- a/src/font.ts +++ b/src/font.ts @@ -8,13 +8,15 @@ import { string_to_utf8_ptr, register_callback_data_pointer, remove_callback_data_pointer, + color_to_int, type ValueOf, } from "./helpers"; -import type { FontExtents, GlyphExtents, SvgPathCommand } from "./types"; +import type { Color, FontExtents, GlyphExtents, SvgPathCommand } from "./types"; import type { Direction } from "./buffer"; import { Face } from "./face"; import type { FontFuncs } from "./font-funcs"; import { DrawFuncs } from "./draw-funcs"; +import type { PaintFuncs } from "./paint-funcs"; import type { Variation } from "./variation"; /** @@ -273,6 +275,86 @@ export class Font { } } + /** + * Paints the glyph. This function is similar to {@link Font.paintGlyphOrFail}, + * but if painting a color glyph failed, it will fall back to painting an + * outline monochrome glyph. + * + * The painting instructions are returned by way of calls to the callbacks of + * the `paintFuncs` object, with `paintData` passed to them. + * + * If the font has color palettes, then `paletteIndex` selects the palette to + * use. If the font only has one palette, this will be 0. + * @param glyphId The glyph ID. + * @param paintFuncs The {@link PaintFuncs} to paint with. + * @param paintData User data to pass to paint callbacks. + * @param paletteIndex The index of the font's color palette to use. + * @param foreground The foreground color, unpremultiplied. + */ + paintGlyph( + glyphId: number, + paintFuncs: PaintFuncs, + paintData?: unknown, + paletteIndex: number = 0, + foreground: Color = { red: 0, green: 0, blue: 0, alpha: 255 }, + ): void { + const paintDataPtr = register_callback_data_pointer(paintData); + try { + exports.hb_font_paint_glyph( + this.ptr, + glyphId, + paintFuncs.ptr, + paintDataPtr, + paletteIndex, + color_to_int(foreground), + ); + } finally { + remove_callback_data_pointer(paintDataPtr); + } + } + + /** + * Paints a color glyph. + * + * Succeeds if the glyph has color paint layers (COLRv0), a color paint graph + * (COLRv1), or a bitmap image that the font's callbacks render successfully. + * Returns `false` if the font has no color data for the glyph; the client can + * then fall back to {@link Font.drawGlyphOrFail} for the monochrome outline. + * + * The painting instructions are returned by way of calls to the callbacks of + * the `paintFuncs` object, with `paintData` passed to them. + * + * If the font has color palettes, then `paletteIndex` selects the palette to + * use. If the font only has one palette, this will be 0. + * @param glyphId The glyph ID. + * @param paintFuncs The {@link PaintFuncs} to paint with. + * @param paintData User data to pass to paint callbacks. + * @param paletteIndex The index of the font's color palette to use. + * @param foreground The foreground color, unpremultiplied. + * @returns `true` if the glyph was painted, `false` otherwise. + */ + paintGlyphOrFail( + glyphId: number, + paintFuncs: PaintFuncs, + paintData?: unknown, + paletteIndex: number = 0, + foreground: Color = { red: 0, green: 0, blue: 0, alpha: 255 }, + ): boolean { + const paintDataPtr = register_callback_data_pointer(paintData); + try { + return !!exports.hb_font_paint_glyph_or_fail( + this.ptr, + glyphId, + paintFuncs.ptr, + paintDataPtr, + paletteIndex, + color_to_int(foreground), + ); + } finally { + remove_callback_data_pointer(paintDataPtr); + } + } + /** * Return a glyph as an SVG path string. * @param glyphId ID of the requested glyph in the font. diff --git a/src/helpers.ts b/src/helpers.ts index 09f014a..7eb9273 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -6,7 +6,7 @@ interface StringPtr { export type ValueOf = T[keyof T]; -import type { HarfBuzzModule } from "./types"; +import type { HarfBuzzModule, Color } from "./types"; // Module-level WASM state (set once by init) export let Module: HarfBuzzModule; @@ -169,3 +169,22 @@ export function remove_callback_data_pointer(dataPtr: number): void { callbackData[dataPtr] = undefined; freeCallbackData.push(dataPtr); } + +export function color_from_int(color: number): Color { + return { + red: (color >> 8) & 0xff, + green: (color >> 16) & 0xff, + blue: (color >> 24) & 0xff, + alpha: color & 0xff, + }; +} + +export function color_to_int(color: Color): number { + return ( + ((color.red << 8) | + (color.green << 16) | + (color.blue << 24) | + color.alpha) >>> + 0 + ); +} diff --git a/src/index.ts b/src/index.ts index bbdf502..d8c7a16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export * from "./face"; export * from "./font"; export * from "./font-funcs"; export * from "./draw-funcs"; +export * from "./paint-funcs"; export * from "./buffer"; export * from "./feature"; export * from "./variation"; diff --git a/src/paint-funcs.ts b/src/paint-funcs.ts new file mode 100644 index 0000000..a48f8f2 --- /dev/null +++ b/src/paint-funcs.ts @@ -0,0 +1,824 @@ +import { + Module, + exports, + registry, + STATIC_ARRAY_SIZE, + hb_untag, + register_callback_data_pointer, + get_callback_data, + remove_callback_data_pointer, + color_from_int, + color_to_int, +} from "./helpers"; +import type { + Color, + ColorLine, + ColorStop, + GlyphExtents, + PaintCompositeMode, + PaintExtend, +} from "./types"; +import { Font } from "./font"; + +function decode_color_line(colorLinePtr: number): ColorLine { + const extend = exports.hb_color_line_get_extend(colorLinePtr) as PaintExtend; + const colorStops: ColorStop[] = []; + const sp = Module.stackSave(); + const countPtr = Module.stackAlloc(4); + const stopsPtr = Module.stackAlloc(STATIC_ARRAY_SIZE * 12); + let startOffset = 0; + let count = STATIC_ARRAY_SIZE; + while (count === STATIC_ARRAY_SIZE) { + Module.HEAPU32[countPtr / 4] = count; + exports.hb_color_line_get_color_stops( + colorLinePtr, + startOffset, + countPtr, + stopsPtr, + ); + count = Module.HEAPU32[countPtr / 4]; + for (let i = 0; i < count; i++) { + const o = (stopsPtr + i * 12) / 4; + colorStops.push({ + offset: Module.HEAPF32[o], + isForeground: Module.HEAPU32[o + 1] !== 0, + color: color_from_int(Module.HEAPU32[o + 2]), + }); + } + startOffset += count; + } + Module.stackRestore(sp); + return { extend, colorStops }; +} + +/** + * A callback to apply a transform to subsequent paint calls. The transform is + * applied after the current transform, and remains in effect until a matching + * call to {@link PaintFuncs.setPopTransformFunc}. + * @param xx xx component of the transform matrix + * @param yx yx component of the transform matrix + * @param xy xy component of the transform matrix + * @param yy yy component of the transform matrix + * @param dx dx component of the transform matrix + * @param dy dy component of the transform matrix + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setPushTransformFunc} + */ +export type PaintPushTransformFunc = ( + xx: number, + yx: number, + xy: number, + yy: number, + dx: number, + dy: number, + paintData: unknown, + userData: unknown, +) => void; + +/** + * A callback to undo the effect of a prior call to the + * {@link PaintFuncs.setPushTransformFunc} callback. + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setPopTransformFunc} + */ +export type PaintPopTransformFunc = ( + paintData: unknown, + userData: unknown, +) => void; + +/** + * A callback to render a color glyph by glyph index. + * @param glyph the glyph ID + * @param font the {@link Font} + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setColorGlyphFunc} + * @returns `true` if the glyph was painted, `false` otherwise. + */ +export type PaintColorGlyphFunc = ( + glyph: number, + font: Font, + paintData: unknown, + userData: unknown, +) => boolean; + +/** + * A callback to clip subsequent paint calls to the outline of a glyph. + * + * The coordinates of the glyph outline are expected in the current `font` scale + * (ie. the results of calling {@link Font.drawGlyph} with `font`). The outline + * is transformed by the current transform. + * + * This clip is applied in addition to the current clip, and remains in effect + * until a matching call to {@link PaintFuncs.setPopClipFunc}. + * @param glyph the glyph ID + * @param font the {@link Font} + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setPushClipGlyphFunc} + */ +export type PaintPushClipGlyphFunc = ( + glyph: number, + font: Font, + paintData: unknown, + userData: unknown, +) => void; + +/** + * A callback to clip subsequent paint calls to a rectangle. + * + * The coordinates of the rectangle are interpreted according to the current + * transform. + * + * This clip is applied in addition to the current clip, and remains in effect + * until a matching call to {@link PaintFuncs.setPopClipFunc}. + * @param xmin min X for the rectangle + * @param ymin min Y for the rectangle + * @param xmax max X for the rectangle + * @param ymax max Y for the rectangle + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setPushClipRectangleFunc} + */ +export type PaintPushClipRectangleFunc = ( + xmin: number, + ymin: number, + xmax: number, + ymax: number, + paintData: unknown, + userData: unknown, +) => void; + +/** + * A callback to undo the effect of a prior call to the + * {@link PaintFuncs.setPushClipGlyphFunc} or + * {@link PaintFuncs.setPushClipRectangleFunc} callback. + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setPopClipFunc} + */ +export type PaintPopClipFunc = (paintData: unknown, userData: unknown) => void; + +/** + * A callback to paint a color everywhere within the current clip. + * + * When `isForeground` is true, this color originates from the foreground-color + * sentinel in the font's color data. The `color` parameter still carries a + * fully resolved RGBA value (with any paint-tree alpha already applied), so + * backends that do not need to distinguish the foreground can simply use + * `color` directly. + * + * Backends that defer foreground resolution (e.g. to honor a CSS `currentColor` + * or a runtime uniform) should substitute their own foreground RGB when + * `isForeground` is true, but must combine the alpha from `color` with their + * foreground alpha, since it encodes additional modulation from the paint tree. + * For this mode to work correctly, the caller should pass a fully-opaque + * foreground color to {@link Font.paintGlyph}, so that the alpha in `color` + * reflects only the paint-tree contribution. + * @param isForeground whether the color is the foreground + * @param color the color to use, unpremultiplied + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setColorFunc} + */ +export type PaintColorFunc = ( + isForeground: boolean, + color: Color, + paintData: unknown, + userData: unknown, +) => void; + +/** + * A callback to paint a glyph image. + * + * This method is called for glyphs with image blobs in the CBDT, sbix or SVG + * tables. The `format` identifies the kind of data that is contained in + * `image`. Possible values include `"png "`, `"svg "` and `"BGRA"`. + * + * The image dimensions and glyph extents are provided if available, and should + * be used to size and position the image. + * @param image the image data + * @param width width of the raster image in pixels, or 0 + * @param height height of the raster image in pixels, or 0 + * @param format the image format as a tag + * @param extents glyph extents for desired rendering + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setImageFunc} + * @returns Whether the operation was successful. + */ +export type PaintImageFunc = ( + image: Uint8Array, + width: number, + height: number, + format: string, + extents: GlyphExtents | undefined, + paintData: unknown, + userData: unknown, +) => boolean; + +/** + * A callback to paint a linear gradient everywhere within the current clip. The + * coordinates of the points are interpreted according to the current transform; see the + * OpenType spec + * [COLR](https://learn.microsoft.com/en-us/typography/opentype/spec/colr) + * section for details on how the points define the direction of the gradient, and +how to interpret the `colorLine`. + * @param colorLine color information for the gradient + * @param x0 X coordinate of the first point + * @param y0 Y coordinate of the first point + * @param x1 X coordinate of the second point + * @param y1 Y coordinate of the second point + * @param x2 X coordinate of the third point + * @param y2 Y coordinate of the third point + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setLinearGradientFunc} + */ +export type PaintLinearGradientFunc = ( + colorLine: ColorLine, + x0: number, + y0: number, + x1: number, + y1: number, + x2: number, + y2: number, + paintData: unknown, + userData: unknown, +) => void; + +/** + * A callback to paint a radial gradient everywhere within the current clip. The + * coordinates of the points are interpreted according to the current transform; see the + * OpenType spec + * [COLR](https://learn.microsoft.com/en-us/typography/opentype/spec/colr) + * section for details on how the points define the direction of the gradient, and +how to interpret the `colorLine`. + * @param colorLine color information for the gradient + * @param x0 X coordinate of the first circle's center + * @param y0 Y coordinate of the first circle's center + * @param r0 radius of the first circle + * @param x1 X coordinate of the second circle's center + * @param y1 Y coordinate of the second circle's center + * @param r1 radius of the second circle + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setRadialGradientFunc} + */ +export type PaintRadialGradientFunc = ( + colorLine: ColorLine, + x0: number, + y0: number, + r0: number, + x1: number, + y1: number, + r1: number, + paintData: unknown, + userData: unknown, +) => void; + +/** + * A callback to paint a sweep gradient everywhere within the current clip. The + * coordinates of the points are interpreted according to the current transform; see the + * OpenType spec + * [COLR](https://learn.microsoft.com/en-us/typography/opentype/spec/colr) + * section for details on how the points define the direction of the gradient, and +how to interpret the `colorLine`. + * @param colorLine color information for the gradient + * @param x0 X coordinate of the circle's center + * @param y0 Y coordinate of the circle's center + * @param startAngle the start angle, in radians + * @param endAngle the end angle, in radians + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setSweepGradientFunc} + */ +export type PaintSweepGradientFunc = ( + colorLine: ColorLine, + x0: number, + y0: number, + startAngle: number, + endAngle: number, + paintData: unknown, + userData: unknown, +) => void; + +/** + * A callback to use an intermediate surface for subsequent paint calls. The + * drawing is redirected to the intermediate surface until a matching call to + * {@link PaintFuncs.setPopGroupFunc}. + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setPushGroupFunc} + */ +export type PaintPushGroupFunc = ( + paintData: unknown, + userData: unknown, +) => void; + +/** + * A callback to undo the effect of a prior call to the + * {@link PaintFuncs.setPushGroupFunc} callback: it stops the redirection to the + * intermediate surface, then composites it on the previous surface using the + * compositing mode passed to this call. + * @param mode the {@link PaintCompositeMode} to use + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setPopGroupFunc} + */ +export type PaintPopGroupFunc = ( + mode: PaintCompositeMode, + paintData: unknown, + userData: unknown, +) => void; + +/** + * A callback to fetch a custom palette override color for `colorIndex`. + * + * Custom palette colors override colors from the font's selected color palette. + * It is not necessary to override all palette entries; return `undefined` for + * entries that should be taken from the font palette. + * + * This function might be called multiple times, but the custom palette is + * expected to remain unchanged for the duration of one {@link Font.paintGlyph} + * call. + * @param colorIndex color index to fetch + * @param paintData the data accompanying the paint functions in + * {@link Font.paintGlyph} + * @param userData the user data pointer passed to + * {@link PaintFuncs.setCustomPaletteColorFunc} + * @returns the custom color, or `undefined` to use the font palette. + */ +export type PaintCustomPaletteColorFunc = ( + colorIndex: number, + paintData: unknown, + userData: unknown, +) => Color | undefined; + +/** + * An object representing + * {@link https://harfbuzz.github.io/harfbuzz-hb-paint.html | HarfBuzz paint functions}. + * + * Glyph paint callbacks. + * + * The callbacks assume that the caller maintains a stack of current transforms, + * clips and intermediate surfaces, as evidenced by the pairs of push/pop + * callbacks. The push/pop calls will be properly nested, so it is fine to store + * the different kinds of object on a single stack. + * + * Not all callbacks are required for all kinds of glyphs. For rendering COLRv0 + * or non-color outline glyphs, the gradient callbacks are not needed, and the + * composite callback only needs to handle simple alpha compositing + * ({@link PaintCompositeMode.SRC_OVER}). + * + * The paint-image callback is only needed for glyphs with image blobs in the + * CBDT, sbix or SVG tables. + * + * The custom-palette-color callback is only necessary if you want to override + * colors from the font palette with custom colors. + */ +export class PaintFuncs { + 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, () => { + exports.hb_paint_funcs_destroy(ptr); + for (const ptr of funcPtrs) Module.removeFunction(ptr); + for (const ptr of userDataPtrs) remove_callback_data_pointer(ptr); + }); + } + + /** + * Sets the push-transform callback on the paint functions object. + * @param func The push-transform callback. + * @param userData Data to pass to `func`. + */ + setPushTransformFunc(func: PaintPushTransformFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + (funcs, paintData, xx, yx, xy, yy, dx, dy, user) => + func( + xx, + yx, + xy, + yy, + dx, + dy, + get_callback_data(paintData), + get_callback_data(user), + ), + "viiffffffi", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_push_transform_func( + this.ptr, + funcPtr, + userDataPtr, + 0, + ); + } + + /** + * Sets the pop-transform callback on the paint functions object. + * @param func The pop-transform callback. + * @param userData Data to pass to `func`. + */ + setPopTransformFunc(func: PaintPopTransformFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + (funcs, paintData, user) => + func(get_callback_data(paintData), get_callback_data(user)), + "viii", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_pop_transform_func( + this.ptr, + funcPtr, + userDataPtr, + 0, + ); + } + + /** + * Sets the color-glyph callback on the paint functions object. + * @param func The color-glyph callback. + * @param userData Data to pass to `func`. + */ + setColorGlyphFunc(func: PaintColorGlyphFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + (funcs, paintData, glyph, fontPtr, user) => + func( + glyph, + new Font(fontPtr), + get_callback_data(paintData), + get_callback_data(user), + ) + ? 1 + : 0, + "iiiiii", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_color_glyph_func( + this.ptr, + funcPtr, + userDataPtr, + 0, + ); + } + + /** + * Sets the push-clip-glyph callback on the paint functions object. + * @param func The push-clip-glyph callback. + * @param userData Data to pass to `func`. + */ + setPushClipGlyphFunc(func: PaintPushClipGlyphFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + (funcs, paintData, glyph, fontPtr, user) => + func( + glyph, + new Font(fontPtr), + get_callback_data(paintData), + get_callback_data(user), + ), + "viiiii", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_push_clip_glyph_func( + this.ptr, + funcPtr, + userDataPtr, + 0, + ); + } + + /** + * Sets the push-clip-rectangle callback on the paint functions object. + * @param func The push-clip-rectangle callback. + * @param userData Data to pass to `func`. + */ + setPushClipRectangleFunc( + func: PaintPushClipRectangleFunc, + userData?: unknown, + ): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + (funcs, paintData, xmin, ymin, xmax, ymax, user) => + func( + xmin, + ymin, + xmax, + ymax, + get_callback_data(paintData), + get_callback_data(user), + ), + "viiffffi", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_push_clip_rectangle_func( + this.ptr, + funcPtr, + userDataPtr, + 0, + ); + } + + /** + * Sets the pop-clip callback on the paint functions object. + * @param func The pop-clip callback. + * @param userData Data to pass to `func`. + */ + setPopClipFunc(func: PaintPopClipFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + (funcs, paintData, user) => + func(get_callback_data(paintData), get_callback_data(user)), + "viii", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_pop_clip_func(this.ptr, funcPtr, userDataPtr, 0); + } + + /** + * Sets the paint-color callback on the paint functions object. + * @param func The paint-color callback. + * @param userData Data to pass to `func`. + */ + setColorFunc(func: PaintColorFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + (funcs, paintData, isForeground, color, user) => + func( + isForeground !== 0, + color_from_int(color), + get_callback_data(paintData), + get_callback_data(user), + ), + "viiiii", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_color_func(this.ptr, funcPtr, userDataPtr, 0); + } + + /** + * Sets the paint-image callback on the paint functions object. + * @param func The paint-image callback. + * @param userData Data to pass to `func`. + */ + setImageFunc(func: PaintImageFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + ( + funcs, + paintData, + blob, + width, + height, + format, + slant, + extentsPtr, + user, + ) => { + const length = exports.hb_blob_get_length(blob); + const dataPtr = exports.hb_blob_get_data(blob, 0); + const image = Module.HEAPU8.subarray(dataPtr, dataPtr + length); + let extents: GlyphExtents | undefined; + if (extentsPtr) { + extents = { + xBearing: Module.HEAP32[extentsPtr / 4], + yBearing: Module.HEAP32[extentsPtr / 4 + 1], + width: Module.HEAP32[extentsPtr / 4 + 2], + height: Module.HEAP32[extentsPtr / 4 + 3], + }; + } + return func( + image, + width, + height, + hb_untag(format), + extents, + get_callback_data(paintData), + get_callback_data(user), + ) + ? 1 + : 0; + }, + "iiiiiiifii", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_image_func(this.ptr, funcPtr, userDataPtr, 0); + } + + /** + * Sets the linear-gradient callback on the paint functions object. + * @param func The linear-gradient callback. + * @param userData Data to pass to `func`. + */ + setLinearGradientFunc( + func: PaintLinearGradientFunc, + userData?: unknown, + ): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + (funcs, paintData, colorLine, x0, y0, x1, y1, x2, y2, user) => + func( + decode_color_line(colorLine), + x0, + y0, + x1, + y1, + x2, + y2, + get_callback_data(paintData), + get_callback_data(user), + ), + "viiiffffffi", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_linear_gradient_func( + this.ptr, + funcPtr, + userDataPtr, + 0, + ); + } + + /** + * Sets the radial-gradient callback on the paint functions object. + * @param func The radial-gradient callback. + * @param userData Data to pass to `func`. + */ + setRadialGradientFunc( + func: PaintRadialGradientFunc, + userData?: unknown, + ): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + (funcs, paintData, colorLine, x0, y0, r0, x1, y1, r1, user) => + func( + decode_color_line(colorLine), + x0, + y0, + r0, + x1, + y1, + r1, + get_callback_data(paintData), + get_callback_data(user), + ), + "viiiffffffi", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_radial_gradient_func( + this.ptr, + funcPtr, + userDataPtr, + 0, + ); + } + + /** + * Sets the sweep-gradient callback on the paint functions object. + * @param func The sweep-gradient callback. + * @param userData Data to pass to `func`. + */ + setSweepGradientFunc(func: PaintSweepGradientFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + (funcs, paintData, colorLine, x0, y0, startAngle, endAngle, user) => + func( + decode_color_line(colorLine), + x0, + y0, + startAngle, + endAngle, + get_callback_data(paintData), + get_callback_data(user), + ), + "viiiffffi", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_sweep_gradient_func( + this.ptr, + funcPtr, + userDataPtr, + 0, + ); + } + + /** + * Sets the push-group callback on the paint functions object. + * @param func The push-group callback. + * @param userData Data to pass to `func`. + */ + setPushGroupFunc(func: PaintPushGroupFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + (funcs, paintData, user) => + func(get_callback_data(paintData), get_callback_data(user)), + "viii", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_push_group_func( + this.ptr, + funcPtr, + userDataPtr, + 0, + ); + } + + /** + * Sets the pop-group callback on the paint functions object. + * @param func The pop-group callback. + * @param userData Data to pass to `func`. + */ + setPopGroupFunc(func: PaintPopGroupFunc, userData?: unknown): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + (funcs, paintData, mode, user) => + func( + mode as PaintCompositeMode, + get_callback_data(paintData), + get_callback_data(user), + ), + "viiii", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_pop_group_func( + this.ptr, + funcPtr, + userDataPtr, + 0, + ); + } + + /** + * Sets the custom-palette-color callback on the paint functions object. + * @param func The custom-palette-color callback. + * @param userData Data to pass to `func`. + */ + setCustomPaletteColorFunc( + func: PaintCustomPaletteColorFunc, + userData?: unknown, + ): void { + const userDataPtr = register_callback_data_pointer(userData); + this.userDataPtrs.push(userDataPtr); + const funcPtr = Module.addFunction( + (funcs, paintData, colorIndex, colorPtr, user) => { + const color = func( + colorIndex, + get_callback_data(paintData), + get_callback_data(user), + ); + if (color !== undefined) { + Module.HEAPU32[colorPtr / 4] = color_to_int(color); + return 1; + } + return 0; + }, + "iiiiii", + ); + this.funcPtrs.push(funcPtr); + exports.hb_paint_funcs_set_custom_palette_color_func( + this.ptr, + funcPtr, + userDataPtr, + 0, + ); + } +} diff --git a/src/types.ts b/src/types.ts index 66c1371..4563f66 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,165 @@ export interface SvgPathCommand { values: number[]; } +/** A color value, eight bits per channel RGB plus alpha transparency. */ +export interface Color { + /** Red channel value. */ + red: number; + /** Green channel value. */ + green: number; + /** Blue channel value. */ + blue: number; + /** Alpha channel value. */ + alpha: number; +} + +/** + * Information about a color stop on a {@link ColorLine}. + * + * Color lines typically have offsets ranging between 0 and 1, but that is not + * required. + * + * The `isForeground` and `color` fields have the same semantics as in + * {@link PaintFuncs.setColorFunc}. + * + * Note: despite `color` being unpremultiplied here, interpolation in gradients + * shall happen in premultiplied space. See the OpenType spec + * [COLR](https://learn.microsoft.com/en-us/typography/opentype/spec/colr) + * section for details. + */ +export interface ColorStop { + /** The offset of the color stop. */ + offset: number; + /** Whether the color is the foreground. */ + isForeground: boolean; + /** The color, unpremultiplied. */ + color: Color; +} + +/** Color information for a gradient. */ +export interface ColorLine { + /** How color values outside the defined interval are determined. */ + extend: PaintExtend; + /** The color stops. */ + colorStops: ColorStop[]; +} + +/** + * The values of this enumeration determine how color values outside the minimum + * and maximum defined offset on a {@link ColorLine} are determined. + * + * See the OpenType spec + * [COLR](https://learn.microsoft.com/en-us/typography/opentype/spec/colr) section + * for details. + */ +export const PaintExtend = { + /** Outside the defined interval, the color of the closest color stop is used. */ + PAD: 0, + /** The color line is repeated over repeated multiples of the defined interval */ + REPEAT: 1, + /** + * The color line is repeated over repeated intervals, as for the repeat mode. + * However, in each repeated interval, the ordering of color stops is the + * reverse of the adjacent interval. + */ + REFLECT: 2, +} as const; +export type PaintExtend = ValueOf; + +/** + * The values of this enumeration describe the compositing modes that can be used + * when combining temporary redirected drawing with the backdrop. + * + * See the OpenType spec + * [COLR](https://learn.microsoft.com/en-us/typography/opentype/spec/colr) section + * for details. + */ +export const PaintCompositeMode = { + /** clear destination layer (bounded) */ + CLEAR: 0, + /** replace destination layer (bounded) */ + SRC: 1, + /** ignore the source */ + DEST: 2, + /** draw source layer on top of destination layer (bounded) */ + SRC_OVER: 3, + /** draw destination on top of source */ + DEST_OVER: 4, + /** draw source where there was destination content (unbounded) */ + SRC_IN: 5, + /** leave destination only where there was source content (unbounded) */ + DEST_IN: 6, + /** draw source where there was no destination content (unbounded) */ + SRC_OUT: 7, + /** leave destination only where there was no source content */ + DEST_OUT: 8, + /** draw source on top of destination content and only there */ + SRC_ATOP: 9, + /** leave destination on top of source content and only there (unbounded) */ + DEST_ATOP: 10, + /** source and destination are shown where there is only one of them */ + XOR: 11, + /** source and destination layers are accumulated */ + PLUS: 12, + /** + * source and destination are complemented and multiplied. This causes the + * result to be at least as light as the lighter inputs. + */ + SCREEN: 13, + /** multiplies or screens, depending on the lightness of the destination color. */ + OVERLAY: 14, + /** + * replaces the destination with the source if it is darker, otherwise keeps the + * source. + */ + DARKEN: 15, + /** + * replaces the destination with the source if it is lighter, otherwise keeps the + * source. + */ + LIGHTEN: 16, + /** brightens the destination color to reflect the source color. */ + COLOR_DODGE: 17, + /** darkens the destination color to reflect the source color. */ + COLOR_BURN: 18, + /** Multiplies or screens, dependent on source color. */ + HARD_LIGHT: 19, + /** Darkens or lightens, dependent on source color. */ + SOFT_LIGHT: 20, + /** Takes the difference of the source and destination color. */ + DIFFERENCE: 21, + /** Produces an effect similar to difference, but with lower contrast. */ + EXCLUSION: 22, + /** + * source and destination layers are multiplied. This causes the result to be at + * least as dark as the darker inputs. + */ + MULTIPLY: 23, + /** + * Creates a color with the hue of the source and the saturation and luminosity + * of the target. + */ + HSL_HUE: 24, + /** + * Creates a color with the saturation of the source and the hue and luminosity + * of the target. Painting with this mode onto a gray area produces no change. + */ + HSL_SATURATION: 25, + /** + * Creates a color with the hue and saturation of the source and the luminosity + * of the target. This preserves the gray levels of the target and is useful for + * coloring monochrome images or tinting color images. + */ + HSL_COLOR: 26, + /** + * Creates a color with the luminosity of the source and the hue and saturation + * of the target. This produces an inverse effect to + * {@link PaintCompositeMode.HSL_COLOR}. + */ + HSL_LUMINOSITY: 27, +} as const; +export type PaintCompositeMode = ValueOf; + export interface AxisInfo { min: number; default: number; diff --git a/test/fonts/test_glyphs-glyf_colr_1.ttf b/test/fonts/test_glyphs-glyf_colr_1.ttf new file mode 100644 index 0000000000000000000000000000000000000000..fb071d748ff8f290e622a10e620e923027388e15 GIT binary patch literal 21568 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 literal 0 HcmV?d00001 diff --git a/test/index.test.js b/test/index.test.js index 86d877e..9e3a248 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -846,6 +846,98 @@ describe("DrawFuncs", function () { }); }); +describe("PaintFuncs", function () { + function colrFont() { + const blob = new hb.Blob( + fs.readFileSync( + path.join(__dirname, "fonts/test_glyphs-glyf_colr_1.ttf"), + ), + ); + return new hb.Font(new hb.Face(blob)); + } + + function recordPaint(font, gid) { + const ops = []; + const funcs = new hb.PaintFuncs(); + funcs.setPushTransformFunc((xx, yx, xy, yy, dx, dy) => + ops.push(["pushTransform", xx, yx, xy, yy, dx, dy]), + ); + funcs.setPopTransformFunc(() => ops.push(["popTransform"])); + funcs.setPushClipGlyphFunc((glyph) => ops.push(["pushClipGlyph", glyph])); + funcs.setPushClipRectangleFunc((xmin, ymin, xmax, ymax) => + ops.push(["pushClipRectangle", xmin, ymin, xmax, ymax]), + ); + funcs.setPopClipFunc(() => ops.push(["popClip"])); + funcs.setColorFunc((isForeground, color) => + ops.push(["color", isForeground, color]), + ); + funcs.setLinearGradientFunc((colorLine) => + ops.push(["linearGradient", colorLine]), + ); + funcs.setColorGlyphFunc(() => false); + font.paintGlyph(gid, funcs); + return ops; + } + + it("paintGlyph falls back to the outline for a non-color glyph", function () { + expect(recordPaint(colrFont(), 2)).to.deep.equal([ + ["pushClipGlyph", 2], + ["color", true, { red: 0, green: 0, blue: 0, alpha: 255 }], + ["popClip"], + ]); + }); + + it("paintGlyphOrFail reports whether a glyph has color", function () { + const font = colrFont(); + const funcs = new hb.PaintFuncs(); + funcs.setPushTransformFunc(() => {}); + funcs.setPopTransformFunc(() => {}); + funcs.setPushClipGlyphFunc(() => {}); + funcs.setPushClipRectangleFunc(() => {}); + funcs.setPopClipFunc(() => {}); + funcs.setLinearGradientFunc(() => {}); + expect(font.paintGlyphOrFail(10, funcs)).to.equal(true); + expect(font.paintGlyphOrFail(2, funcs)).to.equal(false); + }); + + it("paintGlyph reports a linear-gradient COLR glyph", function () { + const ops = recordPaint(colrFont(), 10); + const gradient = ops.find((op) => op[0] === "linearGradient"); + expect(gradient).to.not.equal(undefined); + const colorLine = gradient[1]; + expect(colorLine.extend).to.equal(hb.PaintExtend.REPEAT); + expect(colorLine.colorStops).to.deep.equal([ + { + offset: 0, + isForeground: false, + color: { red: 255, green: 0, blue: 0, alpha: 255 }, + }, + { + offset: 1.5, + isForeground: false, + color: { red: 0, green: 0, blue: 255, alpha: 255 }, + }, + ]); + }); + + it("paintGlyph forwards paintData and per-callback userData", function () { + const font = colrFont(); + const funcs = new hb.PaintFuncs(); + const seen = []; + const colorUser = { cb: "color" }; + const state = { state: 1 }; + funcs.setPushClipGlyphFunc(() => {}); + funcs.setPopClipFunc(() => {}); + funcs.setColorFunc( + (isForeground, color, paintData, userData) => + seen.push([paintData, userData]), + colorUser, + ); + font.paintGlyph(2, funcs, state); + expect(seen).to.deep.equal([[state, colorUser]]); + }); +}); + describe("FontFuncs", function () { it("setGlyphExtentsFunc", function () { let blob = new hb.Blob( From 6b18dfbbc9fbead3401ee44bced8a931ad9453c1 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Wed, 24 Jun 2026 18:43:30 +0300 Subject: [PATCH 04/11] Add Face.hasColorPalettes() --- harfbuzz.symbols | 1 + src/face.ts | 8 ++++++++ test/index.test.js | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/harfbuzz.symbols b/harfbuzz.symbols index 537f4a5..7d8dae0 100644 --- a/harfbuzz.symbols +++ b/harfbuzz.symbols @@ -95,6 +95,7 @@ _hb_font_paint_glyph _hb_font_paint_glyph_or_fail _hb_color_line_get_color_stops _hb_color_line_get_extend +_hb_ot_color_has_palettes _hb_glyph_info_get_glyph_flags _hb_language_from_string _hb_ot_layout_table_get_script_tags diff --git a/src/face.ts b/src/face.ts index 9f92f58..d302d70 100644 --- a/src/face.ts +++ b/src/face.ts @@ -398,4 +398,12 @@ export class Face { Module.stackRestore(sp); return names; } + + /** + * Tests whether a face includes a `CPAL` color-palette table. + * @returns `true` if data found, `false` otherwise. + */ + hasColorPalettes(): boolean { + return !!exports.hb_ot_color_has_palettes(this.ptr); + } } diff --git a/test/index.test.js b/test/index.test.js index 9e3a248..a8902aa 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -360,6 +360,25 @@ describe("Face", function () { paramUiLabelNameIds: [259, 260], }); }); + + it("hasColorPalettes reports a CPAL table", function () { + const colr = new hb.Face( + new hb.Blob( + fs.readFileSync( + path.join(__dirname, "fonts/test_glyphs-glyf_colr_1.ttf"), + ), + ), + ); + const noto = new hb.Face( + new hb.Blob( + fs.readFileSync( + path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf"), + ), + ), + ); + expect(colr.hasColorPalettes()).to.equal(true); + expect(noto.hasColorPalettes()).to.equal(false); + }); }); describe("Font", function () { From bb5c9262ccd95808e52f6856db13b749a3c84a4e Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Wed, 24 Jun 2026 19:25:13 +0300 Subject: [PATCH 05/11] Add Face.getColorPalettes() --- harfbuzz.symbols | 5 +++ src/face.ts | 58 +++++++++++++++++++++++++++++++++- src/types.ts | 66 ++++++++++++++++++++++++++++++++++++-- test/index.test.js | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 3 deletions(-) diff --git a/harfbuzz.symbols b/harfbuzz.symbols index 7d8dae0..676dd62 100644 --- a/harfbuzz.symbols +++ b/harfbuzz.symbols @@ -96,6 +96,11 @@ _hb_font_paint_glyph_or_fail _hb_color_line_get_color_stops _hb_color_line_get_extend _hb_ot_color_has_palettes +_hb_ot_color_palette_get_count +_hb_ot_color_palette_get_colors +_hb_ot_color_palette_get_flags +_hb_ot_color_palette_get_name_id +_hb_ot_color_palette_color_get_name_id _hb_glyph_info_get_glyph_flags _hb_language_from_string _hb_ot_layout_table_get_script_tags diff --git a/src/face.ts b/src/face.ts index d302d70..a95f0ce 100644 --- a/src/face.ts +++ b/src/face.ts @@ -9,9 +9,16 @@ import { language_to_string, language_from_string, typed_array_from_set, + color_from_int, type ValueOf, } from "./helpers"; -import type { AxisInfo, NameEntry, FeatureNameIds } from "./types"; +import type { + AxisInfo, + NameEntry, + FeatureNameIds, + PaletteColor, + ColorPalette, +} from "./types"; import type { Blob } from "./blob"; const HB_OT_NAME_ID_INVALID = 0xffff; @@ -406,4 +413,53 @@ export class Face { hasColorPalettes(): boolean { return !!exports.hb_ot_color_has_palettes(this.ptr); } + + /** + * Fetches the color palettes in the face's `CPAL` table. + * @returns An array of the face's {@link ColorPalette | color palettes}. + */ + getColorPalettes(): ColorPalette[] { + const count = exports.hb_ot_color_palette_get_count(this.ptr); + const palettes: ColorPalette[] = []; + const sp = Module.stackSave(); + const countPtr = Module.stackAlloc(4); + const colorsPtr = Module.stackAlloc(STATIC_ARRAY_SIZE * 4); + for (let i = 0; i < count; i++) { + const colors: PaletteColor[] = []; + let startOffset = 0; + let colorCount = STATIC_ARRAY_SIZE; + while (colorCount === STATIC_ARRAY_SIZE) { + Module.HEAPU32[countPtr / 4] = colorCount; + exports.hb_ot_color_palette_get_colors( + this.ptr, + i, + startOffset, + countPtr, + colorsPtr, + ); + colorCount = Module.HEAPU32[countPtr / 4]; + for (let j = 0; j < colorCount; j++) { + const color: PaletteColor = color_from_int( + Module.HEAPU32[colorsPtr / 4 + j], + ); + const nameId = exports.hb_ot_color_palette_color_get_name_id( + this.ptr, + startOffset + j, + ); + if (nameId != HB_OT_NAME_ID_INVALID) color.nameId = nameId; + colors.push(color); + } + startOffset += colorCount; + } + const palette: ColorPalette = { + colors, + flags: exports.hb_ot_color_palette_get_flags(this.ptr, i), + }; + const nameId = exports.hb_ot_color_palette_get_name_id(this.ptr, i); + if (nameId != HB_OT_NAME_ID_INVALID) palette.nameId = nameId; + palettes.push(palette); + } + Module.stackRestore(sp); + return palettes; + } } diff --git a/src/types.ts b/src/types.ts index 4563f66..8080ce2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -76,12 +76,74 @@ export interface ColorStop { /** Color information for a gradient. */ export interface ColorLine { - /** How color values outside the defined interval are determined. */ + /** The extend mode of the color line. */ extend: PaintExtend; - /** The color stops. */ + /** + * The color stops. + * + * Note that due to variations being applied, the color stops may be out of + * order; it is the caller's responsibility to ensure they are sorted by their + * offset before they are used. + */ colorStops: ColorStop[]; } +/** Flags that describe the properties of a color palette. */ +export const ColorPaletteFlags = { + /** + * Default indicating that there is nothing special to note about a color + * palette. + */ + DEFAULT: 0x00000000, + /** + * Flag indicating that the color palette is appropriate to use when displaying + * the font on a light background such as white. + */ + USABLE_WITH_LIGHT_BACKGROUND: 0x00000001, + /** + * Flag indicating that the color palette is appropriate to use when displaying + * the font on a dark background such as black. + */ + USABLE_WITH_DARK_BACKGROUND: 0x00000002, +} as const; +export type ColorPaletteFlags = ValueOf; + +/** A {@link Color} from a font's color palette, together with its name ID. */ +export interface PaletteColor extends Color { + /** + * The `name` table Name ID that provides display names for the color, or + * `undefined` if the color has no name. + * + * Display names can be generic (e.g., "Background") or specific (e.g., "Eye + * color"). + */ + nameId?: number; +} + +/** A color palette from a font's `CPAL` table. */ +export interface ColorPalette { + /** + * The colors that make up the palette. The RGBA values are unpremultiplied; + * see the OpenType spec + * [CPAL](https://learn.microsoft.com/en-us/typography/opentype/spec/cpal) + * section for details. + */ + colors: PaletteColor[]; + /** + * The `name` table Name ID that provides display names for the palette, or + * `undefined` if the palette has no name. + * + * Palette display names can be generic (e.g., "Default") or provide specific, + * themed names (e.g., "Spring", "Summer", "Fall", and "Winter"). + */ + nameId?: number; + /** + * The flags defined for the palette, a combination of + * {@link ColorPaletteFlags} values. + */ + flags: number; +} + /** * The values of this enumeration determine how color values outside the minimum * and maximum defined offset on a {@link ColorLine} are determined. diff --git a/test/index.test.js b/test/index.test.js index a8902aa..9e78aa4 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -379,6 +379,85 @@ describe("Face", function () { expect(colr.hasColorPalettes()).to.equal(true); expect(noto.hasColorPalettes()).to.equal(false); }); + + it("getColorPalettes returns the CPAL palettes", function () { + const colr = new hb.Face( + new hb.Blob( + fs.readFileSync( + path.join(__dirname, "fonts/test_glyphs-glyf_colr_1.ttf"), + ), + ), + ); + const palettes = colr.getColorPalettes(); + expect(palettes).to.have.lengthOf(3); + expect(palettes).to.deep.equal([ + { + colors: [ + { red: 255, green: 0, blue: 0, alpha: 255 }, + { red: 255, green: 165, blue: 0, alpha: 255 }, + { red: 255, green: 255, blue: 0, alpha: 255 }, + { red: 0, green: 128, blue: 0, alpha: 255 }, + { red: 0, green: 0, blue: 255, alpha: 255 }, + { red: 75, green: 0, blue: 130, alpha: 255 }, + { red: 238, green: 130, blue: 238, alpha: 255 }, + { red: 250, green: 240, blue: 230, alpha: 255 }, + { red: 47, green: 79, blue: 79, alpha: 255 }, + { red: 255, green: 255, blue: 255, alpha: 255 }, + { red: 0, green: 0, blue: 0, alpha: 255 }, + { red: 104, green: 199, blue: 232, alpha: 255 }, + { red: 255, green: 220, blue: 1, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + ], + flags: hb.ColorPaletteFlags.DEFAULT, + }, + { + colors: [ + { red: 42, green: 41, blue: 74, alpha: 255 }, + { red: 36, green: 65, blue: 99, alpha: 255 }, + { red: 27, green: 99, blue: 136, alpha: 255 }, + { red: 21, green: 125, blue: 163, alpha: 255 }, + { red: 14, green: 154, blue: 194, alpha: 255 }, + { red: 5, green: 190, blue: 232, alpha: 255 }, + { red: 0, green: 212, blue: 255, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + ], + flags: hb.ColorPaletteFlags.USABLE_WITH_DARK_BACKGROUND, + }, + { + colors: [ + { red: 252, green: 113, blue: 24, alpha: 255 }, + { red: 251, green: 129, blue: 21, alpha: 255 }, + { red: 250, green: 149, blue: 17, alpha: 255 }, + { red: 250, green: 168, blue: 13, alpha: 255 }, + { red: 249, green: 190, blue: 9, alpha: 255 }, + { red: 248, green: 211, blue: 4, alpha: 255 }, + { red: 248, green: 231, blue: 0, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + { red: 128, green: 128, blue: 128, alpha: 255 }, + ], + flags: hb.ColorPaletteFlags.USABLE_WITH_LIGHT_BACKGROUND, + }, + ]); + const noto = new hb.Face( + new hb.Blob( + fs.readFileSync( + path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf"), + ), + ), + ); + expect(noto.getColorPalettes()).to.deep.equal([]); + }); }); describe("Font", function () { From 9d122c0491b3fe5335b00d2bb41e8f3991671155 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Wed, 24 Jun 2026 19:28:01 +0300 Subject: [PATCH 06/11] Add Face.hasColorLayers() --- harfbuzz.symbols | 1 + src/face.ts | 8 ++++++++ test/index.test.js | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/harfbuzz.symbols b/harfbuzz.symbols index 676dd62..392df15 100644 --- a/harfbuzz.symbols +++ b/harfbuzz.symbols @@ -101,6 +101,7 @@ _hb_ot_color_palette_get_colors _hb_ot_color_palette_get_flags _hb_ot_color_palette_get_name_id _hb_ot_color_palette_color_get_name_id +_hb_ot_color_has_layers _hb_glyph_info_get_glyph_flags _hb_language_from_string _hb_ot_layout_table_get_script_tags diff --git a/src/face.ts b/src/face.ts index a95f0ce..b5c3eb9 100644 --- a/src/face.ts +++ b/src/face.ts @@ -462,4 +462,12 @@ export class Face { Module.stackRestore(sp); return palettes; } + + /** + * Tests whether a face includes a `COLR` table with data according to COLRv0. + * @returns `true` if data found, `false` otherwise. + */ + hasColorLayers(): boolean { + return !!exports.hb_ot_color_has_layers(this.ptr); + } } diff --git a/test/index.test.js b/test/index.test.js index 9e78aa4..857216e 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -458,6 +458,25 @@ describe("Face", function () { ); expect(noto.getColorPalettes()).to.deep.equal([]); }); + + it("hasColorLayers reports a COLRv0 table", function () { + const colr = new hb.Face( + new hb.Blob( + fs.readFileSync( + path.join(__dirname, "fonts/test_glyphs-glyf_colr_1.ttf"), + ), + ), + ); + const noto = new hb.Face( + new hb.Blob( + fs.readFileSync( + path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf"), + ), + ), + ); + expect(colr.hasColorLayers()).to.equal(true); + expect(noto.hasColorLayers()).to.equal(false); + }); }); describe("Font", function () { From 2d27416fccebaa196ed3842bc12273987ea0d91d Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Wed, 24 Jun 2026 19:57:43 +0300 Subject: [PATCH 07/11] Add Face.getGlyphColorLayers() --- harfbuzz.symbols | 1 + src/face.ts | 37 +++++++++++++++++++++++++++++++++++++ src/types.ts | 13 +++++++++++++ test/index.test.js | 21 +++++++++++++++++++++ 4 files changed, 72 insertions(+) diff --git a/harfbuzz.symbols b/harfbuzz.symbols index 392df15..4d0f4f0 100644 --- a/harfbuzz.symbols +++ b/harfbuzz.symbols @@ -102,6 +102,7 @@ _hb_ot_color_palette_get_flags _hb_ot_color_palette_get_name_id _hb_ot_color_palette_color_get_name_id _hb_ot_color_has_layers +_hb_ot_color_glyph_get_layers _hb_glyph_info_get_glyph_flags _hb_language_from_string _hb_ot_layout_table_get_script_tags diff --git a/src/face.ts b/src/face.ts index b5c3eb9..742ab78 100644 --- a/src/face.ts +++ b/src/face.ts @@ -18,6 +18,7 @@ import type { FeatureNameIds, PaletteColor, ColorPalette, + ColorLayer, } from "./types"; import type { Blob } from "./blob"; @@ -470,4 +471,40 @@ export class Face { hasColorLayers(): boolean { return !!exports.hb_ot_color_has_layers(this.ptr); } + + /** + * Fetches a list of all color layers for the specified glyph index in the + * specified face. + * @param glyph The glyph index to query. + * @returns An array of the glyph's {@link ColorLayer | color layers}. + */ + getGlyphColorLayers(glyph: number): ColorLayer[] { + const layers: ColorLayer[] = []; + const sp = Module.stackSave(); + const countPtr = Module.stackAlloc(4); + const layersPtr = Module.stackAlloc(STATIC_ARRAY_SIZE * 8); + let startOffset = 0; + let layerCount = STATIC_ARRAY_SIZE; + while (layerCount === STATIC_ARRAY_SIZE) { + Module.HEAPU32[countPtr / 4] = layerCount; + exports.hb_ot_color_glyph_get_layers( + this.ptr, + glyph, + startOffset, + countPtr, + layersPtr, + ); + layerCount = Module.HEAPU32[countPtr / 4]; + for (let i = 0; i < layerCount; i++) { + const o = layersPtr / 4 + i * 2; + const layer: ColorLayer = { glyph: Module.HEAPU32[o] }; + const colorIndex = Module.HEAPU32[o + 1]; + if (colorIndex != 0xffff) layer.colorIndex = colorIndex; + layers.push(layer); + } + startOffset += layerCount; + } + Module.stackRestore(sp); + return layers; + } } diff --git a/src/types.ts b/src/types.ts index 8080ce2..87fda65 100644 --- a/src/types.ts +++ b/src/types.ts @@ -144,6 +144,19 @@ export interface ColorPalette { flags: number; } +/** + * Pairs of glyph and color index. + * + * A color index of `undefined` does not refer to a palette color, but indicates + * that the foreground color should be used. + */ +export interface ColorLayer { + /** The glyph ID of the layer. */ + glyph: number; + /** The palette color index of the layer. */ + colorIndex?: number; +} + /** * The values of this enumeration determine how color values outside the minimum * and maximum defined offset on a {@link ColorLine} are determined. diff --git a/test/index.test.js b/test/index.test.js index 857216e..b60d869 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -477,6 +477,27 @@ describe("Face", function () { expect(colr.hasColorLayers()).to.equal(true); expect(noto.hasColorLayers()).to.equal(false); }); + + it("getGlyphColorLayers returns a COLRv0 glyph's layers", function () { + const colr = new hb.Face( + new hb.Blob( + fs.readFileSync( + path.join(__dirname, "fonts/test_glyphs-glyf_colr_1.ttf"), + ), + ), + ); + expect(colr.getGlyphColorLayers(168)).to.deep.equal([ + { glyph: 176, colorIndex: 0 }, + { glyph: 175, colorIndex: 1 }, + { glyph: 174, colorIndex: 2 }, + { glyph: 173, colorIndex: 3 }, + { glyph: 172, colorIndex: 4 }, + { glyph: 171, colorIndex: 5 }, + { glyph: 170, colorIndex: 6 }, + { glyph: 5, colorIndex: 10 }, + ]); + expect(colr.getGlyphColorLayers(0)).to.deep.equal([]); + }); }); describe("Font", function () { From 73bd041f092cccaf5a55d98546548733a49f8476 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Wed, 24 Jun 2026 20:04:18 +0300 Subject: [PATCH 08/11] Add Face.hasColorPaint() --- harfbuzz.symbols | 1 + src/face.ts | 8 ++++++++ test/index.test.js | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/harfbuzz.symbols b/harfbuzz.symbols index 4d0f4f0..d5589a4 100644 --- a/harfbuzz.symbols +++ b/harfbuzz.symbols @@ -103,6 +103,7 @@ _hb_ot_color_palette_get_name_id _hb_ot_color_palette_color_get_name_id _hb_ot_color_has_layers _hb_ot_color_glyph_get_layers +_hb_ot_color_has_paint _hb_glyph_info_get_glyph_flags _hb_language_from_string _hb_ot_layout_table_get_script_tags diff --git a/src/face.ts b/src/face.ts index 742ab78..8bf023e 100644 --- a/src/face.ts +++ b/src/face.ts @@ -507,4 +507,12 @@ export class Face { Module.stackRestore(sp); return layers; } + + /** + * Tests whether a face includes a `COLR` table with data according to COLRv1. + * @returns `true` if data found, `false` otherwise. + */ + hasColorPaint(): boolean { + return !!exports.hb_ot_color_has_paint(this.ptr); + } } diff --git a/test/index.test.js b/test/index.test.js index b60d869..6961346 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -498,6 +498,25 @@ describe("Face", function () { ]); expect(colr.getGlyphColorLayers(0)).to.deep.equal([]); }); + + it("hasColorPaint reports a COLRv1 table", function () { + const colr = new hb.Face( + new hb.Blob( + fs.readFileSync( + path.join(__dirname, "fonts/test_glyphs-glyf_colr_1.ttf"), + ), + ), + ); + const noto = new hb.Face( + new hb.Blob( + fs.readFileSync( + path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf"), + ), + ), + ); + expect(colr.hasColorPaint()).to.equal(true); + expect(noto.hasColorPaint()).to.equal(false); + }); }); describe("Font", function () { From f0e9b19c4181c758cc9c7a8a75ac607ea6151d99 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Wed, 24 Jun 2026 20:08:12 +0300 Subject: [PATCH 09/11] Add Face.glyphHasColorPaint() --- harfbuzz.symbols | 1 + src/face.ts | 9 +++++++++ test/index.test.js | 13 +++++++++++++ 3 files changed, 23 insertions(+) diff --git a/harfbuzz.symbols b/harfbuzz.symbols index d5589a4..d2052bf 100644 --- a/harfbuzz.symbols +++ b/harfbuzz.symbols @@ -104,6 +104,7 @@ _hb_ot_color_palette_color_get_name_id _hb_ot_color_has_layers _hb_ot_color_glyph_get_layers _hb_ot_color_has_paint +_hb_ot_color_glyph_has_paint _hb_glyph_info_get_glyph_flags _hb_language_from_string _hb_ot_layout_table_get_script_tags diff --git a/src/face.ts b/src/face.ts index 8bf023e..df23e40 100644 --- a/src/face.ts +++ b/src/face.ts @@ -515,4 +515,13 @@ export class Face { hasColorPaint(): boolean { return !!exports.hb_ot_color_has_paint(this.ptr); } + + /** + * Tests whether a face includes COLRv1 paint data for a glyph. + * @param glyph The glyph index to query. + * @returns `true` if data found, `false` otherwise. + */ + glyphHasColorPaint(glyph: number): boolean { + return !!exports.hb_ot_color_glyph_has_paint(this.ptr, glyph); + } } diff --git a/test/index.test.js b/test/index.test.js index 6961346..2829e8a 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -517,6 +517,19 @@ describe("Face", function () { expect(colr.hasColorPaint()).to.equal(true); expect(noto.hasColorPaint()).to.equal(false); }); + + it("glyphHasColorPaint reports COLRv1 paint for a glyph", function () { + const colr = new hb.Face( + new hb.Blob( + fs.readFileSync( + path.join(__dirname, "fonts/test_glyphs-glyf_colr_1.ttf"), + ), + ), + ); + // Glyph 10 has a COLRv1 paint; glyph 2 is a plain outline. + expect(colr.glyphHasColorPaint(10)).to.equal(true); + expect(colr.glyphHasColorPaint(2)).to.equal(false); + }); }); describe("Font", function () { From 73454deee40b708e7e571e155b7b851582f45148 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Wed, 24 Jun 2026 20:12:11 +0300 Subject: [PATCH 10/11] Add Face.hasColorPng() --- harfbuzz.symbols | 1 + src/face.ts | 9 +++++++++ test/fonts/chromacheck-cbdt.ttf | Bin 0 -> 792 bytes test/index.test.js | 17 +++++++++++++++++ 4 files changed, 27 insertions(+) create mode 100644 test/fonts/chromacheck-cbdt.ttf diff --git a/harfbuzz.symbols b/harfbuzz.symbols index d2052bf..a6a75c8 100644 --- a/harfbuzz.symbols +++ b/harfbuzz.symbols @@ -105,6 +105,7 @@ _hb_ot_color_has_layers _hb_ot_color_glyph_get_layers _hb_ot_color_has_paint _hb_ot_color_glyph_has_paint +_hb_ot_color_has_png _hb_glyph_info_get_glyph_flags _hb_language_from_string _hb_ot_layout_table_get_script_tags diff --git a/src/face.ts b/src/face.ts index df23e40..2a984c0 100644 --- a/src/face.ts +++ b/src/face.ts @@ -524,4 +524,13 @@ export class Face { glyphHasColorPaint(glyph: number): boolean { return !!exports.hb_ot_color_glyph_has_paint(this.ptr, glyph); } + + /** + * Tests whether a face has PNG glyph images (either in `CBDT` or `sbix` + * tables). + * @returns `true` if data found, `false` otherwise. + */ + hasColorPng(): boolean { + return !!exports.hb_ot_color_has_png(this.ptr); + } } diff --git a/test/fonts/chromacheck-cbdt.ttf b/test/fonts/chromacheck-cbdt.ttf new file mode 100644 index 0000000000000000000000000000000000000000..100c01a973f9b680cfa5b8fef45f2ee428b2f3c0 GIT binary patch literal 792 zcmaJ~%$B_Tl(%<#eVcfbIJkamc;xiY^Va}1NgT#WR7JKiM*Nm|Xl~)*taG++ zfcPtMPq`G$IPYI Date: Wed, 24 Jun 2026 20:16:54 +0300 Subject: [PATCH 11/11] Add Font.getGlyphColorPng() --- harfbuzz.symbols | 1 + src/font.ts | 21 +++++++++++++++++++++ test/index.test.js | 18 ++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/harfbuzz.symbols b/harfbuzz.symbols index a6a75c8..153d514 100644 --- a/harfbuzz.symbols +++ b/harfbuzz.symbols @@ -106,6 +106,7 @@ _hb_ot_color_glyph_get_layers _hb_ot_color_has_paint _hb_ot_color_glyph_has_paint _hb_ot_color_has_png +_hb_ot_color_glyph_reference_png _hb_glyph_info_get_glyph_flags _hb_language_from_string _hb_ot_layout_table_get_script_tags diff --git a/src/font.ts b/src/font.ts index ad43292..e9c0f79 100644 --- a/src/font.ts +++ b/src/font.ts @@ -355,6 +355,27 @@ export class Font { } } + /** + * Fetches the PNG image for a glyph. + * + * To get an optimally sized PNG blob, the PPEM values must be set on the font. + * If PPEM is unset, the blob returned will be the largest PNG available. + * @param glyphId A glyph index. + * @returns The PNG image for the glyph, or `undefined` if the glyph has no PNG + * image. + */ + getGlyphColorPng(glyphId: number): Uint8Array | undefined { + const blob = exports.hb_ot_color_glyph_reference_png(this.ptr, glyphId); + const length = exports.hb_blob_get_length(blob); + let png: Uint8Array | undefined; + if (length) { + const dataPtr = exports.hb_blob_get_data(blob, 0); + png = Module.HEAPU8.slice(dataPtr, dataPtr + length); + } + exports.hb_blob_destroy(blob); + return png; + } + /** * Return a glyph as an SVG path string. * @param glyphId ID of the requested glyph in the font. diff --git a/test/index.test.js b/test/index.test.js index f752b13..c99021f 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -905,6 +905,24 @@ describe("Font", function () { { type: "Z", values: [] }, ]); }); + + it("getGlyphColorPng returns a glyph's PNG image", function () { + const font = new hb.Font( + new hb.Face( + new hb.Blob( + fs.readFileSync(path.join(__dirname, "fonts/chromacheck-cbdt.ttf")), + ), + ), + ); + const png = font.getGlyphColorPng(1); + expect(png).to.be.instanceOf(Uint8Array); + // PNG file signature. + expect([...png.slice(0, 8)]).to.deep.equal([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + // A glyph with no PNG image returns undefined. + expect(font.getGlyphColorPng(0)).to.equal(undefined); + }); }); describe("DrawFuncs", function () {