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 642b207..153d514 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 @@ -74,6 +75,38 @@ _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_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_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_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/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/face.ts b/src/face.ts index 9f92f58..2a984c0 100644 --- a/src/face.ts +++ b/src/face.ts @@ -9,9 +9,17 @@ 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, + ColorLayer, +} from "./types"; import type { Blob } from "./blob"; const HB_OT_NAME_ID_INVALID = 0xffff; @@ -398,4 +406,131 @@ 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); + } + + /** + * 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; + } + + /** + * 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); + } + + /** + * 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; + } + + /** + * 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); + } + + /** + * 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); + } + + /** + * 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/src/font.ts b/src/font.ts index c5141fe..e9c0f79 100644 --- a/src/font.ts +++ b/src/font.ts @@ -1,17 +1,22 @@ import { Module, exports, - registry, + track, STATIC_ARRAY_SIZE, hb_tag, utf8_ptr_to_string, 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"; /** @@ -78,14 +83,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; } /** @@ -97,9 +144,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. @@ -114,19 +158,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. */ @@ -195,109 +227,164 @@ export class Font { } /** - * Return a glyph as an SVG path string. - * @param glyphId ID of the requested glyph in the font. - * @returns SVG path data string. + * 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. */ - 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"; - }; + 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); + } + } - 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, + /** + * 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, ); - exports.hb_draw_funcs_set_quadratic_to_func( - ds.drawFuncsPtr, - ds.quadToPtr, - 0, - 0, + } finally { + remove_callback_data_pointer(drawDataPtr); + } + } + + /** + * 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), ); - exports.hb_draw_funcs_set_close_path_func( - ds.drawFuncsPtr, - ds.closePathPtr, - 0, - 0, + } 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); + } + } + + /** + * 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; + } - ds.pathBuffer = ""; - exports.hb_font_draw_glyph(this.ptr, glyphId, ds.drawFuncsPtr, 0); - return ds.pathBuffer; + /** + * Return a glyph as an SVG path string. + * @param glyphId ID of the requested glyph in the font. + * @returns SVG path data string. + */ + glyphToPath(glyphId: number): string { + const path: string[] = []; + this.drawGlyph(glyphId, getPathDrawFuncs(), path); + return path.join(""); } /** @@ -487,15 +574,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/src/helpers.ts b/src/helpers.ts index 3348935..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; @@ -149,3 +149,42 @@ 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); +} + +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 ffaa2a5..d8c7a16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ export * from "./blob"; 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..87fda65 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,240 @@ 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 { + /** The extend mode of the color line. */ + extend: PaintExtend; + /** + * 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; +} + +/** + * 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. + * + * 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/chromacheck-cbdt.ttf b/test/fonts/chromacheck-cbdt.ttf new file mode 100644 index 0000000..100c01a Binary files /dev/null and b/test/fonts/chromacheck-cbdt.ttf differ 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 0000000..fb071d7 Binary files /dev/null and b/test/fonts/test_glyphs-glyf_colr_1.ttf differ diff --git a/test/index.test.js b/test/index.test.js index 56f49ae..c99021f 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -360,6 +360,193 @@ 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); + }); + + 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([]); + }); + + 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); + }); + + 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([]); + }); + + 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); + }); + + 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); + }); + + it("hasColorPng reports CBDT/sbix PNG glyph images", function () { + const cbdt = new hb.Face( + new hb.Blob( + fs.readFileSync(path.join(__dirname, "fonts/chromacheck-cbdt.ttf")), + ), + ); + const noto = new hb.Face( + new hb.Blob( + fs.readFileSync( + path.join(__dirname, "fonts/noto/NotoSans-Regular.ttf"), + ), + ), + ); + expect(cbdt.hasColorPng()).to.equal(true); + expect(noto.hasColorPng()).to.equal(false); + }); }); describe("Font", function () { @@ -698,6 +885,262 @@ 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: [] }, + ]); + }); + + 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 () { + 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("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 () {