diff --git a/packages/webcanvas/src/core/Font.ts b/packages/webcanvas/src/core/Font.ts index 70ae8967..71474d1d 100644 --- a/packages/webcanvas/src/core/Font.ts +++ b/packages/webcanvas/src/core/Font.ts @@ -5,7 +5,8 @@ import { getModule } from '../interop/module'; import { checkResult } from '../common/errors'; -import { FontRegistry } from '../utils/FontRegistry'; +import type { FontProvider } from './FontProvider'; +import { FontsourceProvider } from '../providers/FontsourceProvider'; /** * Supported font file types. @@ -22,34 +23,9 @@ export interface LoadFontOptions { type?: FontType; } -/** - * Options for loading a font from fontsource CDN. - * @category Font - */ -export interface FontsourceOptions { - /** - * Font weight to load. - * @default 400 - */ - weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; - /** - * Font style to load. - * @default 'normal' - */ - style?: 'normal' | 'italic'; - /** - * Unicode subset to load. - * @default 'latin' - */ - subset?: string; -} - -/** @internal */ -const FONTSOURCE_CDN = 'https://cdn.jsdelivr.net/fontsource/fonts'; - /** * Font loader class for managing custom fonts. - * Fonts are loaded globally and can be referenced by name in Text objects. + * Fonts are loaded globally and can be referenced by name in {@link Text} objects. * @category Font * * @example @@ -64,32 +40,71 @@ const FONTSOURCE_CDN = 'https://cdn.jsdelivr.net/fontsource/fonts'; * * @example * ```typescript - * // Auto-load from fontsource CDN (no manual fetch needed) + * // Auto-load from the configured font provider (fontsource CDN by default) * await TVG.Font.load('poppins'); * await TVG.Font.load('roboto', { weight: 700, style: 'italic' }); * * const text = new TVG.Text(); * text.font('poppins').fontSize(48).text('Hello!').fill(50, 50, 50); * ``` + * + * @example + * ```typescript + * // Use a custom font provider + * TVG.Font.provider({ + * fetch: async (name) => { + * const res = await fetch(`/my-fonts/${name}.ttf`); + * return { data: new Uint8Array(await res.arrayBuffer()), type: 'ttf' }; + * } + * }); + * + * await TVG.Font.load('my-font'); + * ``` */ export class Font { + private static _provider: FontProvider = new FontsourceProvider(); + private static readonly _loaded = new Set(); + /** - * Load font from raw data (Uint8Array) + * Set the font provider used when calling `Font.load()` without raw data. + * + * The default provider fetches from the [fontsource](https://fontsource.org) CDN. + * Replace it to load fonts from your own CDN or any other source. + * + * @param provider - A {@link FontProvider} implementation + * @beta + * + * @example + * ```typescript + * TVG.Font.provider({ + * fetch: async (name) => { + * const res = await fetch(`https://my-cdn.com/fonts/${name}.ttf`); + * return { data: new Uint8Array(await res.arrayBuffer()), type: 'ttf' }; + * } + * }); + * ``` + */ + public static provider(provider: FontProvider): void { + Font._provider = provider; + } + + + /** + * Load font from raw data. * @param name - Unique name to identify this font - * @param data - Raw font data + * @param data - Raw font binary data * @param options - Load options */ public static load(name: string, data: Uint8Array, options?: LoadFontOptions): void; /** - * Auto-load a font from the fontsource CDN + * Auto-load a font using the configured font provider. * - * The font name must match a package on fontsource (e.g. `'poppins'`, `'roboto'`). - * Calling this multiple times with the same arguments is safe — the CDN is only - * fetched once regardless of how many times you call it. + * With the default {@link FontsourceProvider}, the name must match a fontsource + * package slug (e.g. `'poppins'`, `'open-sans'`). * - * @param name - Font slug as it appears on fontsource (e.g. `'poppins'`, `'open-sans'`) - * @param options - Weight, style, and subset options + * @param name - Font name passed to the provider + * @param options - Provider-specific options (see {@link FontsourceOptions} for defaults) * * @example * ```typescript @@ -98,11 +113,11 @@ export class Font { * await TVG.Font.load('noto-sans', { subset: 'latin-ext' }); * ``` */ - public static load(name: string, options?: FontsourceOptions): Promise; + public static load(name: string, options?: Record): Promise; public static load( name: string, - dataOrOptions?: Uint8Array | FontsourceOptions, + dataOrOptions?: Uint8Array | Record, loadOptions?: LoadFontOptions, ): void | Promise { if (dataOrOptions instanceof Uint8Array) { @@ -110,29 +125,21 @@ export class Font { return; } - const opts = { - weight: 400, - style: 'normal' as const, - subset: 'latin', - ...dataOrOptions, - }; - - const key = `${name.toLowerCase()}:${opts.subset}:${opts.weight}:${opts.style}`; - - return FontRegistry.ensure(key, async () => { - const slug = name.toLowerCase().replace(/\s+/g, '-'); - const url = `${FONTSOURCE_CDN}/${slug}@latest/${opts.subset}-${opts.weight}-${opts.style}.ttf`; - - const res = await fetch(url); - if (!res.ok) { - throw new Error( - `Font "${name}" could not be loaded from fontsource (HTTP ${res.status}). ` + - `Check that the font exists at https://fontsource.org/fonts/${slug}`, - ); - } - - Font._loadData(name, new Uint8Array(await res.arrayBuffer()), { type: 'ttf' }); - }); + if (Font._loaded.has(name)) { + return Promise.resolve(); + } + + Font._loaded.add(name); + + return Font._provider + .fetch(name, dataOrOptions) + .then((result) => { + Font._loadData(name, result.data, { type: result.type }); + }) + .catch((err) => { + Font._loaded.delete(name); + throw err; + }); } /** @@ -140,6 +147,7 @@ export class Font { * @param name - Font name to unload */ public static unload(name: string): void { + Font._loaded.delete(name); const Module = getModule(); const namePtr = Module._malloc(name.length + 1); diff --git a/packages/webcanvas/src/core/FontProvider.ts b/packages/webcanvas/src/core/FontProvider.ts new file mode 100644 index 00000000..ab1a7033 --- /dev/null +++ b/packages/webcanvas/src/core/FontProvider.ts @@ -0,0 +1,49 @@ +/** + * Font provider abstraction for pluggable font sources. + * @category Font + */ + +import type { FontType } from './Font'; + +/** + * Result returned by a {@link FontProvider} after fetching font data. + * @category Font + */ +export interface FontProviderResult { + /** Raw font binary data */ + data: Uint8Array; + /** Font format */ + type: FontType; +} + +/** + * Interface for pluggable font sources. + * + * A font provider resolves a font name into binary font data. + * Implement this interface to load fonts from any source — a self-hosted + * CDN, a local server, or any custom storage. + * + * @category Font + * @beta + * + * @example + * ```typescript + * TVG.Font.provider({ + * fetch: async (name) => { + * const res = await fetch(`/my-fonts/${name}.ttf`); + * return { data: new Uint8Array(await res.arrayBuffer()), type: 'ttf' }; + * } + * }); + * + * await TVG.Font.load('my-font'); + * ``` + */ +export interface FontProvider { + /** + * Fetch font data by name. + * @param name - Font name as provided by the caller + * @param options - Provider-specific options + * @returns Font binary data and format + */ + fetch(name: string, options?: Record): Promise; +} diff --git a/packages/webcanvas/src/core/Text.ts b/packages/webcanvas/src/core/Text.ts index e1b6386c..a99d4860 100644 --- a/packages/webcanvas/src/core/Text.ts +++ b/packages/webcanvas/src/core/Text.ts @@ -87,9 +87,9 @@ export class Text extends Paint { } /** - * Set the font to use for this text - * @param name - Font name (previously loaded via Font.load()) or "default" - */ + * Set the font to use for this text. + * @param name - Font name + */ public font(name: string): this { const Module = getModule(); diff --git a/packages/webcanvas/src/index.ts b/packages/webcanvas/src/index.ts index c3ae67ae..ad0b35da 100644 --- a/packages/webcanvas/src/index.ts +++ b/packages/webcanvas/src/index.ts @@ -49,6 +49,7 @@ import { LinearGradient } from './core/LinearGradient'; import { RadialGradient } from './core/RadialGradient'; import { Font } from './core/Font'; import { Accessor } from './core/Accessor'; +import { FontsourceProvider } from './providers/FontsourceProvider'; import { ThorVGResultCode, ThorVGError, setGlobalErrorHandler, handleError, type ErrorHandler } from './common/errors'; import * as constants from './common/constants'; import type { RendererType } from './common/constants'; @@ -286,7 +287,7 @@ const ThorVG = { export default ThorVG; // Named exports for advanced usage -export { init, Canvas, Shape, Scene, Picture, Text, Animation, LinearGradient, RadialGradient, Font, Accessor, constants, ThorVGResultCode, ThorVGError }; +export { init, Canvas, Shape, Scene, Picture, Text, Animation, LinearGradient, RadialGradient, Font, FontsourceProvider, Accessor, constants, ThorVGResultCode, ThorVGError }; // Re-export types export type { CanvasOptions } from './core/Canvas'; @@ -296,7 +297,9 @@ export type { RectOptions, StrokeOptions } from './core/Shape'; export type { LoadDataOptions, PictureSize } from './core/Picture'; export type { TextLayout, TextOutline } from './core/Text'; export type { AnimationInfo, AnimationSegment } from './core/Animation'; -export type { LoadFontOptions, FontType, FontsourceOptions } from './core/Font'; +export type { LoadFontOptions, FontType } from './core/Font'; +export type { FontsourceOptions } from './providers/FontsourceProvider'; +export type { FontProvider, FontProviderResult } from './core/FontProvider'; export type { ColorStop } from './core/Fill'; /** @category Canvas */ export type { RendererType } from './common/constants'; diff --git a/packages/webcanvas/src/providers/FontsourceProvider.ts b/packages/webcanvas/src/providers/FontsourceProvider.ts new file mode 100644 index 00000000..5d1f0573 --- /dev/null +++ b/packages/webcanvas/src/providers/FontsourceProvider.ts @@ -0,0 +1,71 @@ +/** + * Fontsource CDN font provider. + * @category Font + */ + +import type { FontProvider, FontProviderResult } from '../core/FontProvider'; + +const FONTSOURCE_CDN = 'https://cdn.jsdelivr.net/fontsource/fonts'; + +/** + * Options for loading a font from the fontsource CDN. + * @category Font + */ +export interface FontsourceOptions { + /** + * Font weight to load. + * @default 400 + */ + weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; + /** + * Font style to load. + * @default 'normal' + */ + style?: 'normal' | 'italic'; + /** + * Unicode subset to load. + * @default 'latin' + */ + subset?: string; +} + +/** + * Font provider that fetches fonts from the [fontsource](https://fontsource.org) CDN. + * + * This is the default provider used by {@link Font.load} when no raw data is supplied. + * + * @category Font + * @beta + * + * @example + * ```typescript + * // Restore to default (if you previously swapped it out) + * TVG.Font.provider(new FontsourceProvider()); + * ``` + */ +export class FontsourceProvider implements FontProvider { + async fetch(name: string, options?: FontsourceOptions): Promise { + const opts = { + weight: 400 as const, + style: 'normal' as const, + subset: 'latin', + ...options, + }; + + const slug = name.toLowerCase().replace(/\s+/g, '-'); + const url = `${FONTSOURCE_CDN}/${slug}@latest/${opts.subset}-${opts.weight}-${opts.style}.ttf`; + + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `Font "${name}" could not be loaded from fontsource (HTTP ${res.status}). ` + + `Check that the font exists at https://fontsource.org/fonts/${slug}`, + ); + } + + return { + data: new Uint8Array(await res.arrayBuffer()), + type: 'ttf', + }; + } +} diff --git a/packages/webcanvas/src/utils/FontRegistry.ts b/packages/webcanvas/src/utils/FontRegistry.ts deleted file mode 100644 index 442ea54b..00000000 --- a/packages/webcanvas/src/utils/FontRegistry.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Font registry for tracking fontsource CDN fetch state. - * @internal - */ -export class FontRegistry { - private static readonly _loaded = new Set(); - private static readonly _pending = new Map>(); - - /** - * Ensure a font is loaded once. - */ - static ensure(key: string, loader: () => Promise): Promise { - if (this._loaded.has(key)) { - return Promise.resolve(); - } - - if (this._pending.has(key)) { - return this._pending.get(key)!; - } - - const promise = loader() - .then(() => { - this._loaded.add(key); - this._pending.delete(key); - }) - .catch((err) => { - // Remove on failure so the caller can retry if desired - this._pending.delete(key); - throw err; - }); - - this._pending.set(key, promise); - return promise; - } -} diff --git a/playground/lib/examples/font-provider.ts b/playground/lib/examples/font-provider.ts new file mode 100644 index 00000000..32ec372a --- /dev/null +++ b/playground/lib/examples/font-provider.ts @@ -0,0 +1,125 @@ +import { ShowcaseExample } from './types'; + +export const fontProviderExample: ShowcaseExample = { + id: 'font-provider', + title: 'Font Provider', + description: 'Use a custom font provider to load fonts from any source', + category: 'text', + useDarkCanvas: true, + thumbnail: '/assets/font-provider-thumbnail.png', + code: `import { init } from '@thorvg/webcanvas'; + +const TVG = await init({ + renderer: 'gl', + locateFile: (path) => '/webcanvas/' + path.split('/').pop() +}); + +const canvas = new TVG.Canvas('#canvas', { + width: 600, + height: 600, +}); + +(async () => { + await Promise.all([ + TVG.Font.load('roboto'), + TVG.Font.load('poppins', { weight: 700 }), + TVG.Font.load('merriweather'), + TVG.Font.load('oswald', { weight: 600 }), + TVG.Font.load('dancing-script', { weight: 700 }), + ]); + + const bg = new TVG.Shape(); + bg.appendRect(0, 0, 600, 600); + bg.fill(22, 22, 30); + canvas.add(bg); + + const header = new TVG.Text(); + header.font('poppins') + .fontSize(30) + .text('Font Auto-Loading') + .fill(255, 255, 255) + .translate(32, 36); + canvas.add(header); + + const subheader = new TVG.Text(); + subheader.font('roboto') + .fontSize(13) + .text('Fonts fetched from fontsource CDN') + .fill(130, 130, 160) + .translate(32, 82); + canvas.add(subheader); + + // divider + const divider = new TVG.Shape(); + divider.appendRect(32, 108, 536, 1); + divider.fill(60, 60, 80); + canvas.add(divider); + + const rows = [ + { + label: "Font.load('roboto') - weight 400, style normal", + sample: 'The quick brown fox jumps over the lazy dog', + font: 'roboto', + size: 20, + y: 128, + }, + { + label: "Font.load('poppins', { weight: 700 }) - bold", + sample: 'Sphinx of black quartz, judge my vow.', + font: 'poppins', + size: 22, + y: 210, + }, + { + label: "Font.load('merriweather') - serif, weight 400", + sample: 'How vexingly quick daft zebras jump!', + font: 'merriweather', + size: 19, + y: 292, + }, + { + label: "Font.load('oswald', { weight: 600 }) - condensed", + sample: 'PACK MY BOX WITH FIVE DOZEN LIQUOR JUGS', + font: 'oswald', + size: 22, + y: 374, + }, + { + label: "Font.load('dancing-script', { weight: 700 }) - script", + sample: 'Waltz, bad nymph, for quick jigs vex!', + font: 'dancing-script', + size: 24, + y: 456, + }, + ]; + + for (const row of rows) { + const labelText = new TVG.Text(); + labelText.font('roboto') + .fontSize(11) + .text(row.label) + .fill(100, 100, 140) + .translate(32, row.y); + canvas.add(labelText); + + const sampleText = new TVG.Text(); + sampleText.font(row.font) + .fontSize(row.size) + .text(row.sample) + .fill(230, 230, 245) + .translate(32, row.y + 20); + canvas.add(sampleText); + + // row separator + if (row !== rows[rows.length - 1]) { + const sep = new TVG.Shape(); + sep.appendRect(32, row.y + 56, 536, 1); + sep.fill(45, 45, 60); + canvas.add(sep); + } + } + + canvas.render(); +})(); +`, +}; diff --git a/playground/lib/examples/index.ts b/playground/lib/examples/index.ts index 5a699be8..9ad7a2bd 100644 --- a/playground/lib/examples/index.ts +++ b/playground/lib/examples/index.ts @@ -17,6 +17,7 @@ import { boundingBoxExample } from './bounding-box'; import { transformStaticExample } from './transform-static'; import { transformAnimationExample } from './transform-animation'; import { textStaticExample } from './text-static'; +import { fontProviderExample } from './font-provider'; import { textAnimationExample } from './text-animation'; import { textLayoutExample } from './text-layout'; import { textLineWrapExample } from './text-line-wrap'; @@ -89,6 +90,7 @@ export const showcaseExamples: ShowcaseExample[] = [ sceneEffectsExample, viewportExample, // Text (alphabetically sorted by title) + fontProviderExample, textAnimationExample, textLayoutExample, textLineWrapExample, diff --git a/playground/public/assets/font-provider-thumbnail.png b/playground/public/assets/font-provider-thumbnail.png new file mode 100644 index 00000000..b4505d51 Binary files /dev/null and b/playground/public/assets/font-provider-thumbnail.png differ