@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 +297 -0
- package/build/index.d.ts +256 -0
- package/build/index.js +1 -1
- package/package.json +1 -1
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