Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/config/font-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export const LINE_HEIGHT_RATIO = 1.2;
export const BASE_RATIO = 0.8;
export const CAP_HEIGHT_RATIO = 0.7;

// Default xadvance for a hand-drawn blank glyph, as a fraction of the cell width.
export const DEFAULT_XADVANCE_RATIO = 0.7;

// Bounds for the fontSize input in the wizards.
export const FONT_SIZE_MIN = 4;
export const FONT_SIZE_MAX = 256;
2 changes: 2 additions & 0 deletions src/core/atlas/pack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function makeGlyph(
codePoint,
fontId: 'font-1',
layers: [makeBaseLayerFromBitmap({ pixels, width, height, xoffset: 0, yoffset: 0 })],
bmf: { xoffset: 0, yoffset: 0, xadvance: width },
pixels,
width,
height,
Expand All @@ -37,6 +38,7 @@ function filledGlyph(codePoint: number, width: number, height: number): Glyph {
codePoint,
fontId: 'font-1',
layers: [makeBaseLayerFromBitmap({ pixels, width, height, xoffset: 0, yoffset: 0 })],
bmf: { xoffset: 0, yoffset: 0, xadvance: width },
pixels,
width,
height,
Expand Down
3 changes: 2 additions & 1 deletion src/core/bmf/serialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ function makeGlyphData(
): BmfGlyphData {
return {
placement: { codePoint, x, y, width: w, height: h, trimX: 0, trimY: 0 },
glyph: { codePoint, xoffset: 0, yoffset: 0, xadvance: w + 1 },
codePoint,
bmf: { xoffset: 0, yoffset: 0, xadvance: w + 1 },
};
}

Expand Down
9 changes: 5 additions & 4 deletions src/core/bmf/serialize.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Font,Glyph, GlyphPlacement } from '../font/types';
import type { BmfGlyphMetadata, Font, GlyphPlacement } from '../font/types';

export interface BmfGlyphData {
placement: GlyphPlacement;
glyph: Pick<Glyph, 'codePoint' | 'xoffset' | 'yoffset' | 'xadvance'>;
codePoint: number;
bmf: BmfGlyphMetadata;
}

export interface BmfSerializeInput {
Expand Down Expand Up @@ -33,11 +34,11 @@ export function serializeBmfText(input: BmfSerializeInput): string {
lines.push(`page id=0 file="${atlasFilename}"`);
lines.push(`chars count=${glyphs.length}`);

for (const { placement: p, glyph: g } of glyphs) {
for (const { placement: p, bmf } of glyphs) {
lines.push(
`char id=${p.codePoint}` +
` x=${p.x} y=${p.y} width=${p.width} height=${p.height}` +
` xoffset=${g.xoffset} yoffset=${g.yoffset} xadvance=${g.xadvance}` +
` xoffset=${bmf.xoffset} yoffset=${bmf.yoffset} xadvance=${bmf.xadvance}` +
` page=0 chnl=15`,
);
}
Expand Down
10 changes: 6 additions & 4 deletions src/core/font/glyphs.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';

import { DEFAULT_XADVANCE_RATIO } from '@/config';

import { initializeGlyphs, makeBlankGlyph } from './glyphs';

describe('makeBlankGlyph', () => {
Expand All @@ -25,12 +27,12 @@ describe('makeBlankGlyph', () => {
}
});

it('defaults xadvance to 0.7× the cell width, rounded', () => {
it('defaults xadvance to DEFAULT_XADVANCE_RATIO × the cell width, rounded', () => {
const glyph = makeBlankGlyph('font-1', 0x41, 8, 12);

expect(glyph.xadvance).toBe(Math.round(8 * 0.7));
expect(glyph.xoffset).toBe(0);
expect(glyph.yoffset).toBe(0);
expect(glyph.bmf.xadvance).toBe(Math.round(8 * DEFAULT_XADVANCE_RATIO));
expect(glyph.bmf.xoffset).toBe(0);
expect(glyph.bmf.yoffset).toBe(0);
});

it('is not marked dirty', () => {
Expand Down
7 changes: 6 additions & 1 deletion src/core/font/glyphs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { DEFAULT_XADVANCE_RATIO } from '@/config';

import { makeBlankLayer } from './layers';
import type { Glyph } from './types';

Expand All @@ -7,16 +9,19 @@ export function makeBlankGlyph(
width: number,
height: number,
): Glyph {
const xadvance = Math.round(width * DEFAULT_XADVANCE_RATIO);

return {
codePoint,
fontId,
layers: [makeBlankLayer()],
bmf: { xoffset: 0, yoffset: 0, xadvance },
pixels: new Uint8Array(width * height),
width,
height,
xoffset: 0,
yoffset: 0,
xadvance: Math.round(width * 0.7),
xadvance,
isDirty: false,
};
}
Expand Down
52 changes: 45 additions & 7 deletions src/core/font/layers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function makeGlyph(layers: Layer[]): Glyph {
codePoint: 0x41,
fontId: 'font-1',
layers,
bmf: { xoffset: 0, yoffset: 0, xadvance: 0 },
pixels: new Uint8Array(0),
width: 0,
height: 0,
Expand All @@ -33,7 +34,13 @@ function makeGlyph(layers: Layer[]): Glyph {
};
}

function inkLayer(width: number, height: number, xoffset: number, yoffset: number, ink: number[]): Layer {
function inkLayer(
width: number,
height: number,
xoffset: number,
yoffset: number,
ink: number[],
): Layer {
const pixels = new Uint8Array(width * height);

ink.forEach((value, index) => {
Expand Down Expand Up @@ -266,15 +273,45 @@ describe('layer mutators', () => {
expect(result.layers[0].xoffset).toBe(5);
});

it('layer mutators keep the legacy fields in sync with flattenGlyph', () => {
it('layer mutators produce a glyph whose flattenGlyph reflects the change', () => {
const layer = inkLayer(2, 1, 3, 4, [255, 128]);
const result = updateLayer(makeGlyph([layer]), layer.id, { xoffset: 10 });
const flat = flattenGlyph(result);

expect(result.width).toBe(2);
expect(result.height).toBe(1);
expect(result.xoffset).toBe(10);
expect(result.yoffset).toBe(4);
expect(Array.from(result.pixels)).toEqual([255, 128]);
expect(flat.width).toBe(2);
expect(flat.height).toBe(1);
expect(flat.xoffset).toBe(10);
expect(flat.yoffset).toBe(4);
expect(Array.from(flat.pixels)).toEqual([255, 128]);
});
});

describe('flattenGlyph cache', () => {
it('returns the same FlattenedGlyph reference for repeat calls on the same glyph', () => {
const layer = inkLayer(2, 1, 0, 0, [255, 128]);
const glyph = makeGlyph([layer]);

expect(flattenGlyph(glyph)).toBe(flattenGlyph(glyph));
});

it('produces a fresh result when the glyph reference changes', () => {
const layer = inkLayer(2, 1, 0, 0, [255, 128]);
const glyph = makeGlyph([layer]);
const first = flattenGlyph(glyph);
const next = updateLayer(glyph, layer.id, { xoffset: 5 });

expect(flattenGlyph(next)).not.toBe(first);
expect(flattenGlyph(next).xoffset).toBe(5);
});

it('skips the cache for includeHidden: true', () => {
const visible = inkLayer(2, 1, 0, 0, [255, 128]);
const hidden = { ...inkLayer(2, 1, 0, 0, [50, 60]), visible: false };
const glyph = makeGlyph([visible, hidden]);

expect(flattenGlyph(glyph, { includeHidden: true })).not.toBe(
flattenGlyph(glyph, { includeHidden: true }),
);
});
});

Expand Down Expand Up @@ -344,6 +381,7 @@ describe('syncLegacyFields', () => {
codePoint: 0x41,
fontId: 'font-1',
layers: [layer],
bmf: { xoffset: 0, yoffset: 0, xadvance: 0 },
pixels: new Uint8Array([0, 0, 0, 0]),
width: 9999,
height: 9999,
Expand Down
78 changes: 66 additions & 12 deletions src/core/font/layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ interface FlattenOptions {
includeHidden?: boolean;
}

// Memoise flattenGlyph by Glyph reference. Every mutation produces a new Glyph
// object (replaceLayer / addLayer / etc.), so reference identity tracks "did
// the layers change?" exactly. The WeakMap drops entries when the Glyph is GC'd.
// `includeHidden: true` is the rare path and isn't worth caching — it skips
// the cache entirely.
const flattenCache = new WeakMap<Glyph, FlattenedGlyph>();

/**
* Composite a glyph's layers into the legacy single-bitmap shape that the export pipeline consumes.
*
Expand All @@ -75,12 +82,33 @@ interface FlattenOptions {
*/
export function flattenGlyph(glyph: Glyph, options: FlattenOptions = {}): FlattenedGlyph {
const includeHidden = options.includeHidden ?? false;

if (!includeHidden) {
const cached = flattenCache.get(glyph);

if (cached) {
return cached;
}
}

const contributing = glyph.layers.filter(
(layer) => (includeHidden || layer.visible) && layer.width > 0 && layer.height > 0,
);

if (contributing.length === 0) {
return { pixels: new Uint8Array(0), width: 0, height: 0, xoffset: 0, yoffset: 0 };
const empty: FlattenedGlyph = {
pixels: new Uint8Array(0),
width: 0,
height: 0,
xoffset: 0,
yoffset: 0,
};

if (!includeHidden) {
flattenCache.set(glyph, empty);
}

return empty;
}

let minX = Infinity;
Expand Down Expand Up @@ -136,7 +164,13 @@ export function flattenGlyph(glyph: Glyph, options: FlattenOptions = {}): Flatte
}
}

return { pixels, width, height, xoffset: minX, yoffset: minY };
const result: FlattenedGlyph = { pixels, width, height, xoffset: minX, yoffset: minY };

if (!includeHidden) {
flattenCache.set(glyph, result);
}

return result;
}

export interface LayerBounds {
Expand Down Expand Up @@ -177,13 +211,21 @@ export function unionLayerBounds(
const layerRight = layerLeft + layer.width;
const layerBottom = layerTop + layer.height;

if (layerLeft < left) {left = layerLeft;}
if (layerLeft < left) {
left = layerLeft;
}

if (layerTop < top) {top = layerTop;}
if (layerTop < top) {
top = layerTop;
}

if (layerRight > right) {right = layerRight;}
if (layerRight > right) {
right = layerRight;
}

if (layerBottom > bottom) {bottom = layerBottom;}
if (layerBottom > bottom) {
bottom = layerBottom;
}

found = true;
}
Expand Down Expand Up @@ -218,13 +260,21 @@ export function trimLayerToInk(layer: Layer): Layer {
continue;
}

if (column < minX) {minX = column;}
if (column < minX) {
minX = column;
}

if (column > maxX) {maxX = column;}
if (column > maxX) {
maxX = column;
}

if (row < minY) {minY = row;}
if (row < minY) {
minY = row;
}

if (row > maxY) {maxY = row;}
if (row > maxY) {
maxY = row;
}
}
}

Expand Down Expand Up @@ -289,7 +339,12 @@ export function syncLegacyFields(glyph: Glyph): Glyph {
}

/** Returns the topmost visible inked layer under the given cell (in glyph cell-space), or null. */
export function hitTestLayer(glyph: Glyph, cellX: number, cellY: number, threshold: number): Layer | null {
export function hitTestLayer(
glyph: Glyph,
cellX: number,
cellY: number,
threshold: number,
): Layer | null {
for (let layerIndex = glyph.layers.length - 1; layerIndex >= 0; layerIndex--) {
const layer = glyph.layers[layerIndex];

Expand Down Expand Up @@ -458,4 +513,3 @@ export function updateLayerPixels(glyph: Glyph, layerId: string, patch: LayerPix

return replaceLayer(glyph, layerId, trimmed);
}

20 changes: 13 additions & 7 deletions src/core/font/portable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface PortableFont {
font: Font;
// Pixels serialized as base64 strings to survive JSON round-trip.
// Layers are reconstructed from the legacy bitmap on import.
glyphs: Array<Omit<Glyph, 'pixels' | 'layers'> & { pixels: string }>;
glyphs: Array<Omit<Glyph, 'pixels' | 'layers' | 'bmf'> & { pixels: string }>;
}

function toBase64(buf: Uint8Array): string {
Expand Down Expand Up @@ -35,14 +35,14 @@ export function exportPortableFont(font: Font, glyphs: Glyph[]): string {
version: 2,
font,
glyphs: glyphs.map((glyph) => {
const rest: Omit<Glyph, 'pixels' | 'layers'> = {
const rest: Omit<Glyph, 'pixels' | 'layers' | 'bmf'> = {
codePoint: glyph.codePoint,
fontId: glyph.fontId,
width: glyph.width,
height: glyph.height,
xoffset: glyph.xoffset,
yoffset: glyph.yoffset,
xadvance: glyph.xadvance,
xoffset: glyph.bmf.xoffset,
yoffset: glyph.bmf.yoffset,
xadvance: glyph.bmf.xadvance,
isDirty: glyph.isDirty,
alphaThreshold: glyph.alphaThreshold,
};
Expand All @@ -67,17 +67,23 @@ export function importPortableFont(json: string): { font: Font; glyphs: Glyph[]

const glyphs: Glyph[] = data.glyphs.map((glyph) => {
const pixels = fromBase64(glyph.pixels);
// v2 carries a single flat bitmap. The xoffset/yoffset stored on v2 are
// the BMF char-line metadata (conflated with the flatten origin in PR 1,
// de-conflated in PR 2). On import we treat them as `bmf` and keep the
// base layer at (0, 0).
const bmf = { xoffset: glyph.xoffset, yoffset: glyph.yoffset, xadvance: glyph.xadvance };

return {
...glyph,
pixels,
bmf,
layers: [
makeBaseLayerFromBitmap({
pixels,
width: glyph.width,
height: glyph.height,
xoffset: glyph.xoffset,
yoffset: glyph.yoffset,
xoffset: 0,
yoffset: 0,
}),
],
};
Expand Down
Loading
Loading