@basementuniverse/image-font 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -119,6 +119,238 @@ font.drawText(context, 'ABC', 0, 0, options);
119
119
 
120
120
  See './example/example.html' for a full example.
121
121
 
122
+ ## Glyph layout
123
+
124
+ For effects that require per-character control — typewriter reveals, path-following, per-character animations — you can separate the *layout* phase from the *draw* phase using `layoutText` and `drawLayout`.
125
+
126
+ `layoutText` runs the same line-splitting, alignment, and cursor-advance logic as `drawText`, but instead of drawing immediately it returns a `GlyphLayout` object containing a `glyphs` array. Each element of the array is a `GlyphInfo` object describing one character in the rendered output. You can inspect and mutate these objects freely before passing the layout to `drawLayout`.
127
+
128
+ `drawText` is unchanged and continues to work as before.
129
+
130
+ ### Typewriter reveal
131
+
132
+ ```ts
133
+ const layout = font.layoutText('HELLO WORLD', x, y, options);
134
+
135
+ // In your update loop, track how many characters have been revealed:
136
+ layout.glyphs.forEach((glyph, i) => {
137
+ glyph.visible = i < revealedCount;
138
+ });
139
+
140
+ // In your draw loop:
141
+ font.drawLayout(context, layout);
142
+ ```
143
+
144
+ ### Per-character animation (wave)
145
+
146
+ ```ts
147
+ const layout = font.layoutText('HELLO WORLD', x, y, options);
148
+
149
+ // Each frame, apply a sine-wave vertical offset to every glyph:
150
+ layout.glyphs.forEach((glyph, i) => {
151
+ glyph.offset = { x: 0, y: Math.sin(Date.now() / 200 + i * 0.5) * 4 };
152
+ });
153
+
154
+ font.drawLayout(context, layout);
155
+ ```
156
+
157
+ ### Per-character colour gradient
158
+
159
+ ```ts
160
+ const layout = font.layoutText('RAINBOW', x, y, options);
161
+ const hueStep = 360 / layout.glyphs.length;
162
+
163
+ layout.glyphs.forEach((glyph, i) => {
164
+ glyph.color = `hsl(${i * hueStep}, 100%, 50%)`;
165
+ });
166
+
167
+ font.drawLayout(context, layout);
168
+ ```
169
+
170
+ ### Text along a path
171
+
172
+ ```ts
173
+ const layout = font.layoutText('ALONG A CURVE', 0, 0, options);
174
+
175
+ // Place each glyph at a point on a path, rotating it to follow the tangent:
176
+ let distance = 0;
177
+ layout.glyphs.forEach(glyph => {
178
+ const { point, tangentAngle } = samplePath(path, distance + glyph.advance / 2);
179
+ glyph.position = { x: point.x - glyph.size.x / 2, y: point.y - glyph.size.y / 2 };
180
+ glyph.rotation = tangentAngle;
181
+ distance += glyph.advance;
182
+ });
183
+
184
+ font.drawLayout(context, layout);
185
+ ```
186
+
187
+ ### `preDraw` / `postDraw` callbacks
188
+
189
+ For effects that require additional canvas operations around individual characters (outlines, drop shadows, debug bounds), use the `preDraw` and `postDraw` callbacks on each `GlyphInfo`. The context is saved before and restored after each glyph when any per-glyph property is set.
190
+
191
+ ```ts
192
+ const layout = font.layoutText('OUTLINED', x, y, options);
193
+
194
+ layout.glyphs.forEach(glyph => {
195
+ glyph.postDraw = (ctx, g) => {
196
+ ctx.strokeStyle = 'black';
197
+ ctx.lineWidth = 2;
198
+ ctx.strokeRect(g.bounds.x, g.bounds.y, g.bounds.width, g.bounds.height);
199
+ };
200
+ });
201
+
202
+ font.drawLayout(context, layout);
203
+ ```
204
+
205
+ ## GlyphLayout and GlyphInfo types
206
+
207
+ ```ts
208
+ type GlyphLayout = {
209
+ /** The original text string that was laid out */
210
+ text: string;
211
+
212
+ /** The x position originally passed to layoutText */
213
+ x: number;
214
+
215
+ /** The y position originally passed to layoutText */
216
+ y: number;
217
+
218
+ /** The rendering options used to produce this layout */
219
+ options?: ImageFontRenderingOptions;
220
+
221
+ /**
222
+ * The total bounding box of the laid-out text in canvas pixels
223
+ *
224
+ * Equivalent to the value returned by measureText with the same arguments
225
+ */
226
+ bounds: vec2;
227
+
228
+ /**
229
+ * Per-glyph layout information, one entry per character in the rendered
230
+ * output (after wrapping/truncation)
231
+ *
232
+ * Glyphs with no atlas texture (e.g. spaces) are included with
233
+ * visible: false so that index-based effects remain aligned with the string
234
+ */
235
+ glyphs: GlyphInfo[];
236
+ };
237
+
238
+ type GlyphInfo = {
239
+ /** The character this glyph represents */
240
+ character: string;
241
+
242
+ /**
243
+ * The sequential index of this glyph in the layout (0-based)
244
+ *
245
+ * This is the glyph's position in the rendered output (after any wrapping
246
+ * or truncation applied by the overflow options), not necessarily its index
247
+ * in the original input string
248
+ */
249
+ index: number;
250
+
251
+ /** The line index this glyph is on (0-based) */
252
+ lineIndex: number;
253
+
254
+ /**
255
+ * The top-left draw position of this glyph in canvas pixels
256
+ *
257
+ * Computed from the cursor position minus any configured per-character or
258
+ * global offset, with the font and render scale already applied
259
+ */
260
+ position: vec2;
261
+
262
+ /**
263
+ * The size of this glyph's atlas tile in canvas pixels (post-scale)
264
+ *
265
+ * Will be (0, 0) for glyphs that have no texture in the atlas (e.g. spaces)
266
+ */
267
+ size: vec2;
268
+
269
+ /**
270
+ * The advance width of this glyph in canvas pixels
271
+ *
272
+ * This is how far the cursor moves after this glyph, including kerning
273
+ */
274
+ advance: number;
275
+
276
+ /**
277
+ * The bounding box of this glyph in canvas pixels
278
+ *
279
+ * Equivalent to { x: position.x, y: position.y, width: size.x, height: size.y }.
280
+ * Useful for hit-testing, debug overlays, and path-following calculations
281
+ */
282
+ bounds: { x: number; y: number; width: number; height: number };
283
+
284
+ /**
285
+ * Whether this glyph should be rendered
286
+ *
287
+ * Automatically false for characters with no texture in the atlas (e.g.
288
+ * spaces). Set to false manually to hide individual glyphs (typewriter
289
+ * reveal etc.)
290
+ */
291
+ visible: boolean;
292
+
293
+ /**
294
+ * Additional per-glyph draw offset in canvas pixels, applied on top of the
295
+ * computed position
296
+ */
297
+ offset?: vec2;
298
+
299
+ /**
300
+ * Per-glyph scale multiplier, applied around the pivot point
301
+ *
302
+ * Multiplied on top of the font and render scale already baked into
303
+ * position and size
304
+ */
305
+ scale?: number;
306
+
307
+ /**
308
+ * Rotation in radians, applied around the pivot point
309
+ */
310
+ rotation?: number;
311
+
312
+ /**
313
+ * The pivot point for rotation and scale transforms, in canvas pixels
314
+ *
315
+ * Defaults to the centre of the glyph's bounding box (after any per-glyph
316
+ * offset is applied)
317
+ */
318
+ pivot?: vec2;
319
+
320
+ /**
321
+ * Opacity multiplier (0–1), multiplied against the context's current
322
+ * globalAlpha at the time drawLayout is called
323
+ */
324
+ alpha?: number;
325
+
326
+ /**
327
+ * Per-glyph color override
328
+ *
329
+ * Overrides the color set in the layout's ImageFontRenderingOptions for this
330
+ * glyph only. The coloringMode and coloringFunction from the layout options
331
+ * are still used
332
+ */
333
+ color?: string;
334
+
335
+ /**
336
+ * Called immediately before this glyph is drawn, after all canvas transforms
337
+ * have been applied
338
+ *
339
+ * The context is saved and restored around each glyph when any per-glyph
340
+ * property (alpha, rotation, scale, preDraw, postDraw) is set
341
+ */
342
+ preDraw?: (context: CanvasRenderingContext2D, glyph: GlyphInfo) => void;
343
+
344
+ /**
345
+ * Called immediately after this glyph is drawn, before the context is
346
+ * restored
347
+ *
348
+ * Useful for outlines, drop shadows, or debug bounding boxes
349
+ */
350
+ postDraw?: (context: CanvasRenderingContext2D, glyph: GlyphInfo) => void;
351
+ };
352
+ ```
353
+
122
354
  ## ImageFont configuration
123
355
 
124
356
  ```ts
@@ -179,6 +411,8 @@ Also, each character configuration should have a `textureAtlasPosition: vec2` pr
179
411
  ## Rendering and measuring options
180
412
 
181
413
  ```ts
414
+ type OverflowMode = 'word-wrap' | 'character-wrap' | 'hidden' | 'ellipsis' | 'none';
415
+
182
416
  type ImageFontRenderingOptions = {
183
417
  /**
184
418
  * The scale factor to apply to the font when rendering
@@ -245,9 +479,72 @@ type ImageFontRenderingOptions = {
245
479
  texture: HTMLCanvasElement,
246
480
  color: string
247
481
  ) => void;
482
+
483
+ /**
484
+ * Maximum width of the text in pixels (pre-scale)
485
+ *
486
+ * When set, text that exceeds this width will be handled according to the
487
+ * overflow option. Alignment works correctly regardless of maxWidth.
488
+ *
489
+ * If not specified, text will not be wrapped or clipped.
490
+ */
491
+ maxWidth?: number;
492
+
493
+ /**
494
+ * How to handle text that exceeds maxWidth
495
+ *
496
+ * - 'word-wrap': wrap at word boundaries (falls back to character-wrap for
497
+ * single words that exceed maxWidth)
498
+ * - 'character-wrap': wrap at character boundaries
499
+ * - 'hidden': text is cut off at maxWidth
500
+ * - 'ellipsis': text is cut off and an ellipsis string is appended
501
+ * - 'none': maxWidth is ignored
502
+ *
503
+ * Default is 'word-wrap'
504
+ */
505
+ overflow?: OverflowMode;
506
+
507
+ /**
508
+ * The string to use as an ellipsis when overflow is 'ellipsis'
509
+ *
510
+ * Default is '...'
511
+ */
512
+ ellipsisString?: string;
513
+
514
+ /**
515
+ * The height of each line in pixels (pre-scale), scaled by the active scale
516
+ * factor
517
+ *
518
+ * If not specified, defaults to the tallest character defined in the font,
519
+ * giving consistent line spacing regardless of the actual string content.
520
+ */
521
+ lineHeight?: number;
248
522
  };
249
523
  ```
250
524
 
525
+ ### Multi-line text example
526
+
527
+ ```ts
528
+ // Word-wrap within 200px, with custom line height
529
+ font.drawText(context, 'HELLO WORLD THIS IS A LONG STRING', x, y, {
530
+ maxWidth: 200,
531
+ overflow: 'word-wrap',
532
+ lineHeight: 50,
533
+ align: 'center',
534
+ baseLine: 'top',
535
+ });
536
+
537
+ // Truncate with ellipsis
538
+ font.drawText(context, 'HELLO WORLD', x, y, {
539
+ maxWidth: 100,
540
+ overflow: 'ellipsis',
541
+ ellipsisString: '...',
542
+ });
543
+
544
+ // measureText respects wrapping and returns the actual rendered bounding box
545
+ const size = font.measureText('HELLO WORLD', { maxWidth: 100, overflow: 'word-wrap' });
546
+ ```
547
+
251
548
  ## Utility scripts
252
549
 
253
550
  It can be rather tedious creating data for large image-fonts with lots of characters, so I vibe-coded a utility script to help with that in './example/generate-data.html'.
package/build/index.d.ts CHANGED
@@ -57,6 +57,7 @@ export type ImageFontCharacterConfig = {
57
57
  height?: number;
58
58
  };
59
59
  export type ColoringMode = 'multiply' | 'overlay' | 'hue' | 'custom';
60
+ export type OverflowMode = 'word-wrap' | 'character-wrap' | 'hidden' | 'ellipsis' | 'none';
60
61
  export type ImageFontRenderingOptions = {
61
62
  /**
62
63
  * The scale factor to apply to the font when rendering
@@ -112,6 +113,191 @@ export type ImageFontRenderingOptions = {
112
113
  * 'multiply'
113
114
  */
114
115
  coloringFunction?: (context: CanvasRenderingContext2D, texture: HTMLCanvasElement, color: string) => void;
116
+ /**
117
+ * Maximum width of the text in pixels (pre-scale)
118
+ *
119
+ * When set, text that exceeds this width will be handled according to the
120
+ * overflow option
121
+ *
122
+ * If not specified, text will not be wrapped or clipped
123
+ */
124
+ maxWidth?: number;
125
+ /**
126
+ * How to handle text that exceeds maxWidth
127
+ *
128
+ * - 'word-wrap': wrap at word boundaries (falls back to character-wrap for
129
+ * single words that exceed maxWidth)
130
+ * - 'character-wrap': wrap at character boundaries
131
+ * - 'hidden': text is cut off at maxWidth
132
+ * - 'ellipsis': text is cut off and an ellipsis string is appended
133
+ * - 'none': maxWidth is ignored
134
+ *
135
+ * Default is 'word-wrap'
136
+ */
137
+ overflow?: OverflowMode;
138
+ /**
139
+ * The string to use as an ellipsis when overflow is 'ellipsis'
140
+ *
141
+ * Default is '...'
142
+ */
143
+ ellipsisString?: string;
144
+ /**
145
+ * The height of each line in pixels (pre-scale)
146
+ *
147
+ * If not specified, defaults to the tallest character in the font
148
+ * (consistent across all strings in this font)
149
+ */
150
+ lineHeight?: number;
151
+ };
152
+ export type GlyphInfo = {
153
+ /**
154
+ * The character this glyph represents
155
+ */
156
+ character: string;
157
+ /**
158
+ * The sequential index of this glyph in the layout (0-based)
159
+ *
160
+ * This is the glyph's position in the rendered output (after any wrapping or
161
+ * truncation applied by the overflow options), not necessarily its index in
162
+ * the original input string
163
+ */
164
+ index: number;
165
+ /**
166
+ * The line index this glyph is on (0-based)
167
+ */
168
+ lineIndex: number;
169
+ /**
170
+ * The top-left draw position of this glyph in canvas pixels
171
+ *
172
+ * Computed from the cursor position minus any configured per-character or
173
+ * global offset, with the font and render scale already applied
174
+ */
175
+ position: vec2;
176
+ /**
177
+ * The size of this glyph's atlas tile in canvas pixels (post-scale)
178
+ *
179
+ * Will be (0, 0) for glyphs that have no texture in the atlas (e.g. spaces)
180
+ */
181
+ size: vec2;
182
+ /**
183
+ * The advance width of this glyph in canvas pixels
184
+ *
185
+ * This is how far the cursor moves after this glyph, including kerning
186
+ */
187
+ advance: number;
188
+ /**
189
+ * The bounding box of this glyph in canvas pixels
190
+ *
191
+ * Equivalent to { x: position.x, y: position.y, width: size.x, height: size.y }.
192
+ * Useful for hit-testing, debug overlays, and path-following calculations
193
+ */
194
+ bounds: {
195
+ x: number;
196
+ y: number;
197
+ width: number;
198
+ height: number;
199
+ };
200
+ /**
201
+ * Whether this glyph should be rendered
202
+ *
203
+ * Automatically false for characters with no texture in the atlas (e.g.
204
+ * spaces). Set this to false manually to hide individual glyphs, e.g. for
205
+ * a typewriter reveal effect
206
+ */
207
+ visible: boolean;
208
+ /**
209
+ * Additional per-glyph draw offset in canvas pixels, applied on top of the
210
+ * computed position
211
+ *
212
+ * Useful for per-character animations such as bobbing, shaking, or
213
+ * path-following nudges
214
+ */
215
+ offset?: vec2;
216
+ /**
217
+ * Per-glyph scale multiplier, applied around the pivot point
218
+ *
219
+ * Multiplied on top of the font and render scale already baked into
220
+ * position and size. Useful for pop-in or emphasis animations
221
+ */
222
+ scale?: number;
223
+ /**
224
+ * Rotation in radians, applied around the pivot point
225
+ *
226
+ * Useful for text-along-a-path or per-character wobble animations
227
+ */
228
+ rotation?: number;
229
+ /**
230
+ * The pivot point for rotation and scale transforms, in canvas pixels
231
+ *
232
+ * Defaults to the centre of the glyph's bounding box (after any per-glyph
233
+ * offset is applied). Set this explicitly for path-following, where each
234
+ * glyph rotates around a specific point on the path
235
+ */
236
+ pivot?: vec2;
237
+ /**
238
+ * Opacity multiplier for this glyph (0–1), multiplied against the context's
239
+ * current globalAlpha at the time drawLayout is called
240
+ *
241
+ * Useful for fade-in typewriter effects or translucent ghost glyphs
242
+ */
243
+ alpha?: number;
244
+ /**
245
+ * Per-glyph color override
246
+ *
247
+ * Overrides the color set in the layout's ImageFontRenderingOptions for this
248
+ * glyph only. The coloringMode and coloringFunction from the layout options
249
+ * are still used
250
+ */
251
+ color?: string;
252
+ /**
253
+ * Called for each glyph immediately before it is drawn, after all canvas
254
+ * transforms (rotation, scale, alpha) have been applied
255
+ *
256
+ * Any context changes made here are isolated to this glyph — the context is
257
+ * saved before and restored after rendering each glyph whenever any
258
+ * per-glyph property (alpha, rotation, scale, preDraw, postDraw) is set
259
+ */
260
+ preDraw?: (context: CanvasRenderingContext2D, glyph: GlyphInfo) => void;
261
+ /**
262
+ * Called for each glyph immediately after it is drawn, before the context
263
+ * is restored
264
+ *
265
+ * Useful for drawing outlines, drop shadows, or debug bounding boxes on top
266
+ * of the glyph
267
+ */
268
+ postDraw?: (context: CanvasRenderingContext2D, glyph: GlyphInfo) => void;
269
+ };
270
+ export type GlyphLayout = {
271
+ /**
272
+ * The original text string that was laid out
273
+ */
274
+ text: string;
275
+ /**
276
+ * The x position originally passed to layoutText
277
+ */
278
+ x: number;
279
+ /**
280
+ * The y position originally passed to layoutText
281
+ */
282
+ y: number;
283
+ /**
284
+ * The rendering options used to produce this layout
285
+ */
286
+ options?: ImageFontRenderingOptions;
287
+ /**
288
+ * The total bounding box of the laid-out text in canvas pixels
289
+ *
290
+ * Equivalent to the value returned by measureText with the same arguments
291
+ */
292
+ bounds: vec2;
293
+ /**
294
+ * Per-glyph layout information, one entry per character in the rendered
295
+ * output (after wrapping/truncation)
296
+ *
297
+ * Glyphs with no atlas texture (e.g. spaces) are included with
298
+ * visible: false so that index-based effects remain aligned with the string
299
+ */
300
+ glyphs: GlyphInfo[];
115
301
  };
116
302
  export declare function isImageFontConfigData(value: unknown): value is ImageFontConfigData;
117
303
  export declare class ImageFont {
@@ -138,10 +324,80 @@ export declare class ImageFont {
138
324
  * Calculate the height of a single character when rendered with this font
139
325
  */
140
326
  private measureCharacterHeight;
327
+ /**
328
+ * Get the effective line height in scaled pixels
329
+ *
330
+ * If lineHeight is specified in options, it is used (scaled). Otherwise,
331
+ * defaults to the tallest character defined in the font config, which gives
332
+ * consistent line spacing regardless of the actual string being rendered.
333
+ */
334
+ private measureLineHeight;
335
+ /**
336
+ * Measure the width of a line of text (without kerning on the last character)
337
+ */
338
+ private measureLineWidth;
339
+ /**
340
+ * Split text into lines according to maxWidth and overflow mode
341
+ */
342
+ private getLines;
343
+ /**
344
+ * Wrap a string at character boundaries to fit within maxWidth (already scaled)
345
+ */
346
+ private characterWrapLine;
347
+ /**
348
+ * Truncate a line to fit within maxWidth (already scaled), cutting off overflow
349
+ */
350
+ private truncateLine;
351
+ /**
352
+ * Truncate a line to fit within maxWidth (already scaled), appending ellipsis
353
+ */
354
+ private truncateLineWithEllipsis;
355
+ /**
356
+ * Compute the glyph layout for a string of text without drawing it
357
+ *
358
+ * This is the internal core shared by layoutText and drawText. It runs the
359
+ * same line-splitting, alignment, and cursor-advance logic, recording each
360
+ * character's final canvas position and metadata into a GlyphLayout instead
361
+ * of drawing immediately.
362
+ */
363
+ private _computeLayout;
141
364
  /**
142
365
  * Get the width of a string of text when rendered with this font
143
366
  */
144
367
  measureText(text: string, options?: ImageFontRenderingOptions): vec2;
368
+ /**
369
+ * Compute the layout for a string of text without drawing it
370
+ *
371
+ * Returns a GlyphLayout containing per-glyph position, size, and metadata.
372
+ * The layout can be inspected and modified — for example setting per-glyph
373
+ * offset, rotation, scale, alpha, color, or visibility — before being passed
374
+ * to drawLayout to render it.
375
+ *
376
+ * This enables effects such as text along a path, typewriter reveals,
377
+ * per-character colour gradients, and frame-by-frame glyph animations
378
+ * without any breaking changes to the existing drawText API.
379
+ */
380
+ layoutText(text: string, x: number, y: number, options?: ImageFontRenderingOptions): GlyphLayout;
381
+ /**
382
+ * Draw a pre-computed GlyphLayout on a canvas
383
+ *
384
+ * Iterates the glyphs in the layout and draws each visible one, honouring
385
+ * any per-glyph properties that were set after layoutText was called:
386
+ *
387
+ * - visible — set to false to skip a glyph (typewriter reveal etc.)
388
+ * - offset — additional draw offset in canvas pixels
389
+ * - scale — per-glyph scale multiplier applied around the pivot
390
+ * - rotation — rotation in radians applied around the pivot
391
+ * - pivot — pivot point for rotation/scale (defaults to glyph centre)
392
+ * - alpha — opacity multiplier (0–1)
393
+ * - color — per-glyph color override
394
+ * - preDraw — callback invoked after transforms, before drawing
395
+ * - postDraw — callback invoked after drawing, before context restore
396
+ *
397
+ * The context is saved and restored around each glyph when any of the
398
+ * per-glyph transform properties or callbacks are present.
399
+ */
400
+ drawLayout(context: CanvasRenderingContext2D, layout: GlyphLayout): void;
145
401
  /**
146
402
  * Draw text on a canvas using this font
147
403
  */
package/build/index.js CHANGED
@@ -26,7 +26,7 @@ return /******/ (() => { // webpackBootstrap
26
26
  /***/ ((__unused_webpack_module, exports, __webpack_require__) => {
27
27
 
28
28
  "use strict";
29
- eval("{\nObject.defineProperty(exports, \"__esModule\", ({ value: true }));\nexports.imageFontContentProcessor = exports.ImageFont = exports.isImageFontConfigData = void 0;\nconst texture_atlas_1 = __webpack_require__(/*! @basementuniverse/texture-atlas */ \"./node_modules/@basementuniverse/texture-atlas/build/index.js\");\nconst vec_1 = __webpack_require__(/*! @basementuniverse/vec */ \"./node_modules/@basementuniverse/vec/vec.js\");\n// -----------------------------------------------------------------------------\n// TYPE GUARDS\n// -----------------------------------------------------------------------------\nfunction isImageFontConfigData(value) {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n if (!('textureAtlasSize' in value) ||\n typeof value.textureAtlasSize !== 'object' ||\n value.textureAtlasSize === null) {\n return false;\n }\n if (!('x' in value.textureAtlasSize) ||\n typeof value.textureAtlasSize.x !== 'number') {\n return false;\n }\n if (!('y' in value.textureAtlasSize) ||\n typeof value.textureAtlasSize.y !== 'number') {\n return false;\n }\n if ('offset' in value) {\n if (typeof value.offset !== 'object' || value.offset === null) {\n return false;\n }\n if (!('x' in value.offset) || typeof value.offset.x !== 'number') {\n return false;\n }\n if (!('y' in value.offset) || typeof value.offset.y !== 'number') {\n return false;\n }\n }\n if ('scale' in value && typeof value.scale !== 'number') {\n return false;\n }\n if ('defaultCharacterConfig' in value) {\n if (typeof value.defaultCharacterConfig !== 'object' ||\n value.defaultCharacterConfig === null) {\n return false;\n }\n if (!isImageFontCharacterConfigData(value.defaultCharacterConfig, false)) {\n return false;\n }\n }\n if (!('characters' in value) ||\n typeof value.characters !== 'object' ||\n value.characters === null) {\n return false;\n }\n for (const [char, config] of Object.entries(value.characters)) {\n // Character keys must be single characters / grapheme clusters\n if (typeof char !== 'string' || [...char].length !== 1) {\n return false;\n }\n if (!isImageFontCharacterConfigData(config)) {\n return false;\n }\n }\n return true;\n}\nexports.isImageFontConfigData = isImageFontConfigData;\nfunction isImageFontCharacterConfigData(value, includeTextureAtlasPosition = true) {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n if (includeTextureAtlasPosition) {\n if (!('textureAtlasPosition' in value) ||\n typeof value.textureAtlasPosition !== 'object' ||\n value.textureAtlasPosition === null) {\n return false;\n }\n if (!('x' in value.textureAtlasPosition) ||\n typeof value.textureAtlasPosition.x !== 'number') {\n return false;\n }\n if (!('y' in value.textureAtlasPosition) ||\n typeof value.textureAtlasPosition.y !== 'number') {\n return false;\n }\n }\n if ('offset' in value) {\n if (typeof value.offset !== 'object' || value.offset === null) {\n return false;\n }\n if (!('x' in value.offset) || typeof value.offset.x !== 'number') {\n return false;\n }\n if (!('y' in value.offset) || typeof value.offset.y !== 'number') {\n return false;\n }\n }\n if ('width' in value && typeof value.width !== 'number') {\n return false;\n }\n if ('height' in value && typeof value.height !== 'number') {\n return false;\n }\n return true;\n}\n// -----------------------------------------------------------------------------\n// IMAGE FONT CLASS\n// -----------------------------------------------------------------------------\nclass ImageFont {\n constructor(textures, config) {\n this.colorCache = new Map();\n this.textures = textures;\n this.config = {\n ...ImageFont.DEFAULT_CONFIG,\n ...config,\n defaultCharacterConfig: {\n ...ImageFont.DEFAULT_CONFIG.defaultCharacterConfig,\n ...config.defaultCharacterConfig,\n },\n characters: {\n ...ImageFont.DEFAULT_CONFIG.characters,\n ...config.characters,\n },\n };\n }\n getCacheKey(character, color, mode) {\n return `${character}:${color}:${mode}`;\n }\n /**\n * Determine the coloring mode to use, falling back to 'multiply' if needed\n */\n getColoringMode(options) {\n var _a;\n const mode = (_a = options.coloringMode) !== null && _a !== void 0 ? _a : 'multiply';\n // If mode is 'custom' but no coloring function is provided, we fall back\n // to 'multiply'\n if (mode === 'custom' && !options.coloringFunction) {\n return 'multiply';\n }\n return mode;\n }\n /**\n * Create a colored version of a texture using the specified coloring mode\n */\n createColoredTexture(texture, color, mode, coloringFunction) {\n const canvas = document.createElement('canvas');\n canvas.width = texture.width;\n canvas.height = texture.height;\n const context = canvas.getContext('2d');\n // Draw the original texture\n context.drawImage(texture, 0, 0);\n // Apply coloring based on mode\n switch (mode) {\n case 'multiply':\n case 'hue':\n // Use a second canvas to preserve transparency for multiply/hue modes\n const tempCanvas = document.createElement('canvas');\n tempCanvas.width = texture.width;\n tempCanvas.height = texture.height;\n const tempContext = tempCanvas.getContext('2d');\n // Draw the character on the temp canvas\n tempContext.drawImage(texture, 0, 0);\n // Apply the color effect (multiply or hue)\n tempContext.globalCompositeOperation = mode;\n tempContext.fillStyle = color;\n tempContext.fillRect(0, 0, tempCanvas.width, tempCanvas.height);\n // Clear the main canvas and draw the colored result using source-atop\n // to preserve the original alpha channel\n context.clearRect(0, 0, canvas.width, canvas.height);\n context.drawImage(texture, 0, 0);\n context.globalCompositeOperation = 'source-atop';\n context.drawImage(tempCanvas, 0, 0);\n break;\n case 'overlay':\n context.globalCompositeOperation = 'source-atop';\n context.globalAlpha = 0.5;\n context.fillStyle = color;\n context.fillRect(0, 0, canvas.width, canvas.height);\n context.globalAlpha = 1.0;\n break;\n case 'custom':\n if (coloringFunction) {\n coloringFunction(context, texture, color);\n }\n break;\n }\n // Reset composite operation\n context.globalCompositeOperation = 'source-over';\n return canvas;\n }\n /**\n * Calculate the width of a single character when rendered with this font\n */\n measureCharacterWidth(character, options) {\n var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;\n const characterConfig = this.config.characters[character];\n const actualScale = ((_a = options === null || options === void 0 ? void 0 : options.scale) !== null && _a !== void 0 ? _a : 1) * ((_b = this.config.scale) !== null && _b !== void 0 ? _b : 1);\n const texture = this.textures[character];\n let width = 0;\n if (options === null || options === void 0 ? void 0 : options.monospace) {\n if ((options === null || options === void 0 ? void 0 : options.kerning) !== undefined) {\n width = options.kerning;\n }\n else {\n width =\n (_e = (_d = (_c = this.config.defaultCharacterConfig) === null || _c === void 0 ? void 0 : _c.width) !== null && _d !== void 0 ? _d : texture === null || texture === void 0 ? void 0 : texture.width) !== null && _e !== void 0 ? _e : 0;\n }\n }\n else {\n width =\n ((_j = (_h = (_f = characterConfig === null || characterConfig === void 0 ? void 0 : characterConfig.width) !== null && _f !== void 0 ? _f : (_g = this.config.defaultCharacterConfig) === null || _g === void 0 ? void 0 : _g.width) !== null && _h !== void 0 ? _h : texture === null || texture === void 0 ? void 0 : texture.width) !== null && _j !== void 0 ? _j : 0) * ((_k = options === null || options === void 0 ? void 0 : options.kerning) !== null && _k !== void 0 ? _k : 1);\n }\n return width * actualScale;\n }\n /**\n * Calculate the height of a single character when rendered with this font\n */\n measureCharacterHeight(character, options) {\n var _a, _b, _c, _d, _e;\n const characterConfig = this.config.characters[character];\n const actualScale = ((_a = options === null || options === void 0 ? void 0 : options.scale) !== null && _a !== void 0 ? _a : 1) * ((_b = this.config.scale) !== null && _b !== void 0 ? _b : 1);\n return (((_e = (_c = characterConfig === null || characterConfig === void 0 ? void 0 : characterConfig.height) !== null && _c !== void 0 ? _c : (_d = this.config.defaultCharacterConfig) === null || _d === void 0 ? void 0 : _d.height) !== null && _e !== void 0 ? _e : 0) * actualScale);\n }\n /**\n * Get the width of a string of text when rendered with this font\n */\n measureText(text, options) {\n // When calculating the total width, ignore kerning for the last character\n const characters = Array.from(text);\n const lastCharacterWidth = this.measureCharacterWidth(characters[characters.length - 1], {\n scale: options === null || options === void 0 ? void 0 : options.scale,\n });\n const width = characters\n .slice(0, characters.length - 1)\n .reduce((width, character) => width + this.measureCharacterWidth(character, options), 0) + lastCharacterWidth;\n const height = Math.max(...characters.map(character => this.measureCharacterHeight(character, options)));\n return (0, vec_1.vec2)(width, height);\n }\n /**\n * Draw text on a canvas using this font\n */\n drawText(context, text, x, y, options) {\n var _a, _b, _c, _d, _e, _f;\n const size = this.measureText(text, options);\n let currentX = x;\n switch (options === null || options === void 0 ? void 0 : options.align) {\n case 'center':\n currentX -= size.x / 2;\n break;\n case 'right':\n currentX -= size.x;\n break;\n }\n const actualScale = ((_a = options === null || options === void 0 ? void 0 : options.scale) !== null && _a !== void 0 ? _a : 1) * ((_b = this.config.scale) !== null && _b !== void 0 ? _b : 1);\n let actualY = y;\n switch (options === null || options === void 0 ? void 0 : options.baseLine) {\n case 'middle':\n actualY = y - size.y / 2;\n break;\n case 'bottom':\n actualY = y - size.y;\n break;\n }\n for (const character of text) {\n const characterWidth = this.measureCharacterWidth(character, options);\n const texture = this.textures[character];\n if (!texture) {\n currentX += characterWidth;\n continue;\n }\n const characterConfig = this.config.characters[character];\n const offset = vec_1.vec2.add((_c = this.config.offset) !== null && _c !== void 0 ? _c : (0, vec_1.vec2)(), (_f = (_d = characterConfig === null || characterConfig === void 0 ? void 0 : characterConfig.offset) !== null && _d !== void 0 ? _d : (_e = this.config.defaultCharacterConfig) === null || _e === void 0 ? void 0 : _e.offset) !== null && _f !== void 0 ? _f : (0, vec_1.vec2)());\n let finalTexture = texture;\n // Apply coloring if color is provided\n if (options === null || options === void 0 ? void 0 : options.color) {\n const coloringMode = this.getColoringMode(options);\n const cacheKey = this.getCacheKey(character, options.color, coloringMode);\n // Check if colored texture is already cached\n if (this.colorCache.has(cacheKey)) {\n finalTexture = this.colorCache.get(cacheKey);\n }\n else {\n // Create colored texture and cache it\n finalTexture = this.createColoredTexture(texture, options.color, coloringMode, options.coloringFunction);\n // Manage cache size\n if (this.colorCache.size >= ImageFont.MAX_COLOR_CACHE_SIZE) {\n // Remove oldest entry (first entry in the Map)\n const firstKey = this.colorCache.keys().next().value;\n if (firstKey !== undefined) {\n this.colorCache.delete(firstKey);\n }\n }\n this.colorCache.set(cacheKey, finalTexture);\n }\n }\n context.drawImage(finalTexture, currentX - offset.x * actualScale, actualY - offset.y * actualScale, finalTexture.width * actualScale, finalTexture.height * actualScale);\n currentX += characterWidth;\n }\n }\n}\nexports.ImageFont = ImageFont;\nImageFont.MAX_COLOR_CACHE_SIZE = 1000;\nImageFont.DEFAULT_CONFIG = {\n offset: (0, vec_1.vec2)(),\n scale: 1,\n defaultCharacterConfig: {\n offset: (0, vec_1.vec2)(),\n },\n characters: {},\n};\n// -----------------------------------------------------------------------------\n// CONTENT PROCESSOR\n// -----------------------------------------------------------------------------\n/**\n * Content Manager Processor for loading image fonts\n *\n * @see https://www.npmjs.com/package/@basementuniverse/content-manager\n */\nasync function imageFontContentProcessor(content, data, imageName) {\n var _a;\n if (!isImageFontConfigData(data.content)) {\n throw new Error('Invalid image font config');\n }\n const image = (_a = content[imageName]) === null || _a === void 0 ? void 0 : _a.content;\n if (!image) {\n throw new Error(`Image '${imageName}' not found`);\n }\n // Create the texture atlas\n const atlas = (0, texture_atlas_1.textureAtlas)(image, {\n relative: true,\n width: data.content.textureAtlasSize.x,\n height: data.content.textureAtlasSize.y,\n regions: Object.fromEntries(Object.entries(data.content.characters).map(([char, config]) => [\n char,\n {\n x: config.textureAtlasPosition.x,\n y: config.textureAtlasPosition.y,\n },\n ])),\n });\n // Create the image font\n const font = new ImageFont(atlas, data.content);\n // Store the font in the content manager\n content[data.name] = {\n name: data.name,\n type: 'json',\n content: font,\n status: 'processed',\n };\n}\nexports.imageFontContentProcessor = imageFontContentProcessor;\n\n\n//# sourceURL=webpack://@basementuniverse/image-font/./index.ts?\n}");
29
+ eval("{\nObject.defineProperty(exports, \"__esModule\", ({ value: true }));\nexports.imageFontContentProcessor = exports.ImageFont = exports.isImageFontConfigData = void 0;\nconst texture_atlas_1 = __webpack_require__(/*! @basementuniverse/texture-atlas */ \"./node_modules/@basementuniverse/texture-atlas/build/index.js\");\nconst vec_1 = __webpack_require__(/*! @basementuniverse/vec */ \"./node_modules/@basementuniverse/vec/vec.js\");\n// -----------------------------------------------------------------------------\n// TYPE GUARDS\n// -----------------------------------------------------------------------------\nfunction isImageFontConfigData(value) {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n if (!('textureAtlasSize' in value) ||\n typeof value.textureAtlasSize !== 'object' ||\n value.textureAtlasSize === null) {\n return false;\n }\n if (!('x' in value.textureAtlasSize) ||\n typeof value.textureAtlasSize.x !== 'number') {\n return false;\n }\n if (!('y' in value.textureAtlasSize) ||\n typeof value.textureAtlasSize.y !== 'number') {\n return false;\n }\n if ('offset' in value) {\n if (typeof value.offset !== 'object' || value.offset === null) {\n return false;\n }\n if (!('x' in value.offset) || typeof value.offset.x !== 'number') {\n return false;\n }\n if (!('y' in value.offset) || typeof value.offset.y !== 'number') {\n return false;\n }\n }\n if ('scale' in value && typeof value.scale !== 'number') {\n return false;\n }\n if ('defaultCharacterConfig' in value) {\n if (typeof value.defaultCharacterConfig !== 'object' ||\n value.defaultCharacterConfig === null) {\n return false;\n }\n if (!isImageFontCharacterConfigData(value.defaultCharacterConfig, false)) {\n return false;\n }\n }\n if (!('characters' in value) ||\n typeof value.characters !== 'object' ||\n value.characters === null) {\n return false;\n }\n for (const [char, config] of Object.entries(value.characters)) {\n // Character keys must be single characters / grapheme clusters\n if (typeof char !== 'string' || [...char].length !== 1) {\n return false;\n }\n if (!isImageFontCharacterConfigData(config)) {\n return false;\n }\n }\n return true;\n}\nexports.isImageFontConfigData = isImageFontConfigData;\nfunction isImageFontCharacterConfigData(value, includeTextureAtlasPosition = true) {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n if (includeTextureAtlasPosition) {\n if (!('textureAtlasPosition' in value) ||\n typeof value.textureAtlasPosition !== 'object' ||\n value.textureAtlasPosition === null) {\n return false;\n }\n if (!('x' in value.textureAtlasPosition) ||\n typeof value.textureAtlasPosition.x !== 'number') {\n return false;\n }\n if (!('y' in value.textureAtlasPosition) ||\n typeof value.textureAtlasPosition.y !== 'number') {\n return false;\n }\n }\n if ('offset' in value) {\n if (typeof value.offset !== 'object' || value.offset === null) {\n return false;\n }\n if (!('x' in value.offset) || typeof value.offset.x !== 'number') {\n return false;\n }\n if (!('y' in value.offset) || typeof value.offset.y !== 'number') {\n return false;\n }\n }\n if ('width' in value && typeof value.width !== 'number') {\n return false;\n }\n if ('height' in value && typeof value.height !== 'number') {\n return false;\n }\n return true;\n}\n// -----------------------------------------------------------------------------\n// IMAGE FONT CLASS\n// -----------------------------------------------------------------------------\nclass ImageFont {\n constructor(textures, config) {\n this.colorCache = new Map();\n this.textures = textures;\n this.config = {\n ...ImageFont.DEFAULT_CONFIG,\n ...config,\n defaultCharacterConfig: {\n ...ImageFont.DEFAULT_CONFIG.defaultCharacterConfig,\n ...config.defaultCharacterConfig,\n },\n characters: {\n ...ImageFont.DEFAULT_CONFIG.characters,\n ...config.characters,\n },\n };\n }\n getCacheKey(character, color, mode) {\n return `${character}:${color}:${mode}`;\n }\n /**\n * Determine the coloring mode to use, falling back to 'multiply' if needed\n */\n getColoringMode(options) {\n var _a;\n const mode = (_a = options.coloringMode) !== null && _a !== void 0 ? _a : 'multiply';\n // If mode is 'custom' but no coloring function is provided, we fall back\n // to 'multiply'\n if (mode === 'custom' && !options.coloringFunction) {\n return 'multiply';\n }\n return mode;\n }\n /**\n * Create a colored version of a texture using the specified coloring mode\n */\n createColoredTexture(texture, color, mode, coloringFunction) {\n const canvas = document.createElement('canvas');\n canvas.width = texture.width;\n canvas.height = texture.height;\n const context = canvas.getContext('2d');\n // Draw the original texture\n context.drawImage(texture, 0, 0);\n // Apply coloring based on mode\n switch (mode) {\n case 'multiply':\n case 'hue':\n // Use a second canvas to preserve transparency for multiply/hue modes\n const tempCanvas = document.createElement('canvas');\n tempCanvas.width = texture.width;\n tempCanvas.height = texture.height;\n const tempContext = tempCanvas.getContext('2d');\n // Draw the character on the temp canvas\n tempContext.drawImage(texture, 0, 0);\n // Apply the color effect (multiply or hue)\n tempContext.globalCompositeOperation = mode;\n tempContext.fillStyle = color;\n tempContext.fillRect(0, 0, tempCanvas.width, tempCanvas.height);\n // Clear the main canvas and draw the colored result using source-atop\n // to preserve the original alpha channel\n context.clearRect(0, 0, canvas.width, canvas.height);\n context.drawImage(texture, 0, 0);\n context.globalCompositeOperation = 'source-atop';\n context.drawImage(tempCanvas, 0, 0);\n break;\n case 'overlay':\n context.globalCompositeOperation = 'source-atop';\n context.globalAlpha = 0.5;\n context.fillStyle = color;\n context.fillRect(0, 0, canvas.width, canvas.height);\n context.globalAlpha = 1.0;\n break;\n case 'custom':\n if (coloringFunction) {\n coloringFunction(context, texture, color);\n }\n break;\n }\n // Reset composite operation\n context.globalCompositeOperation = 'source-over';\n return canvas;\n }\n /**\n * Calculate the width of a single character when rendered with this font\n */\n measureCharacterWidth(character, options) {\n var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;\n const characterConfig = this.config.characters[character];\n const actualScale = ((_a = options === null || options === void 0 ? void 0 : options.scale) !== null && _a !== void 0 ? _a : 1) * ((_b = this.config.scale) !== null && _b !== void 0 ? _b : 1);\n const texture = this.textures[character];\n let width = 0;\n if (options === null || options === void 0 ? void 0 : options.monospace) {\n if ((options === null || options === void 0 ? void 0 : options.kerning) !== undefined) {\n width = options.kerning;\n }\n else {\n width =\n (_e = (_d = (_c = this.config.defaultCharacterConfig) === null || _c === void 0 ? void 0 : _c.width) !== null && _d !== void 0 ? _d : texture === null || texture === void 0 ? void 0 : texture.width) !== null && _e !== void 0 ? _e : 0;\n }\n }\n else {\n width =\n ((_j = (_h = (_f = characterConfig === null || characterConfig === void 0 ? void 0 : characterConfig.width) !== null && _f !== void 0 ? _f : (_g = this.config.defaultCharacterConfig) === null || _g === void 0 ? void 0 : _g.width) !== null && _h !== void 0 ? _h : texture === null || texture === void 0 ? void 0 : texture.width) !== null && _j !== void 0 ? _j : 0) * ((_k = options === null || options === void 0 ? void 0 : options.kerning) !== null && _k !== void 0 ? _k : 1);\n }\n return width * actualScale;\n }\n /**\n * Calculate the height of a single character when rendered with this font\n */\n measureCharacterHeight(character, options) {\n var _a, _b, _c, _d, _e;\n const characterConfig = this.config.characters[character];\n const actualScale = ((_a = options === null || options === void 0 ? void 0 : options.scale) !== null && _a !== void 0 ? _a : 1) * ((_b = this.config.scale) !== null && _b !== void 0 ? _b : 1);\n return (((_e = (_c = characterConfig === null || characterConfig === void 0 ? void 0 : characterConfig.height) !== null && _c !== void 0 ? _c : (_d = this.config.defaultCharacterConfig) === null || _d === void 0 ? void 0 : _d.height) !== null && _e !== void 0 ? _e : 0) * actualScale);\n }\n /**\n * Get the effective line height in scaled pixels\n *\n * If lineHeight is specified in options, it is used (scaled). Otherwise,\n * defaults to the tallest character defined in the font config, which gives\n * consistent line spacing regardless of the actual string being rendered.\n */\n measureLineHeight(options) {\n var _a, _b, _c, _d;\n if ((options === null || options === void 0 ? void 0 : options.lineHeight) !== undefined) {\n const actualScale = ((_a = options === null || options === void 0 ? void 0 : options.scale) !== null && _a !== void 0 ? _a : 1) * ((_b = this.config.scale) !== null && _b !== void 0 ? _b : 1);\n return options.lineHeight * actualScale;\n }\n // Use the tallest character defined in the font (consistent for any string)\n const actualScale = ((_c = options === null || options === void 0 ? void 0 : options.scale) !== null && _c !== void 0 ? _c : 1) * ((_d = this.config.scale) !== null && _d !== void 0 ? _d : 1);\n const allConfigs = [\n ...Object.values(this.config.characters),\n this.config.defaultCharacterConfig,\n ].filter(Boolean);\n const maxHeight = allConfigs.reduce((max, cfg) => { var _a; return Math.max(max, (_a = cfg.height) !== null && _a !== void 0 ? _a : 0); }, 0);\n return maxHeight * actualScale;\n }\n /**\n * Measure the width of a line of text (without kerning on the last character)\n */\n measureLineWidth(line, options) {\n if (line.length === 0) {\n return 0;\n }\n const characters = Array.from(line);\n const lastCharacterWidth = this.measureCharacterWidth(characters[characters.length - 1], { scale: options === null || options === void 0 ? void 0 : options.scale });\n return (characters\n .slice(0, characters.length - 1)\n .reduce((w, ch) => w + this.measureCharacterWidth(ch, options), 0) +\n lastCharacterWidth);\n }\n /**\n * Split text into lines according to maxWidth and overflow mode\n */\n getLines(text, options) {\n var _a, _b, _c, _d;\n // If no maxWidth, or overflow is 'none', return single line\n if (!(options === null || options === void 0 ? void 0 : options.maxWidth) || (options === null || options === void 0 ? void 0 : options.overflow) === 'none') {\n return [text];\n }\n const maxWidth = options.maxWidth * ((_a = options === null || options === void 0 ? void 0 : options.scale) !== null && _a !== void 0 ? _a : 1) * ((_b = this.config.scale) !== null && _b !== void 0 ? _b : 1);\n const overflow = (_c = options === null || options === void 0 ? void 0 : options.overflow) !== null && _c !== void 0 ? _c : 'word-wrap';\n if (overflow === 'hidden') {\n return [this.truncateLine(text, maxWidth, options)];\n }\n if (overflow === 'ellipsis') {\n const ellipsis = (_d = options === null || options === void 0 ? void 0 : options.ellipsisString) !== null && _d !== void 0 ? _d : '...';\n return [this.truncateLineWithEllipsis(text, maxWidth, ellipsis, options)];\n }\n // word-wrap or character-wrap: build multiple lines\n const lines = [];\n if (overflow === 'word-wrap') {\n // Split on spaces; re-join words greedily\n const words = text.split(' ');\n let currentLine = '';\n for (let i = 0; i < words.length; i++) {\n const word = words[i];\n const candidate = currentLine.length > 0 ? currentLine + ' ' + word : word;\n if (this.measureLineWidth(candidate, options) <= maxWidth) {\n currentLine = candidate;\n }\n else {\n // Flush current line if it has content\n if (currentLine.length > 0) {\n lines.push(currentLine);\n currentLine = '';\n }\n // Check if the single word itself exceeds maxWidth — fall back to\n // character-wrap for that word\n if (this.measureLineWidth(word, options) > maxWidth) {\n const charWrapped = this.characterWrapLine(word, maxWidth, options);\n // All but the last segment become complete lines\n for (let j = 0; j < charWrapped.length - 1; j++) {\n lines.push(charWrapped[j]);\n }\n currentLine = charWrapped[charWrapped.length - 1];\n }\n else {\n currentLine = word;\n }\n }\n }\n if (currentLine.length > 0) {\n lines.push(currentLine);\n }\n }\n else {\n // character-wrap\n const charWrapped = this.characterWrapLine(text, maxWidth, options);\n lines.push(...charWrapped);\n }\n return lines.length > 0 ? lines : [''];\n }\n /**\n * Wrap a string at character boundaries to fit within maxWidth (already scaled)\n */\n characterWrapLine(text, maxWidth, options) {\n const lines = [];\n const characters = Array.from(text);\n let currentLine = '';\n for (const ch of characters) {\n const candidate = currentLine + ch;\n if (this.measureLineWidth(candidate, options) <= maxWidth) {\n currentLine = candidate;\n }\n else {\n if (currentLine.length > 0) {\n lines.push(currentLine);\n }\n currentLine = ch;\n }\n }\n if (currentLine.length > 0) {\n lines.push(currentLine);\n }\n return lines.length > 0 ? lines : [''];\n }\n /**\n * Truncate a line to fit within maxWidth (already scaled), cutting off overflow\n */\n truncateLine(text, maxWidth, options) {\n const characters = Array.from(text);\n let result = '';\n for (const ch of characters) {\n const candidate = result + ch;\n if (this.measureLineWidth(candidate, options) <= maxWidth) {\n result = candidate;\n }\n else {\n break;\n }\n }\n return result;\n }\n /**\n * Truncate a line to fit within maxWidth (already scaled), appending ellipsis\n */\n truncateLineWithEllipsis(text, maxWidth, ellipsis, options) {\n // If the whole text already fits, no ellipsis needed\n if (this.measureLineWidth(text, options) <= maxWidth) {\n return text;\n }\n const characters = Array.from(text);\n let result = '';\n for (const ch of characters) {\n const candidate = result + ch + ellipsis;\n if (this.measureLineWidth(candidate, options) <= maxWidth) {\n result = result + ch;\n }\n else {\n break;\n }\n }\n return result + ellipsis;\n }\n /**\n * Compute the glyph layout for a string of text without drawing it\n *\n * This is the internal core shared by layoutText and drawText. It runs the\n * same line-splitting, alignment, and cursor-advance logic, recording each\n * character's final canvas position and metadata into a GlyphLayout instead\n * of drawing immediately.\n */\n _computeLayout(text, x, y, options) {\n var _a, _b, _c, _d, _e, _f;\n const lines = this.getLines(text, options);\n const totalSize = this.measureText(text, options);\n const lineHeight = this.measureLineHeight(options);\n const actualScale = ((_a = options === null || options === void 0 ? void 0 : options.scale) !== null && _a !== void 0 ? _a : 1) * ((_b = this.config.scale) !== null && _b !== void 0 ? _b : 1);\n // Apply baseline (vertical) alignment to the whole text block\n let blockY = y;\n switch (options === null || options === void 0 ? void 0 : options.baseLine) {\n case 'middle':\n blockY = y - totalSize.y / 2;\n break;\n case 'bottom':\n blockY = y - totalSize.y;\n break;\n }\n const glyphs = [];\n let glyphIndex = 0;\n for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {\n const line = lines[lineIndex];\n const lineWidth = this.measureLineWidth(line, options);\n const actualY = blockY + lineIndex * lineHeight;\n // Apply horizontal alignment per line\n let currentX = x;\n switch (options === null || options === void 0 ? void 0 : options.align) {\n case 'center':\n currentX = x - lineWidth / 2;\n break;\n case 'right':\n currentX = x - lineWidth;\n break;\n }\n for (const character of line) {\n const characterWidth = this.measureCharacterWidth(character, options);\n const texture = this.textures[character];\n const characterConfig = this.config.characters[character];\n const offset = vec_1.vec2.add((_c = this.config.offset) !== null && _c !== void 0 ? _c : (0, vec_1.vec2)(), (_f = (_d = characterConfig === null || characterConfig === void 0 ? void 0 : characterConfig.offset) !== null && _d !== void 0 ? _d : (_e = this.config.defaultCharacterConfig) === null || _e === void 0 ? void 0 : _e.offset) !== null && _f !== void 0 ? _f : (0, vec_1.vec2)());\n let position;\n let size;\n if (texture) {\n position = (0, vec_1.vec2)(currentX - offset.x * actualScale, actualY - offset.y * actualScale);\n size = (0, vec_1.vec2)(texture.width * actualScale, texture.height * actualScale);\n }\n else {\n position = (0, vec_1.vec2)(currentX, actualY);\n size = (0, vec_1.vec2)(0, 0);\n }\n glyphs.push({\n character,\n index: glyphIndex,\n lineIndex,\n position,\n size,\n advance: characterWidth,\n bounds: {\n x: position.x,\n y: position.y,\n width: size.x,\n height: size.y,\n },\n visible: !!texture,\n });\n currentX += characterWidth;\n glyphIndex++;\n }\n }\n return {\n text,\n x,\n y,\n options,\n bounds: totalSize,\n glyphs,\n };\n }\n /**\n * Get the width of a string of text when rendered with this font\n */\n measureText(text, options) {\n const lines = this.getLines(text, options);\n const lineHeight = this.measureLineHeight(options);\n const width = Math.max(...lines.map(line => this.measureLineWidth(line, options)));\n const height = lines.length === 1 ? lineHeight : lineHeight * lines.length;\n return (0, vec_1.vec2)(width, height);\n }\n /**\n * Compute the layout for a string of text without drawing it\n *\n * Returns a GlyphLayout containing per-glyph position, size, and metadata.\n * The layout can be inspected and modified — for example setting per-glyph\n * offset, rotation, scale, alpha, color, or visibility — before being passed\n * to drawLayout to render it.\n *\n * This enables effects such as text along a path, typewriter reveals,\n * per-character colour gradients, and frame-by-frame glyph animations\n * without any breaking changes to the existing drawText API.\n */\n layoutText(text, x, y, options) {\n return this._computeLayout(text, x, y, options);\n }\n /**\n * Draw a pre-computed GlyphLayout on a canvas\n *\n * Iterates the glyphs in the layout and draws each visible one, honouring\n * any per-glyph properties that were set after layoutText was called:\n *\n * - visible — set to false to skip a glyph (typewriter reveal etc.)\n * - offset — additional draw offset in canvas pixels\n * - scale — per-glyph scale multiplier applied around the pivot\n * - rotation — rotation in radians applied around the pivot\n * - pivot — pivot point for rotation/scale (defaults to glyph centre)\n * - alpha — opacity multiplier (0–1)\n * - color — per-glyph color override\n * - preDraw — callback invoked after transforms, before drawing\n * - postDraw — callback invoked after drawing, before context restore\n *\n * The context is saved and restored around each glyph when any of the\n * per-glyph transform properties or callbacks are present.\n */\n drawLayout(context, layout) {\n var _a, _b, _c, _d, _e, _f;\n const options = layout.options;\n for (const glyph of layout.glyphs) {\n if (!glyph.visible) {\n continue;\n }\n const texture = this.textures[glyph.character];\n if (!texture) {\n continue;\n }\n const needsContextSave = glyph.alpha !== undefined ||\n glyph.rotation !== undefined ||\n glyph.scale !== undefined ||\n glyph.preDraw !== undefined ||\n glyph.postDraw !== undefined;\n if (needsContextSave) {\n context.save();\n }\n // Apply per-glyph alpha (multiplied against current globalAlpha)\n if (glyph.alpha !== undefined) {\n context.globalAlpha *= glyph.alpha;\n }\n // Resolve final draw position (including optional per-glyph offset)\n const drawX = glyph.position.x + ((_b = (_a = glyph.offset) === null || _a === void 0 ? void 0 : _a.x) !== null && _b !== void 0 ? _b : 0);\n const drawY = glyph.position.y + ((_d = (_c = glyph.offset) === null || _c === void 0 ? void 0 : _c.y) !== null && _d !== void 0 ? _d : 0);\n // Apply rotation and/or per-glyph scale transforms around the pivot\n if (glyph.rotation !== undefined || glyph.scale !== undefined) {\n const pivot = (_e = glyph.pivot) !== null && _e !== void 0 ? _e : (0, vec_1.vec2)(drawX + glyph.size.x / 2, drawY + glyph.size.y / 2);\n context.translate(pivot.x, pivot.y);\n if (glyph.rotation !== undefined) {\n context.rotate(glyph.rotation);\n }\n if (glyph.scale !== undefined) {\n context.scale(glyph.scale, glyph.scale);\n }\n context.translate(-pivot.x, -pivot.y);\n }\n // Call preDraw callback (after transforms, before drawing)\n if (glyph.preDraw) {\n glyph.preDraw(context, glyph);\n }\n // Resolve the effective color: per-glyph color overrides the layout color\n const effectiveColor = (_f = glyph.color) !== null && _f !== void 0 ? _f : options === null || options === void 0 ? void 0 : options.color;\n let finalTexture = texture;\n if (effectiveColor) {\n const effectiveOptions = glyph.color\n ? { ...options, color: glyph.color }\n : (options !== null && options !== void 0 ? options : {});\n const coloringMode = this.getColoringMode(effectiveOptions);\n const cacheKey = this.getCacheKey(glyph.character, effectiveColor, coloringMode);\n if (this.colorCache.has(cacheKey)) {\n finalTexture = this.colorCache.get(cacheKey);\n }\n else {\n finalTexture = this.createColoredTexture(texture, effectiveColor, coloringMode, effectiveOptions.coloringFunction);\n if (this.colorCache.size >= ImageFont.MAX_COLOR_CACHE_SIZE) {\n const firstKey = this.colorCache.keys().next().value;\n if (firstKey !== undefined) {\n this.colorCache.delete(firstKey);\n }\n }\n this.colorCache.set(cacheKey, finalTexture);\n }\n }\n context.drawImage(finalTexture, drawX, drawY, glyph.size.x, glyph.size.y);\n // Call postDraw callback (after drawing, before context restore)\n if (glyph.postDraw) {\n glyph.postDraw(context, glyph);\n }\n if (needsContextSave) {\n context.restore();\n }\n }\n }\n /**\n * Draw text on a canvas using this font\n */\n drawText(context, text, x, y, options) {\n this.drawLayout(context, this._computeLayout(text, x, y, options));\n }\n}\nexports.ImageFont = ImageFont;\nImageFont.MAX_COLOR_CACHE_SIZE = 1000;\nImageFont.DEFAULT_CONFIG = {\n offset: (0, vec_1.vec2)(),\n scale: 1,\n defaultCharacterConfig: {\n offset: (0, vec_1.vec2)(),\n },\n characters: {},\n};\n// -----------------------------------------------------------------------------\n// CONTENT PROCESSOR\n// -----------------------------------------------------------------------------\n/**\n * Content Manager Processor for loading image fonts\n *\n * @see https://www.npmjs.com/package/@basementuniverse/content-manager\n */\nasync function imageFontContentProcessor(content, data, imageName) {\n var _a;\n if (!isImageFontConfigData(data.content)) {\n throw new Error('Invalid image font config');\n }\n const image = (_a = content[imageName]) === null || _a === void 0 ? void 0 : _a.content;\n if (!image) {\n throw new Error(`Image '${imageName}' not found`);\n }\n // Create the texture atlas\n const atlas = (0, texture_atlas_1.textureAtlas)(image, {\n relative: true,\n width: data.content.textureAtlasSize.x,\n height: data.content.textureAtlasSize.y,\n regions: Object.fromEntries(Object.entries(data.content.characters).map(([char, config]) => [\n char,\n {\n x: config.textureAtlasPosition.x,\n y: config.textureAtlasPosition.y,\n },\n ])),\n });\n // Create the image font\n const font = new ImageFont(atlas, data.content);\n // Store the font in the content manager\n content[data.name] = {\n name: data.name,\n type: 'json',\n content: font,\n status: 'processed',\n };\n}\nexports.imageFontContentProcessor = imageFontContentProcessor;\n\n\n//# sourceURL=webpack://@basementuniverse/image-font/./index.ts?\n}");
30
30
 
31
31
  /***/ }),
32
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basementuniverse/image-font",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "A component for rendering text using image fonts",
5
5
  "author": "Gordon Larrigan <gordonlarrigan@gmail.com> (https://gordonlarrigan.com)",
6
6
  "license": "MIT",