@flighthq/glyphatlas 0.1.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/dist/glyphAtlas.d.ts +6 -0
- package/dist/glyphAtlas.d.ts.map +1 -0
- package/dist/glyphAtlas.js +61 -0
- package/dist/glyphAtlas.js.map +1 -0
- package/dist/glyphAtlasDirty.d.ts +4 -0
- package/dist/glyphAtlasDirty.d.ts.map +1 -0
- package/dist/glyphAtlasDirty.js +16 -0
- package/dist/glyphAtlasDirty.js.map +1 -0
- package/dist/glyphAtlasEntry.d.ts +3 -0
- package/dist/glyphAtlasEntry.d.ts.map +1 -0
- package/dist/glyphAtlasEntry.js +171 -0
- package/dist/glyphAtlasEntry.js.map +1 -0
- package/dist/glyphAtlasMetrics.d.ts +4 -0
- package/dist/glyphAtlasMetrics.d.ts.map +1 -0
- package/dist/glyphAtlasMetrics.js +12 -0
- package/dist/glyphAtlasMetrics.js.map +1 -0
- package/dist/glyphRasterizerBackend.d.ts +5 -0
- package/dist/glyphRasterizerBackend.d.ts.map +1 -0
- package/dist/glyphRasterizerBackend.js +92 -0
- package/dist/glyphRasterizerBackend.js.map +1 -0
- package/dist/glyphSource.d.ts +3 -0
- package/dist/glyphSource.d.ts.map +1 -0
- package/dist/glyphSource.js +26 -0
- package/dist/glyphSource.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/src/glyphAtlas.test.ts +64 -0
- package/src/glyphAtlasDirty.test.ts +75 -0
- package/src/glyphAtlasEntry.test.ts +157 -0
- package/src/glyphAtlasMetrics.test.ts +18 -0
- package/src/glyphRasterizerBackend.test.ts +49 -0
- package/src/glyphSource.test.ts +43 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { GlyphAtlas, GlyphAtlasOptions, GlyphMetrics, Surface } from '@flighthq/types';
|
|
2
|
+
export declare function createGlyphAtlas(options: Readonly<GlyphAtlasOptions>): GlyphAtlas;
|
|
3
|
+
export declare function deriveGlyphMetricsFromFontSize(fontSize: number): GlyphMetrics;
|
|
4
|
+
export declare function disposeGlyphAtlas(atlas: GlyphAtlas): void;
|
|
5
|
+
export declare function getGlyphAtlasSurface(atlas: Readonly<GlyphAtlas>): Surface;
|
|
6
|
+
//# sourceMappingURL=glyphAtlas.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glyphAtlas.d.ts","sourceRoot":"","sources":["../src/glyphAtlas.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,iBAAiB,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAO5F,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,QAAQ,CAAC,iBAAiB,CAAC,GAAG,UAAU,CAwBjF;AAKD,wBAAgB,8BAA8B,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,CAM7E;AAOD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAQzD;AAID,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,OAAO,CAEzE"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createSurface } from '@flighthq/surface';
|
|
2
|
+
// Allocates a dynamic glyph atlas: an empty `width x height` atlas surface, an empty codepoint→entry
|
|
3
|
+
// cache, and a fresh incremental shelf packer. Glyphs are added lazily by `getGlyphAtlasEntry`;
|
|
4
|
+
// nothing is rasterized here. `padding` defaults to 1px of gutter between glyphs and from the edges;
|
|
5
|
+
// `maxGlyphs` (default 0 = unbounded, only the atlas area caps it) bounds the live cache for LRU
|
|
6
|
+
// eviction. Line metrics are derived from `fontSize` for now (see `deriveGlyphMetricsFromFontSize`).
|
|
7
|
+
export function createGlyphAtlas(options) {
|
|
8
|
+
const padding = options.padding ?? 1;
|
|
9
|
+
return {
|
|
10
|
+
runtime: {
|
|
11
|
+
bitmaps: new Map(),
|
|
12
|
+
dirty: false,
|
|
13
|
+
dirtyMaxX: 0,
|
|
14
|
+
dirtyMaxY: 0,
|
|
15
|
+
dirtyMinX: 0,
|
|
16
|
+
dirtyMinY: 0,
|
|
17
|
+
entries: new Map(),
|
|
18
|
+
lru: [],
|
|
19
|
+
maxGlyphs: options.maxGlyphs ?? 0,
|
|
20
|
+
metrics: deriveGlyphMetricsFromFontSize(options.fontSize),
|
|
21
|
+
packBottom: padding,
|
|
22
|
+
padding,
|
|
23
|
+
rasterizeOptions: {
|
|
24
|
+
fontFamily: options.fontFamily,
|
|
25
|
+
fontSize: options.fontSize,
|
|
26
|
+
},
|
|
27
|
+
shelves: [],
|
|
28
|
+
surface: createSurface(options.width, options.height),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// Estimates line metrics from a pixel font size when a real font-metrics source is not wired up. The
|
|
33
|
+
// 0.8/0.2 ascent/descent split and zero line gap are a coarse Latin-typical default; a text
|
|
34
|
+
// renderer that needs true metrics should read them from the shaping layer once that seam exists.
|
|
35
|
+
export function deriveGlyphMetricsFromFontSize(fontSize) {
|
|
36
|
+
return {
|
|
37
|
+
ascent: fontSize * 0.8,
|
|
38
|
+
descent: fontSize * 0.2,
|
|
39
|
+
lineGap: 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// Drops the cache, the retained source bitmaps, the LRU order, and the packer so the atlas becomes
|
|
43
|
+
// an empty, inert shell and its sizable retained memory (the per-glyph bitmap copies) becomes
|
|
44
|
+
// GC-eligible. The surface holds only CPU-managed pixel data (no GPU/native handle) and is released
|
|
45
|
+
// to GC when the atlas is dropped, so this is `dispose*`, not `destroy*`; a renderer that uploaded
|
|
46
|
+
// the surface to a GPU texture frees that texture through its own render state.
|
|
47
|
+
export function disposeGlyphAtlas(atlas) {
|
|
48
|
+
const runtime = atlas.runtime;
|
|
49
|
+
runtime.entries.clear();
|
|
50
|
+
runtime.bitmaps.clear();
|
|
51
|
+
runtime.lru.length = 0;
|
|
52
|
+
runtime.shelves.length = 0;
|
|
53
|
+
runtime.packBottom = runtime.padding;
|
|
54
|
+
runtime.dirty = false;
|
|
55
|
+
}
|
|
56
|
+
// The atlas's backing surface — the pixels a renderer uploads to a GPU texture. Use
|
|
57
|
+
// `getGlyphAtlasDirtyRegion` to upload only the changed sub-rect.
|
|
58
|
+
export function getGlyphAtlasSurface(atlas) {
|
|
59
|
+
return atlas.runtime.surface;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=glyphAtlas.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glyphAtlas.js","sourceRoot":"","sources":["../src/glyphAtlas.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAGlD,qGAAqG;AACrG,gGAAgG;AAChG,qGAAqG;AACrG,iGAAiG;AACjG,qGAAqG;AACrG,MAAM,UAAU,gBAAgB,CAAC,OAAoC;IACnE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,CAAC,CAAC;IACrC,OAAO;QACL,OAAO,EAAE;YACP,OAAO,EAAE,IAAI,GAAG,EAAE;YAClB,KAAK,EAAE,KAAK;YACZ,SAAS,EAAE,CAAC;YACZ,SAAS,EAAE,CAAC;YACZ,SAAS,EAAE,CAAC;YACZ,SAAS,EAAE,CAAC;YACZ,OAAO,EAAE,IAAI,GAAG,EAAE;YAClB,GAAG,EAAE,EAAE;YACP,SAAS,EAAE,OAAO,CAAC,SAAS,IAAI,CAAC;YACjC,OAAO,EAAE,8BAA8B,CAAC,OAAO,CAAC,QAAQ,CAAC;YACzD,UAAU,EAAE,OAAO;YACnB,OAAO;YACP,gBAAgB,EAAE;gBAChB,UAAU,EAAE,OAAO,CAAC,UAAU;gBAC9B,QAAQ,EAAE,OAAO,CAAC,QAAQ;aAC3B;YACD,OAAO,EAAE,EAAE;YACX,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,MAAM,CAAC;SACtD;KACF,CAAC;AACJ,CAAC;AAED,qGAAqG;AACrG,4FAA4F;AAC5F,kGAAkG;AAClG,MAAM,UAAU,8BAA8B,CAAC,QAAgB;IAC7D,OAAO;QACL,MAAM,EAAE,QAAQ,GAAG,GAAG;QACtB,OAAO,EAAE,QAAQ,GAAG,GAAG;QACvB,OAAO,EAAE,CAAC;KACX,CAAC;AACJ,CAAC;AAED,mGAAmG;AACnG,8FAA8F;AAC9F,oGAAoG;AACpG,mGAAmG;AACnG,gFAAgF;AAChF,MAAM,UAAU,iBAAiB,CAAC,KAAiB;IACjD,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC;IAC9B,OAAO,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;IACxB,OAAO,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;IACxB,OAAO,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC;IACvB,OAAO,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC;IAC3B,OAAO,CAAC,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC;IACrC,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC;AACxB,CAAC;AAED,oFAAoF;AACpF,kEAAkE;AAClE,MAAM,UAAU,oBAAoB,CAAC,KAA2B;IAC9D,OAAO,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;AAC/B,CAAC"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { GlyphAtlas, Rectangle } from '@flighthq/types';
|
|
2
|
+
export declare function clearGlyphAtlasDirty(atlas: Readonly<GlyphAtlas>): void;
|
|
3
|
+
export declare function getGlyphAtlasDirtyRegion(atlas: Readonly<GlyphAtlas>): Rectangle | null;
|
|
4
|
+
//# sourceMappingURL=glyphAtlasDirty.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glyphAtlasDirty.d.ts","sourceRoot":"","sources":["../src/glyphAtlasDirty.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAI7D,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,IAAI,CAEtE;AAKD,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,SAAS,GAAG,IAAI,CAStF"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createRectangle } from '@flighthq/geometry';
|
|
2
|
+
// Resets the atlas's dirty region to empty. Call it right after uploading the current dirty rect to
|
|
3
|
+
// the GPU texture so the next glyph addition starts a fresh region.
|
|
4
|
+
export function clearGlyphAtlasDirty(atlas) {
|
|
5
|
+
atlas.runtime.dirty = false;
|
|
6
|
+
}
|
|
7
|
+
// Returns the union of every atlas rectangle written since the last `clearGlyphAtlasDirty` (a fresh
|
|
8
|
+
// `Rectangle`), or null when nothing has changed. A renderer uploads only this sub-rect of the atlas
|
|
9
|
+
// surface to its GPU texture, then clears it.
|
|
10
|
+
export function getGlyphAtlasDirtyRegion(atlas) {
|
|
11
|
+
const runtime = atlas.runtime;
|
|
12
|
+
if (!runtime.dirty)
|
|
13
|
+
return null;
|
|
14
|
+
return createRectangle(runtime.dirtyMinX, runtime.dirtyMinY, runtime.dirtyMaxX - runtime.dirtyMinX, runtime.dirtyMaxY - runtime.dirtyMinY);
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=glyphAtlasDirty.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glyphAtlasDirty.js","sourceRoot":"","sources":["../src/glyphAtlasDirty.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAGrD,oGAAoG;AACpG,oEAAoE;AACpE,MAAM,UAAU,oBAAoB,CAAC,KAA2B;IAC9D,KAAK,CAAC,OAAO,CAAC,KAAK,GAAG,KAAK,CAAC;AAC9B,CAAC;AAED,oGAAoG;AACpG,qGAAqG;AACrG,8CAA8C;AAC9C,MAAM,UAAU,wBAAwB,CAAC,KAA2B;IAClE,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC;IAC9B,IAAI,CAAC,OAAO,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IAChC,OAAO,eAAe,CACpB,OAAO,CAAC,SAAS,EACjB,OAAO,CAAC,SAAS,EACjB,OAAO,CAAC,SAAS,GAAG,OAAO,CAAC,SAAS,EACrC,OAAO,CAAC,SAAS,GAAG,OAAO,CAAC,SAAS,CACtC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glyphAtlasEntry.d.ts","sourceRoot":"","sources":["../src/glyphAtlasEntry.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAqB,UAAU,EAAyB,MAAM,iBAAiB,CAAC;AASxG,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAsDpG"}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createSurfaceRegion, writeSurfacePixels } from '@flighthq/surface';
|
|
2
|
+
import { getGlyphRasterizerBackend } from './glyphRasterizerBackend';
|
|
3
|
+
// Returns the cached entry for `codepoint`, or ensures it first on a miss: rasterize via the active
|
|
4
|
+
// backend, pack the bitmap into the atlas (incremental shelf placement; on exhaustion, evict the
|
|
5
|
+
// least-recently-used glyphs and repack), blit its pixels into the atlas surface, record the dirty
|
|
6
|
+
// rect, and cache the entry. Returns null when the glyph cannot be produced — no rasterizer output,
|
|
7
|
+
// or a single glyph larger than the whole atlas. This is the dynamic `GlyphSource.getGlyphEntry`.
|
|
8
|
+
export function getGlyphAtlasEntry(atlas, codepoint) {
|
|
9
|
+
const runtime = atlas.runtime;
|
|
10
|
+
const existing = runtime.entries.get(codepoint);
|
|
11
|
+
if (existing !== undefined) {
|
|
12
|
+
_touchGlyphLru(runtime, codepoint);
|
|
13
|
+
return existing;
|
|
14
|
+
}
|
|
15
|
+
const bitmap = getGlyphRasterizerBackend().rasterize(codepoint, runtime.rasterizeOptions);
|
|
16
|
+
if (bitmap === null)
|
|
17
|
+
return null;
|
|
18
|
+
// A glyph larger than the usable atlas area can never be placed, however much is evicted.
|
|
19
|
+
const padding = runtime.padding;
|
|
20
|
+
const usableWidth = runtime.surface.width - 2 * padding;
|
|
21
|
+
const usableHeight = runtime.surface.height - 2 * padding;
|
|
22
|
+
if (bitmap.width > usableWidth || bitmap.height > usableHeight)
|
|
23
|
+
return null;
|
|
24
|
+
// Evicting for the glyph-count budget frees logical cache slots; the freed atlas space is reclaimed
|
|
25
|
+
// lazily by the first repack that placement forces below.
|
|
26
|
+
let needsRepack = false;
|
|
27
|
+
while (runtime.maxGlyphs > 0 && runtime.entries.size >= runtime.maxGlyphs) {
|
|
28
|
+
if (!_evictLeastRecentlyUsedGlyph(runtime))
|
|
29
|
+
break;
|
|
30
|
+
needsRepack = true;
|
|
31
|
+
}
|
|
32
|
+
let placement = _placeGlyphOnShelf(runtime, bitmap.width, bitmap.height);
|
|
33
|
+
if (placement === null && needsRepack) {
|
|
34
|
+
_repackGlyphAtlas(runtime);
|
|
35
|
+
placement = _placeGlyphOnShelf(runtime, bitmap.width, bitmap.height);
|
|
36
|
+
}
|
|
37
|
+
while (placement === null) {
|
|
38
|
+
// The usable-bounds check above guarantees the glyph fits in an empty atlas, so this sentinel is
|
|
39
|
+
// defensive: an empty cache that still cannot place means the glyph exceeds the atlas.
|
|
40
|
+
if (runtime.entries.size === 0)
|
|
41
|
+
return null;
|
|
42
|
+
_evictLeastRecentlyUsedGlyph(runtime);
|
|
43
|
+
_repackGlyphAtlas(runtime);
|
|
44
|
+
placement = _placeGlyphOnShelf(runtime, bitmap.width, bitmap.height);
|
|
45
|
+
}
|
|
46
|
+
const entry = {
|
|
47
|
+
advance: bitmap.advance,
|
|
48
|
+
bearingX: bitmap.bearingX,
|
|
49
|
+
bearingY: bitmap.bearingY,
|
|
50
|
+
height: bitmap.height,
|
|
51
|
+
page: 0, // The dynamic atlas is one growing surface — a single page.
|
|
52
|
+
width: bitmap.width,
|
|
53
|
+
x: placement.x,
|
|
54
|
+
y: placement.y,
|
|
55
|
+
};
|
|
56
|
+
runtime.entries.set(codepoint, entry);
|
|
57
|
+
runtime.bitmaps.set(codepoint, bitmap);
|
|
58
|
+
runtime.lru.push(codepoint);
|
|
59
|
+
_blitGlyphIntoAtlasSurface(runtime, entry, bitmap);
|
|
60
|
+
return entry;
|
|
61
|
+
}
|
|
62
|
+
// Writes the glyph's RGBA pixels into the atlas surface at the entry's rect and unions that rect into
|
|
63
|
+
// the dirty region for incremental upload.
|
|
64
|
+
function _blitGlyphIntoAtlasSurface(runtime, entry, bitmap) {
|
|
65
|
+
const region = createSurfaceRegion(runtime.surface, entry.x, entry.y, entry.width, entry.height);
|
|
66
|
+
writeSurfacePixels(region, bitmap.pixels);
|
|
67
|
+
_markGlyphAtlasDirtyRect(runtime, entry.x, entry.y, entry.width, entry.height);
|
|
68
|
+
}
|
|
69
|
+
// Evicts the least-recently-used glyph (front of the LRU list), removing its entry and retained
|
|
70
|
+
// bitmap. Its atlas space is not reclaimed until the next repack. Returns false when nothing is
|
|
71
|
+
// cached.
|
|
72
|
+
function _evictLeastRecentlyUsedGlyph(runtime) {
|
|
73
|
+
const codepoint = runtime.lru.shift();
|
|
74
|
+
if (codepoint === undefined)
|
|
75
|
+
return false;
|
|
76
|
+
runtime.entries.delete(codepoint);
|
|
77
|
+
runtime.bitmaps.delete(codepoint);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
// Unions the rectangle (`x`,`y`,`width`,`height`) into the atlas's dirty region, starting a fresh
|
|
81
|
+
// region when none is pending.
|
|
82
|
+
function _markGlyphAtlasDirtyRect(runtime, x, y, width, height) {
|
|
83
|
+
const maxX = x + width;
|
|
84
|
+
const maxY = y + height;
|
|
85
|
+
if (!runtime.dirty) {
|
|
86
|
+
runtime.dirty = true;
|
|
87
|
+
runtime.dirtyMinX = x;
|
|
88
|
+
runtime.dirtyMinY = y;
|
|
89
|
+
runtime.dirtyMaxX = maxX;
|
|
90
|
+
runtime.dirtyMaxY = maxY;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
runtime.dirtyMinX = Math.min(runtime.dirtyMinX, x);
|
|
94
|
+
runtime.dirtyMinY = Math.min(runtime.dirtyMinY, y);
|
|
95
|
+
runtime.dirtyMaxX = Math.max(runtime.dirtyMaxX, maxX);
|
|
96
|
+
runtime.dirtyMaxY = Math.max(runtime.dirtyMaxY, maxY);
|
|
97
|
+
}
|
|
98
|
+
// Incrementally places a `width x height` glyph with the shelf packer: it reuses the shortest shelf
|
|
99
|
+
// tall enough with horizontal room (best-height-fit), else opens a new shelf at the current bottom.
|
|
100
|
+
// Padding is honored as a gutter to the left/right of each glyph and from the atlas edges. Returns
|
|
101
|
+
// the top-left placement, or null when neither an existing shelf nor a new one has room.
|
|
102
|
+
function _placeGlyphOnShelf(runtime, width, height) {
|
|
103
|
+
const padding = runtime.padding;
|
|
104
|
+
const surface = runtime.surface;
|
|
105
|
+
const rightLimit = surface.width - padding;
|
|
106
|
+
let best = null;
|
|
107
|
+
let bestSlack = Number.POSITIVE_INFINITY;
|
|
108
|
+
for (const shelf of runtime.shelves) {
|
|
109
|
+
if (shelf.height < height)
|
|
110
|
+
continue;
|
|
111
|
+
if (shelf.cursorX + width > rightLimit)
|
|
112
|
+
continue;
|
|
113
|
+
const slack = shelf.height - height;
|
|
114
|
+
if (slack < bestSlack) {
|
|
115
|
+
best = shelf;
|
|
116
|
+
bestSlack = slack;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (best !== null) {
|
|
120
|
+
const x = best.cursorX;
|
|
121
|
+
best.cursorX = x + width + padding;
|
|
122
|
+
return { x, y: best.y };
|
|
123
|
+
}
|
|
124
|
+
const y = runtime.packBottom;
|
|
125
|
+
if (y + height > surface.height - padding)
|
|
126
|
+
return null;
|
|
127
|
+
if (padding + width > rightLimit)
|
|
128
|
+
return null;
|
|
129
|
+
runtime.shelves.push({ cursorX: padding + width + padding, height, y });
|
|
130
|
+
runtime.packBottom = y + height + padding;
|
|
131
|
+
return { x: padding, y };
|
|
132
|
+
}
|
|
133
|
+
// Rebuilds the atlas from its surviving cached glyphs to reclaim the space freed by eviction: it
|
|
134
|
+
// clears the surface and shelf state, re-places every survivor (tallest first, for tight shelf
|
|
135
|
+
// packing), re-blits its pixels, and updates its entry's position in place. A survivor that no
|
|
136
|
+
// longer fits is dropped. The whole atlas is marked dirty since glyphs have moved.
|
|
137
|
+
function _repackGlyphAtlas(runtime) {
|
|
138
|
+
runtime.shelves.length = 0;
|
|
139
|
+
runtime.packBottom = runtime.padding;
|
|
140
|
+
runtime.surface.data.fill(0);
|
|
141
|
+
const codepoints = [...runtime.entries.keys()].sort((a, b) => {
|
|
142
|
+
const heightDelta = runtime.entries.get(b).height - runtime.entries.get(a).height;
|
|
143
|
+
return heightDelta !== 0 ? heightDelta : a - b;
|
|
144
|
+
});
|
|
145
|
+
for (const codepoint of codepoints) {
|
|
146
|
+
const entry = runtime.entries.get(codepoint);
|
|
147
|
+
const bitmap = runtime.bitmaps.get(codepoint);
|
|
148
|
+
const placement = _placeGlyphOnShelf(runtime, bitmap.width, bitmap.height);
|
|
149
|
+
if (placement === null) {
|
|
150
|
+
runtime.entries.delete(codepoint);
|
|
151
|
+
runtime.bitmaps.delete(codepoint);
|
|
152
|
+
const lruIndex = runtime.lru.indexOf(codepoint);
|
|
153
|
+
if (lruIndex !== -1)
|
|
154
|
+
runtime.lru.splice(lruIndex, 1);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
entry.x = placement.x;
|
|
158
|
+
entry.y = placement.y;
|
|
159
|
+
const region = createSurfaceRegion(runtime.surface, entry.x, entry.y, entry.width, entry.height);
|
|
160
|
+
writeSurfacePixels(region, bitmap.pixels);
|
|
161
|
+
}
|
|
162
|
+
_markGlyphAtlasDirtyRect(runtime, 0, 0, runtime.surface.width, runtime.surface.height);
|
|
163
|
+
}
|
|
164
|
+
// Moves `codepoint` to the most-recently-used end of the LRU list so eviction takes the oldest first.
|
|
165
|
+
function _touchGlyphLru(runtime, codepoint) {
|
|
166
|
+
const index = runtime.lru.indexOf(codepoint);
|
|
167
|
+
if (index !== -1)
|
|
168
|
+
runtime.lru.splice(index, 1);
|
|
169
|
+
runtime.lru.push(codepoint);
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=glyphAtlasEntry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glyphAtlasEntry.js","sourceRoot":"","sources":["../src/glyphAtlasEntry.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { GlyphAtlas, GlyphMetrics } from '@flighthq/types';
|
|
2
|
+
export declare function getGlyphAtlasKerning(_atlas: Readonly<GlyphAtlas>, _left: number, _right: number): number;
|
|
3
|
+
export declare function getGlyphAtlasMetrics(atlas: Readonly<GlyphAtlas>): Readonly<GlyphMetrics>;
|
|
4
|
+
//# sourceMappingURL=glyphAtlasMetrics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glyphAtlasMetrics.d.ts","sourceRoot":"","sources":["../src/glyphAtlasMetrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAKhE,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,QAAQ,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAExG;AAID,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,YAAY,CAAC,CAExF"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The horizontal kerning adjustment (pixels) between an adjacent `left`/`right` glyph pair. The
|
|
2
|
+
// dynamic canvas rasterizer exposes no pair kerning, so this is 0 in the first build; a native
|
|
3
|
+
// shaping backend or a later kerning-table source can fill it in without changing the seam.
|
|
4
|
+
export function getGlyphAtlasKerning(_atlas, _left, _right) {
|
|
5
|
+
return 0;
|
|
6
|
+
}
|
|
7
|
+
// The atlas's shared line metrics (ascent/descent/lineGap), as the `GlyphSource` metrics. Currently
|
|
8
|
+
// derived from the configured font size at creation (see `deriveGlyphMetricsFromFontSize`).
|
|
9
|
+
export function getGlyphAtlasMetrics(atlas) {
|
|
10
|
+
return atlas.runtime.metrics;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=glyphAtlasMetrics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glyphAtlasMetrics.js","sourceRoot":"","sources":["../src/glyphAtlasMetrics.ts"],"names":[],"mappings":"AAEA,gGAAgG;AAChG,+FAA+F;AAC/F,4FAA4F;AAC5F,MAAM,UAAU,oBAAoB,CAAC,MAA4B,EAAE,KAAa,EAAE,MAAc;IAC9F,OAAO,CAAC,CAAC;AACX,CAAC;AAED,oGAAoG;AACpG,4FAA4F;AAC5F,MAAM,UAAU,oBAAoB,CAAC,KAA2B;IAC9D,OAAO,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;AAC/B,CAAC"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { GlyphRasterizerBackend } from '@flighthq/types';
|
|
2
|
+
export declare function createWebGlyphRasterizerBackend(): GlyphRasterizerBackend;
|
|
3
|
+
export declare function getGlyphRasterizerBackend(): GlyphRasterizerBackend;
|
|
4
|
+
export declare function setGlyphRasterizerBackend(backend: GlyphRasterizerBackend | null): void;
|
|
5
|
+
//# sourceMappingURL=glyphRasterizerBackend.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glyphRasterizerBackend.d.ts","sourceRoot":"","sources":["../src/glyphRasterizerBackend.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgD,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAQ5G,wBAAgB,+BAA+B,IAAI,sBAAsB,CAQxE;AAID,wBAAgB,yBAAyB,IAAI,sBAAsB,CAGlE;AAGD,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,GAAG,IAAI,CAEtF"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Builds the default web backend, which rasterizes a glyph on an offscreen canvas: it sets the font,
|
|
2
|
+
// measures the glyph box + advance with `measureText`, fills the glyph with `fillText`, and reads the
|
|
3
|
+
// pixels back with `getImageData`. Nothing touches the DOM at construction time — the canvas is
|
|
4
|
+
// acquired lazily on the first `rasterize` — so importing the package has no side effect. When no
|
|
5
|
+
// canvas is available (a headless/native host, or jsdom without a 2D context) `rasterize` returns
|
|
6
|
+
// null rather than throwing; a native host installs its own backend via `setGlyphRasterizerBackend`.
|
|
7
|
+
export function createWebGlyphRasterizerBackend() {
|
|
8
|
+
return {
|
|
9
|
+
rasterize(codepoint, options) {
|
|
10
|
+
const context = _acquireGlyphRasterContext();
|
|
11
|
+
if (context === null)
|
|
12
|
+
return null;
|
|
13
|
+
return _rasterizeGlyphOnContext(context, codepoint, options);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
// The active rasterizer backend, lazily defaulting to the web canvas backend. There is always a
|
|
18
|
+
// backend; `getGlyphAtlasEntry` asks this one to render a missing glyph.
|
|
19
|
+
export function getGlyphRasterizerBackend() {
|
|
20
|
+
if (_backend === null)
|
|
21
|
+
_backend = createWebGlyphRasterizerBackend();
|
|
22
|
+
return _backend;
|
|
23
|
+
}
|
|
24
|
+
// Installs a native host rasterizer backend; pass null to fall back to the lazy web default.
|
|
25
|
+
export function setGlyphRasterizerBackend(backend) {
|
|
26
|
+
_backend = backend;
|
|
27
|
+
}
|
|
28
|
+
let _backend = null;
|
|
29
|
+
// Acquires a 2D drawing context from whichever canvas the host offers — an `OffscreenCanvas` first,
|
|
30
|
+
// then a DOM `<canvas>` — or null when neither exists (or has no 2D context). The whole acquisition
|
|
31
|
+
// is guarded so a host that throws on `getContext` degrades to the null sentinel instead of failing.
|
|
32
|
+
function _acquireGlyphRasterContext() {
|
|
33
|
+
try {
|
|
34
|
+
if (typeof OffscreenCanvas !== 'undefined') {
|
|
35
|
+
const context = new OffscreenCanvas(1, 1).getContext('2d');
|
|
36
|
+
if (context !== null)
|
|
37
|
+
return context;
|
|
38
|
+
}
|
|
39
|
+
if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
|
|
40
|
+
const context = document.createElement('canvas').getContext('2d');
|
|
41
|
+
if (context !== null)
|
|
42
|
+
return context;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
// Renders one glyph onto `context` and reads it back as a straight-alpha RGBA bitmap. The context's
|
|
51
|
+
// canvas is resized to the measured ink box (plus a 1px guard on each side so anti-aliased edges are
|
|
52
|
+
// not clipped); the glyph is filled in opaque white at the baseline so a text renderer can tint it.
|
|
53
|
+
// Returns null for a zero-area glyph (whitespace with no ink) so the caller records only real glyphs.
|
|
54
|
+
function _rasterizeGlyphOnContext(context, codepoint, options) {
|
|
55
|
+
const text = String.fromCodePoint(codepoint);
|
|
56
|
+
const fontStyle = options.fontStyle ?? 'normal';
|
|
57
|
+
const fontWeight = options.fontWeight ?? 'normal';
|
|
58
|
+
context.font = `${fontStyle} ${fontWeight} ${options.fontSize}px ${options.fontFamily}`;
|
|
59
|
+
context.textBaseline = 'alphabetic';
|
|
60
|
+
context.textAlign = 'left';
|
|
61
|
+
const metrics = context.measureText(text);
|
|
62
|
+
const advance = metrics.width;
|
|
63
|
+
const left = metrics.actualBoundingBoxLeft ?? 0;
|
|
64
|
+
const right = metrics.actualBoundingBoxRight ?? advance;
|
|
65
|
+
const ascent = metrics.actualBoundingBoxAscent ?? options.fontSize;
|
|
66
|
+
const descent = metrics.actualBoundingBoxDescent ?? 0;
|
|
67
|
+
const guard = 1;
|
|
68
|
+
const width = Math.max(0, Math.ceil(left + right)) + guard * 2;
|
|
69
|
+
const height = Math.max(0, Math.ceil(ascent + descent)) + guard * 2;
|
|
70
|
+
if (width <= guard * 2 || height <= guard * 2)
|
|
71
|
+
return null;
|
|
72
|
+
const canvas = context.canvas;
|
|
73
|
+
canvas.width = width;
|
|
74
|
+
canvas.height = height;
|
|
75
|
+
// font resets when the canvas is resized, so restore it before drawing.
|
|
76
|
+
context.font = `${fontStyle} ${fontWeight} ${options.fontSize}px ${options.fontFamily}`;
|
|
77
|
+
context.textBaseline = 'alphabetic';
|
|
78
|
+
context.textAlign = 'left';
|
|
79
|
+
context.clearRect(0, 0, width, height);
|
|
80
|
+
context.fillStyle = '#ffffff';
|
|
81
|
+
context.fillText(text, guard + left, guard + ascent);
|
|
82
|
+
const image = context.getImageData(0, 0, width, height);
|
|
83
|
+
return {
|
|
84
|
+
advance,
|
|
85
|
+
bearingX: -left,
|
|
86
|
+
bearingY: ascent,
|
|
87
|
+
height,
|
|
88
|
+
pixels: new Uint8ClampedArray(image.data),
|
|
89
|
+
width,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=glyphRasterizerBackend.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glyphRasterizerBackend.js","sourceRoot":"","sources":["../src/glyphRasterizerBackend.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glyphSource.d.ts","sourceRoot":"","sources":["../src/glyphSource.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAU/D,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,WAAW,CAiBxF"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { getGlyphAtlasSurface } from './glyphAtlas';
|
|
2
|
+
import { getGlyphAtlasEntry } from './glyphAtlasEntry';
|
|
3
|
+
import { getGlyphAtlasKerning, getGlyphAtlasMetrics } from './glyphAtlasMetrics';
|
|
4
|
+
// Adapts a `GlyphAtlas` into the `GlyphSource` seam a text renderer consumes, binding the atlas's
|
|
5
|
+
// free functions into the method object. `getGlyphEntry` ensures-then-returns (rasterize-on-miss),
|
|
6
|
+
// so a renderer drawing a string just asks for each glyph. This is the dynamic implementation of
|
|
7
|
+
// `GlyphSource`; `@flighthq/bitmapfont` will provide a static one of the same shape.
|
|
8
|
+
export function createGlyphSourceFromGlyphAtlas(atlas) {
|
|
9
|
+
return {
|
|
10
|
+
getGlyphAtlasImage(page = 0) {
|
|
11
|
+
// One growing surface = page 0; a `Surface` is an `ImageResource`, so this pairs the geometry
|
|
12
|
+
// seam with its pixels directly.
|
|
13
|
+
return page === 0 ? getGlyphAtlasSurface(atlas) : null;
|
|
14
|
+
},
|
|
15
|
+
getGlyphEntry(codepoint) {
|
|
16
|
+
return getGlyphAtlasEntry(atlas, codepoint);
|
|
17
|
+
},
|
|
18
|
+
getGlyphKerning(left, right) {
|
|
19
|
+
return getGlyphAtlasKerning(atlas, left, right);
|
|
20
|
+
},
|
|
21
|
+
getGlyphMetrics() {
|
|
22
|
+
return getGlyphAtlasMetrics(atlas);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=glyphSource.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"glyphSource.js","sourceRoot":"","sources":["../src/glyphSource.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAEjF,kGAAkG;AAClG,mGAAmG;AACnG,iGAAiG;AACjG,qFAAqF;AACrF,MAAM,UAAU,+BAA+B,CAAC,KAA2B;IACzE,OAAO;QACL,kBAAkB,CAAC,IAAI,GAAG,CAAC;YACzB,8FAA8F;YAC9F,iCAAiC;YACjC,OAAO,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QACzD,CAAC;QACD,aAAa,CAAC,SAAS;YACrB,OAAO,kBAAkB,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC;QAC9C,CAAC;QACD,eAAe,CAAC,IAAI,EAAE,KAAK;YACzB,OAAO,oBAAoB,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;QAClD,CAAC;QACD,eAAe;YACb,OAAO,oBAAoB,CAAC,KAAK,CAAC,CAAC;QACrC,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,eAAe,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,eAAe,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flighthq/glyphatlas",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"src/**/*.test.ts",
|
|
16
|
+
"!dist/**/*.test.js",
|
|
17
|
+
"!dist/**/*.test.d.ts",
|
|
18
|
+
"!dist/**/*.test.js.map",
|
|
19
|
+
"!dist/**/*.test.d.ts.map"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -b",
|
|
23
|
+
"clean": "tsc -b --clean",
|
|
24
|
+
"test": "vitest run --config vitest.config.ts",
|
|
25
|
+
"test:watch": "vitest --watch --config vitest.config.ts",
|
|
26
|
+
"prepack": "npm run clean && npm run clean:dist && npm run build",
|
|
27
|
+
"clean:dist": "tsx ../../scripts/clean-package-dist.ts"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@flighthq/geometry": "0.1.0",
|
|
31
|
+
"@flighthq/surface": "0.1.0",
|
|
32
|
+
"@flighthq/types": "0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"typescript": "^5.3.0"
|
|
36
|
+
},
|
|
37
|
+
"description": "Dynamic glyph atlas — rasterize-on-miss into a growing texture atlas with LRU eviction and dirty-region tracking, behind a swappable rasterizer backend; owns and implements the GlyphSource seam for GPU text",
|
|
38
|
+
"sideEffects": false
|
|
39
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { GlyphRasterizerBackend } from '@flighthq/types';
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createGlyphAtlas,
|
|
6
|
+
deriveGlyphMetricsFromFontSize,
|
|
7
|
+
disposeGlyphAtlas,
|
|
8
|
+
getGlyphAtlasSurface,
|
|
9
|
+
} from './glyphAtlas';
|
|
10
|
+
import { getGlyphAtlasEntry } from './glyphAtlasEntry';
|
|
11
|
+
import { setGlyphRasterizerBackend } from './glyphRasterizerBackend';
|
|
12
|
+
|
|
13
|
+
describe('createGlyphAtlas', () => {
|
|
14
|
+
it('allocates an atlas surface at the requested size with an empty cache', () => {
|
|
15
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 64, width: 128 });
|
|
16
|
+
const surface = getGlyphAtlasSurface(atlas);
|
|
17
|
+
|
|
18
|
+
expect(surface.width).toBe(128);
|
|
19
|
+
expect(surface.height).toBe(64);
|
|
20
|
+
expect(atlas.runtime.entries.size).toBe(0);
|
|
21
|
+
expect(atlas.runtime.padding).toBe(1);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('deriveGlyphMetricsFromFontSize', () => {
|
|
26
|
+
it('splits the em into ascent, descent, and zero line gap', () => {
|
|
27
|
+
expect(deriveGlyphMetricsFromFontSize(10)).toEqual({ ascent: 8, descent: 2, lineGap: 0 });
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('disposeGlyphAtlas', () => {
|
|
32
|
+
afterEach(() => setGlyphRasterizerBackend(null));
|
|
33
|
+
|
|
34
|
+
it('clears the cache so a subsequent lookup re-rasterizes', () => {
|
|
35
|
+
const { backend, calls } = createMockRasterizerBackend();
|
|
36
|
+
setGlyphRasterizerBackend(backend);
|
|
37
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 128, width: 128 });
|
|
38
|
+
|
|
39
|
+
getGlyphAtlasEntry(atlas, 65);
|
|
40
|
+
disposeGlyphAtlas(atlas);
|
|
41
|
+
expect(atlas.runtime.entries.size).toBe(0);
|
|
42
|
+
|
|
43
|
+
getGlyphAtlasEntry(atlas, 65);
|
|
44
|
+
expect(calls).toEqual([65, 65]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('getGlyphAtlasSurface', () => {
|
|
49
|
+
it('returns the atlas backing surface', () => {
|
|
50
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 32, width: 32 });
|
|
51
|
+
expect(getGlyphAtlasSurface(atlas)).toBe(atlas.runtime.surface);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function createMockRasterizerBackend(): { backend: GlyphRasterizerBackend; calls: number[] } {
|
|
56
|
+
const calls: number[] = [];
|
|
57
|
+
const backend: GlyphRasterizerBackend = {
|
|
58
|
+
rasterize(codepoint) {
|
|
59
|
+
calls.push(codepoint);
|
|
60
|
+
return { advance: 8, bearingX: 1, bearingY: 8, height: 8, pixels: new Uint8ClampedArray(8 * 8 * 4), width: 8 };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
return { backend, calls };
|
|
64
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { GlyphRasterizerBackend } from '@flighthq/types';
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createGlyphAtlas } from './glyphAtlas';
|
|
5
|
+
import { clearGlyphAtlasDirty, getGlyphAtlasDirtyRegion } from './glyphAtlasDirty';
|
|
6
|
+
import { getGlyphAtlasEntry } from './glyphAtlasEntry';
|
|
7
|
+
import { setGlyphRasterizerBackend } from './glyphRasterizerBackend';
|
|
8
|
+
|
|
9
|
+
describe('clearGlyphAtlasDirty', () => {
|
|
10
|
+
afterEach(() => setGlyphRasterizerBackend(null));
|
|
11
|
+
|
|
12
|
+
it('resets the dirty region to null', () => {
|
|
13
|
+
setGlyphRasterizerBackend(createMockRasterizerBackend());
|
|
14
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 128, width: 128 });
|
|
15
|
+
|
|
16
|
+
getGlyphAtlasEntry(atlas, 65);
|
|
17
|
+
expect(getGlyphAtlasDirtyRegion(atlas)).not.toBeNull();
|
|
18
|
+
|
|
19
|
+
clearGlyphAtlasDirty(atlas);
|
|
20
|
+
expect(getGlyphAtlasDirtyRegion(atlas)).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('getGlyphAtlasDirtyRegion', () => {
|
|
25
|
+
afterEach(() => setGlyphRasterizerBackend(null));
|
|
26
|
+
|
|
27
|
+
it('is null on a fresh atlas', () => {
|
|
28
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 128, width: 128 });
|
|
29
|
+
expect(getGlyphAtlasDirtyRegion(atlas)).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('covers a newly added glyph rect', () => {
|
|
33
|
+
setGlyphRasterizerBackend(createMockRasterizerBackend());
|
|
34
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 128, width: 128 });
|
|
35
|
+
|
|
36
|
+
const entry = getGlyphAtlasEntry(atlas, 65)!;
|
|
37
|
+
const region = getGlyphAtlasDirtyRegion(atlas)!;
|
|
38
|
+
|
|
39
|
+
expect(region.x).toBe(entry.x);
|
|
40
|
+
expect(region.y).toBe(entry.y);
|
|
41
|
+
expect(region.width).toBe(entry.width);
|
|
42
|
+
expect(region.height).toBe(entry.height);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('re-dirties only the new rect after a clear', () => {
|
|
46
|
+
setGlyphRasterizerBackend(createMockRasterizerBackend());
|
|
47
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 128, width: 128 });
|
|
48
|
+
|
|
49
|
+
getGlyphAtlasEntry(atlas, 65);
|
|
50
|
+
clearGlyphAtlasDirty(atlas);
|
|
51
|
+
const entry = getGlyphAtlasEntry(atlas, 66)!;
|
|
52
|
+
const region = getGlyphAtlasDirtyRegion(atlas)!;
|
|
53
|
+
|
|
54
|
+
expect(region.x).toBe(entry.x);
|
|
55
|
+
expect(region.y).toBe(entry.y);
|
|
56
|
+
expect(region.width).toBe(entry.width);
|
|
57
|
+
expect(region.height).toBe(entry.height);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function createMockRasterizerBackend(): GlyphRasterizerBackend {
|
|
62
|
+
return {
|
|
63
|
+
rasterize(codepoint) {
|
|
64
|
+
const size = 8 + (codepoint % 4);
|
|
65
|
+
return {
|
|
66
|
+
advance: size,
|
|
67
|
+
bearingX: 1,
|
|
68
|
+
bearingY: size,
|
|
69
|
+
height: size,
|
|
70
|
+
pixels: new Uint8ClampedArray(size * size * 4),
|
|
71
|
+
width: size,
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { getSurfacePixel } from '@flighthq/surface';
|
|
2
|
+
import type { GlyphEntry, GlyphRasterizedBitmap, GlyphRasterizerBackend } from '@flighthq/types';
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { createGlyphAtlas, getGlyphAtlasSurface } from './glyphAtlas';
|
|
6
|
+
import { getGlyphAtlasEntry } from './glyphAtlasEntry';
|
|
7
|
+
import { setGlyphRasterizerBackend } from './glyphRasterizerBackend';
|
|
8
|
+
|
|
9
|
+
describe('getGlyphAtlasEntry', () => {
|
|
10
|
+
afterEach(() => setGlyphRasterizerBackend(null));
|
|
11
|
+
|
|
12
|
+
it('rasterizes a missing glyph once and caches it', () => {
|
|
13
|
+
const { backend, calls } = createMockRasterizerBackend();
|
|
14
|
+
setGlyphRasterizerBackend(backend);
|
|
15
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 256, width: 256 });
|
|
16
|
+
|
|
17
|
+
const first = getGlyphAtlasEntry(atlas, 65);
|
|
18
|
+
const second = getGlyphAtlasEntry(atlas, 65);
|
|
19
|
+
|
|
20
|
+
expect(first).not.toBeNull();
|
|
21
|
+
expect(second).toBe(first);
|
|
22
|
+
expect(calls).toEqual([65]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('passes the rasterized size, advance, and bearing through to the entry', () => {
|
|
26
|
+
const { backend } = createMockRasterizerBackend((cp) => ({ height: 10, width: cp === 65 ? 12 : 6 }));
|
|
27
|
+
setGlyphRasterizerBackend(backend);
|
|
28
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 256, width: 256 });
|
|
29
|
+
|
|
30
|
+
const entry = getGlyphAtlasEntry(atlas, 65)!;
|
|
31
|
+
|
|
32
|
+
expect(entry.width).toBe(12);
|
|
33
|
+
expect(entry.height).toBe(10);
|
|
34
|
+
expect(entry.advance).toBe(12);
|
|
35
|
+
expect(entry.bearingX).toBe(1);
|
|
36
|
+
expect(entry.bearingY).toBe(10);
|
|
37
|
+
expect(entry.page).toBe(0); // The dynamic atlas is a single growing page.
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('places different glyphs in non-overlapping, in-bounds rects', () => {
|
|
41
|
+
const { backend } = createMockRasterizerBackend((cp) => ({ height: 8 + (cp % 5), width: 8 + (cp % 7) }));
|
|
42
|
+
setGlyphRasterizerBackend(backend);
|
|
43
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 256, width: 256 });
|
|
44
|
+
const surface = getGlyphAtlasSurface(atlas);
|
|
45
|
+
|
|
46
|
+
const entries: GlyphEntry[] = [];
|
|
47
|
+
for (let cp = 65; cp < 75; cp++) entries.push(getGlyphAtlasEntry(atlas, cp)!);
|
|
48
|
+
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
expect(entry.x).toBeGreaterThanOrEqual(0);
|
|
51
|
+
expect(entry.y).toBeGreaterThanOrEqual(0);
|
|
52
|
+
expect(entry.x + entry.width).toBeLessThanOrEqual(surface.width);
|
|
53
|
+
expect(entry.y + entry.height).toBeLessThanOrEqual(surface.height);
|
|
54
|
+
}
|
|
55
|
+
expectNoOverlap(entries);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('blits the glyph pixels into the atlas surface at the entry rect', () => {
|
|
59
|
+
const { backend } = createMockRasterizerBackend();
|
|
60
|
+
setGlyphRasterizerBackend(backend);
|
|
61
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 256, width: 256 });
|
|
62
|
+
const surface = getGlyphAtlasSurface(atlas);
|
|
63
|
+
|
|
64
|
+
const entry = getGlyphAtlasEntry(atlas, 0x41)!;
|
|
65
|
+
const corner = getSurfacePixel(surface, entry.x, entry.y);
|
|
66
|
+
const inside = getSurfacePixel(surface, entry.x + 2, entry.y + 2);
|
|
67
|
+
|
|
68
|
+
// The mock fills every glyph pixel with (R = codepoint & 0xff, G = 0x80, B = 0x40, A = 0xff).
|
|
69
|
+
expect((corner >>> 24) & 0xff).toBe(0x41);
|
|
70
|
+
expect((corner >>> 16) & 0xff).toBe(0x80);
|
|
71
|
+
expect((corner >>> 8) & 0xff).toBe(0x40);
|
|
72
|
+
expect(corner & 0xff).toBe(0xff);
|
|
73
|
+
expect(inside).toBe(corner);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('evicts the least-recently-used glyph past the glyph budget and re-rasterizes it on demand', () => {
|
|
77
|
+
const { backend, calls } = createMockRasterizerBackend();
|
|
78
|
+
setGlyphRasterizerBackend(backend);
|
|
79
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 256, maxGlyphs: 2, width: 256 });
|
|
80
|
+
|
|
81
|
+
getGlyphAtlasEntry(atlas, 65);
|
|
82
|
+
getGlyphAtlasEntry(atlas, 66);
|
|
83
|
+
getGlyphAtlasEntry(atlas, 65); // touch 65 so 66 becomes least-recently-used
|
|
84
|
+
getGlyphAtlasEntry(atlas, 67); // over budget -> evicts 66
|
|
85
|
+
expect(calls).toEqual([65, 66, 67]);
|
|
86
|
+
|
|
87
|
+
getGlyphAtlasEntry(atlas, 65); // still cached -> no re-rasterize
|
|
88
|
+
expect(calls).toEqual([65, 66, 67]);
|
|
89
|
+
|
|
90
|
+
getGlyphAtlasEntry(atlas, 66); // evicted -> re-rasterizes
|
|
91
|
+
expect(calls).toEqual([65, 66, 67, 66]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('keeps rects non-overlapping after eviction and repack', () => {
|
|
95
|
+
const { backend } = createMockRasterizerBackend((cp) => ({ height: 8 + (cp % 6), width: 8 + (cp % 4) }));
|
|
96
|
+
setGlyphRasterizerBackend(backend);
|
|
97
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 256, maxGlyphs: 4, width: 256 });
|
|
98
|
+
|
|
99
|
+
// Add eight glyphs through a four-glyph budget: each of the last four insertions evicts an older
|
|
100
|
+
// glyph and repacks. The four most-recent codepoints stay cached, so re-requesting them are pure
|
|
101
|
+
// hits that return their stable post-repack positions.
|
|
102
|
+
for (let cp = 65; cp < 73; cp++) getGlyphAtlasEntry(atlas, cp);
|
|
103
|
+
const surviving: GlyphEntry[] = [];
|
|
104
|
+
for (let cp = 69; cp < 73; cp++) surviving.push(getGlyphAtlasEntry(atlas, cp)!);
|
|
105
|
+
|
|
106
|
+
expect(surviving).toHaveLength(4);
|
|
107
|
+
expectNoOverlap(surviving);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns null for a single glyph larger than the whole atlas', () => {
|
|
111
|
+
const { backend } = createMockRasterizerBackend(() => ({ height: 200, width: 200 }));
|
|
112
|
+
setGlyphRasterizerBackend(backend);
|
|
113
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 64, width: 64 });
|
|
114
|
+
|
|
115
|
+
expect(getGlyphAtlasEntry(atlas, 65)).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns null when the rasterizer produces nothing', () => {
|
|
119
|
+
const backend: GlyphRasterizerBackend = { rasterize: () => null };
|
|
120
|
+
setGlyphRasterizerBackend(backend);
|
|
121
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 256, width: 256 });
|
|
122
|
+
|
|
123
|
+
expect(getGlyphAtlasEntry(atlas, 65)).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
function createMockRasterizerBackend(
|
|
128
|
+
sizeFor: (codepoint: number) => { width: number; height: number } = () => ({ height: 8, width: 8 }),
|
|
129
|
+
): { backend: GlyphRasterizerBackend; calls: number[] } {
|
|
130
|
+
const calls: number[] = [];
|
|
131
|
+
const backend: GlyphRasterizerBackend = {
|
|
132
|
+
rasterize(codepoint): GlyphRasterizedBitmap {
|
|
133
|
+
calls.push(codepoint);
|
|
134
|
+
const { width, height } = sizeFor(codepoint);
|
|
135
|
+
const pixels = new Uint8ClampedArray(width * height * 4);
|
|
136
|
+
for (let i = 0; i < width * height; i++) {
|
|
137
|
+
pixels[i * 4] = codepoint & 0xff;
|
|
138
|
+
pixels[i * 4 + 1] = 0x80;
|
|
139
|
+
pixels[i * 4 + 2] = 0x40;
|
|
140
|
+
pixels[i * 4 + 3] = 0xff;
|
|
141
|
+
}
|
|
142
|
+
return { advance: width, bearingX: 1, bearingY: height, height, pixels, width };
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
return { backend, calls };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function expectNoOverlap(entries: readonly Readonly<GlyphEntry>[]): void {
|
|
149
|
+
for (let i = 0; i < entries.length; i++) {
|
|
150
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
151
|
+
const a = entries[i];
|
|
152
|
+
const b = entries[j];
|
|
153
|
+
const overlaps = a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
|
|
154
|
+
expect(overlaps).toBe(false);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createGlyphAtlas } from './glyphAtlas';
|
|
4
|
+
import { getGlyphAtlasKerning, getGlyphAtlasMetrics } from './glyphAtlasMetrics';
|
|
5
|
+
|
|
6
|
+
describe('getGlyphAtlasKerning', () => {
|
|
7
|
+
it('is zero in the first build (no pair kerning source)', () => {
|
|
8
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 16, height: 64, width: 64 });
|
|
9
|
+
expect(getGlyphAtlasKerning(atlas, 65, 66)).toBe(0);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('getGlyphAtlasMetrics', () => {
|
|
14
|
+
it('returns the font-size-derived line metrics', () => {
|
|
15
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 20, height: 64, width: 64 });
|
|
16
|
+
expect(getGlyphAtlasMetrics(atlas)).toEqual({ ascent: 16, descent: 4, lineGap: 0 });
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { GlyphRasterizerBackend } from '@flighthq/types';
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createWebGlyphRasterizerBackend,
|
|
6
|
+
getGlyphRasterizerBackend,
|
|
7
|
+
setGlyphRasterizerBackend,
|
|
8
|
+
} from './glyphRasterizerBackend';
|
|
9
|
+
|
|
10
|
+
describe('createWebGlyphRasterizerBackend', () => {
|
|
11
|
+
it('constructs a backend without a DOM side effect', () => {
|
|
12
|
+
expect(typeof createWebGlyphRasterizerBackend().rasterize).toBe('function');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('sentinels to null when no 2D canvas context is available', () => {
|
|
16
|
+
// jsdom's HTMLCanvasElement.getContext('2d') returns null without the canvas package installed,
|
|
17
|
+
// and OffscreenCanvas is undefined, so the web backend has no surface to rasterize onto.
|
|
18
|
+
const backend = createWebGlyphRasterizerBackend();
|
|
19
|
+
expect(backend.rasterize(65, { fontFamily: 'sans-serif', fontSize: 16 })).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('getGlyphRasterizerBackend', () => {
|
|
24
|
+
afterEach(() => setGlyphRasterizerBackend(null));
|
|
25
|
+
|
|
26
|
+
it('lazily defaults to a web backend', () => {
|
|
27
|
+
setGlyphRasterizerBackend(null);
|
|
28
|
+
expect(typeof getGlyphRasterizerBackend().rasterize).toBe('function');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns the installed backend', () => {
|
|
32
|
+
const backend: GlyphRasterizerBackend = { rasterize: () => null };
|
|
33
|
+
setGlyphRasterizerBackend(backend);
|
|
34
|
+
expect(getGlyphRasterizerBackend()).toBe(backend);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('setGlyphRasterizerBackend', () => {
|
|
39
|
+
afterEach(() => setGlyphRasterizerBackend(null));
|
|
40
|
+
|
|
41
|
+
it('restores the lazy web default when passed null', () => {
|
|
42
|
+
const backend: GlyphRasterizerBackend = { rasterize: () => null };
|
|
43
|
+
setGlyphRasterizerBackend(backend);
|
|
44
|
+
expect(getGlyphRasterizerBackend()).toBe(backend);
|
|
45
|
+
|
|
46
|
+
setGlyphRasterizerBackend(null);
|
|
47
|
+
expect(getGlyphRasterizerBackend()).not.toBe(backend);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { GlyphRasterizerBackend } from '@flighthq/types';
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createGlyphAtlas, getGlyphAtlasSurface } from './glyphAtlas';
|
|
5
|
+
import { getGlyphAtlasEntry } from './glyphAtlasEntry';
|
|
6
|
+
import { setGlyphRasterizerBackend } from './glyphRasterizerBackend';
|
|
7
|
+
import { createGlyphSourceFromGlyphAtlas } from './glyphSource';
|
|
8
|
+
|
|
9
|
+
describe('createGlyphSourceFromGlyphAtlas', () => {
|
|
10
|
+
afterEach(() => setGlyphRasterizerBackend(null));
|
|
11
|
+
|
|
12
|
+
it('exposes the atlas as a GlyphSource that rasterizes on miss', () => {
|
|
13
|
+
const backend: GlyphRasterizerBackend = {
|
|
14
|
+
rasterize: () => ({
|
|
15
|
+
advance: 8,
|
|
16
|
+
bearingX: 1,
|
|
17
|
+
bearingY: 8,
|
|
18
|
+
height: 8,
|
|
19
|
+
pixels: new Uint8ClampedArray(8 * 8 * 4),
|
|
20
|
+
width: 8,
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
setGlyphRasterizerBackend(backend);
|
|
24
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 20, height: 128, width: 128 });
|
|
25
|
+
const source = createGlyphSourceFromGlyphAtlas(atlas);
|
|
26
|
+
|
|
27
|
+
const entry = source.getGlyphEntry(65);
|
|
28
|
+
expect(entry).not.toBeNull();
|
|
29
|
+
expect(entry!.page).toBe(0);
|
|
30
|
+
expect(source.getGlyphEntry(65)).toBe(getGlyphAtlasEntry(atlas, 65));
|
|
31
|
+
expect(source.getGlyphKerning(65, 66)).toBe(0);
|
|
32
|
+
expect(source.getGlyphMetrics()).toEqual({ ascent: 16, descent: 4, lineGap: 0 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('pairs page 0 with the atlas surface and has no other page', () => {
|
|
36
|
+
const atlas = createGlyphAtlas({ fontFamily: 'mock', fontSize: 20, height: 128, width: 128 });
|
|
37
|
+
const source = createGlyphSourceFromGlyphAtlas(atlas);
|
|
38
|
+
|
|
39
|
+
expect(source.getGlyphAtlasImage(0)).toBe(getGlyphAtlasSurface(atlas));
|
|
40
|
+
expect(source.getGlyphAtlasImage()).toBe(getGlyphAtlasSurface(atlas));
|
|
41
|
+
expect(source.getGlyphAtlasImage(1)).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
});
|