diff --git a/src/config/font-defaults.ts b/src/config/font-defaults.ts index e5af061..a81deb5 100644 --- a/src/config/font-defaults.ts +++ b/src/config/font-defaults.ts @@ -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; diff --git a/src/core/atlas/pack.test.ts b/src/core/atlas/pack.test.ts index 7deb97b..91557c2 100644 --- a/src/core/atlas/pack.test.ts +++ b/src/core/atlas/pack.test.ts @@ -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, @@ -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, diff --git a/src/core/bmf/serialize.test.ts b/src/core/bmf/serialize.test.ts index 591eae3..4870f51 100644 --- a/src/core/bmf/serialize.test.ts +++ b/src/core/bmf/serialize.test.ts @@ -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 }, }; } diff --git a/src/core/bmf/serialize.ts b/src/core/bmf/serialize.ts index 4d74850..f01aa7a 100644 --- a/src/core/bmf/serialize.ts +++ b/src/core/bmf/serialize.ts @@ -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; + codePoint: number; + bmf: BmfGlyphMetadata; } export interface BmfSerializeInput { @@ -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`, ); } diff --git a/src/core/font/glyphs.test.ts b/src/core/font/glyphs.test.ts index cc779b6..0bdc5f2 100644 --- a/src/core/font/glyphs.test.ts +++ b/src/core/font/glyphs.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { DEFAULT_XADVANCE_RATIO } from '@/config'; + import { initializeGlyphs, makeBlankGlyph } from './glyphs'; describe('makeBlankGlyph', () => { @@ -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', () => { diff --git a/src/core/font/glyphs.ts b/src/core/font/glyphs.ts index ad28ac1..a02bb8b 100644 --- a/src/core/font/glyphs.ts +++ b/src/core/font/glyphs.ts @@ -1,3 +1,5 @@ +import { DEFAULT_XADVANCE_RATIO } from '@/config'; + import { makeBlankLayer } from './layers'; import type { Glyph } from './types'; @@ -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, }; } diff --git a/src/core/font/layers.test.ts b/src/core/font/layers.test.ts index 4d30dcd..ff03183 100644 --- a/src/core/font/layers.test.ts +++ b/src/core/font/layers.test.ts @@ -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, @@ -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) => { @@ -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 }), + ); }); }); @@ -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, diff --git a/src/core/font/layers.ts b/src/core/font/layers.ts index 3af4499..c578663 100644 --- a/src/core/font/layers.ts +++ b/src/core/font/layers.ts @@ -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(); + /** * Composite a glyph's layers into the legacy single-bitmap shape that the export pipeline consumes. * @@ -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; @@ -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 { @@ -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; } @@ -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; + } } } @@ -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]; @@ -458,4 +513,3 @@ export function updateLayerPixels(glyph: Glyph, layerId: string, patch: LayerPix return replaceLayer(glyph, layerId, trimmed); } - diff --git a/src/core/font/portable.ts b/src/core/font/portable.ts index e174a07..77fe870 100644 --- a/src/core/font/portable.ts +++ b/src/core/font/portable.ts @@ -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 & { pixels: string }>; + glyphs: Array & { pixels: string }>; } function toBase64(buf: Uint8Array): string { @@ -35,14 +35,14 @@ export function exportPortableFont(font: Font, glyphs: Glyph[]): string { version: 2, font, glyphs: glyphs.map((glyph) => { - const rest: Omit = { + const rest: Omit = { 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, }; @@ -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, }), ], }; diff --git a/src/core/font/types.ts b/src/core/font/types.ts index 7bf7b79..369285d 100644 --- a/src/core/font/types.ts +++ b/src/core/font/types.ts @@ -45,17 +45,34 @@ export interface Layer { locked: boolean; } +/** + * BMF char-line metadata. `xoffset`/`yoffset` are the source-font positioning nudge + * (from TTF bearings or a parsed .fnt) — independent of where ink sits in the layer + * buffer. `xadvance` is the pen-advance in pixels. Exported as the `char` line's + * xoffset/yoffset/xadvance fields, summed with the flatten origin and placement trim. + */ +export interface BmfGlyphMetadata { + xoffset: number; + yoffset: number; + xadvance: number; +} + export interface Glyph { codePoint: number; fontId: string; - // Stage A invariant: layers[0] mirrors the legacy top-level pixel fields exactly. - // Stage B will make layers authoritative and drop the legacy fields. layers: Layer[]; + bmf: BmfGlyphMetadata; + /** @deprecated PR 2 removes this. Use `flattenGlyph(glyph).pixels`. */ pixels: Uint8Array; + /** @deprecated PR 2 removes this. Use `flattenGlyph(glyph).width`. */ width: number; + /** @deprecated PR 2 removes this. Use `flattenGlyph(glyph).height`. */ height: number; + /** @deprecated PR 2 removes this. Use `flattenGlyph(glyph).xoffset` for layout or `glyph.bmf.xoffset` for export. */ xoffset: number; + /** @deprecated PR 2 removes this. Use `flattenGlyph(glyph).yoffset` for layout or `glyph.bmf.yoffset` for export. */ yoffset: number; + /** @deprecated PR 2 removes this. Use `glyph.bmf.xadvance`. */ xadvance: number; isDirty: boolean; alphaThreshold?: number; diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index ed9807c..6021054 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -9,12 +9,25 @@ import { makeBlankGlyph } from './font/glyphs'; import type { Glyph } from './font/types'; function inkBlock(glyph: Glyph, x: number, y: number, width: number, height: number): Glyph { + const layer = glyph.layers[0]; + // The base layer starts at width=0/height=0 — resize it to the glyph cell and ink the inner block. + const pixels = new Uint8Array(glyph.width * glyph.height); + for (let row = y; row < y + height; row++) { for (let column = x; column < x + width; column++) { - glyph.pixels[row * glyph.width + column] = 255; + pixels[row * glyph.width + column] = 255; } } + layer.pixels = pixels; + layer.width = glyph.width; + layer.height = glyph.height; + layer.xoffset = 0; + layer.yoffset = 0; + + // Mirror to legacy fields so packGlyphs (which still goes via flattenGlyph) sees the ink. + glyph.pixels = pixels; + glyph.bmf = { xoffset: 1, yoffset: 2, xadvance: glyph.width + 1 }; glyph.xoffset = 1; glyph.yoffset = 2; glyph.xadvance = glyph.width + 1; @@ -53,12 +66,8 @@ describe('pack → serialize → parse round-trip', () => { return { placement, - glyph: { - codePoint: sourceGlyph.codePoint, - xoffset: sourceGlyph.xoffset, - yoffset: sourceGlyph.yoffset, - xadvance: sourceGlyph.xadvance, - }, + codePoint: sourceGlyph.codePoint, + bmf: sourceGlyph.bmf, }; }); @@ -89,9 +98,9 @@ describe('pack → serialize → parse round-trip', () => { expect(parsedChar?.y).toBe(entry.placement.y); expect(parsedChar?.width).toBe(entry.placement.width); expect(parsedChar?.height).toBe(entry.placement.height); - expect(parsedChar?.xoffset).toBe(entry.glyph.xoffset); - expect(parsedChar?.yoffset).toBe(entry.glyph.yoffset); - expect(parsedChar?.xadvance).toBe(entry.glyph.xadvance); + expect(parsedChar?.xoffset).toBe(entry.bmf.xoffset); + expect(parsedChar?.yoffset).toBe(entry.bmf.yoffset); + expect(parsedChar?.xadvance).toBe(entry.bmf.xadvance); } }); }); diff --git a/src/db/glyphs.ts b/src/db/glyphs.ts index c6f15fe..d497f41 100644 --- a/src/db/glyphs.ts +++ b/src/db/glyphs.ts @@ -11,8 +11,25 @@ export async function saveGlyphs(glyphs: Glyph[]): Promise { await db.glyphs.bulkPut(records); } +// Records persisted before PR 1 lack the `bmf` sub-object. Synthesise it from +// the legacy top-level fields so the in-memory shape is always complete. +// PR 2 introduces a v6 DB migration that does this on disk and lets us drop +// this defensive normalisation. +function normaliseBmf(record: Glyph): Glyph { + if (record.bmf) { + return record; + } + + return { + ...record, + bmf: { xoffset: record.xoffset, yoffset: record.yoffset, xadvance: record.xadvance }, + }; +} + export async function getGlyphsForFont(fontId: string): Promise { - return db.glyphs.where('fontId').equals(fontId).toArray(); + const records = await db.glyphs.where('fontId').equals(fontId).toArray(); + + return records.map(normaliseBmf); } export async function deleteGlyph(fontId: string, codePoint: number): Promise { diff --git a/src/features/editor/PreviewFloat.tsx b/src/features/editor/PreviewFloat.tsx index cf60875..58a275c 100644 --- a/src/features/editor/PreviewFloat.tsx +++ b/src/features/editor/PreviewFloat.tsx @@ -9,6 +9,7 @@ import { PREVIEW_MISSING_GLYPH_ADVANCE_RATIO, PREVIEW_PLACEHOLDER_HEIGHT_RATIO, } from '@/config'; +import { flattenGlyph } from '@/core/font/layers'; import { effectiveThreshold } from '@/core/font/threshold'; import { useStore } from '@/store'; @@ -44,7 +45,7 @@ export const PreviewFloat = ({ open, onClose }: Props): React.JSX.Element => { const glyph = glyphMap.get(cp); totalWidth += glyph - ? glyph.xadvance + spacing.x + ? glyph.bmf.xadvance + spacing.x : Math.round(currentFont.settings.fontSize * PREVIEW_MISSING_GLYPH_ADVANCE_RATIO); } @@ -65,13 +66,13 @@ export const PreviewFloat = ({ open, onClose }: Props): React.JSX.Element => { for (const cp of codePoints) { const glyph = glyphMap.get(cp); + const flat = glyph ? flattenGlyph(glyph) : null; - if (!glyph || glyph.width === 0 || glyph.height === 0) { + if (!glyph || !flat || flat.width === 0 || flat.height === 0) { const advance = glyph - ? glyph.xadvance + ? glyph.bmf.xadvance : Math.round(currentFont.settings.fontSize * PREVIEW_MISSING_GLYPH_ADVANCE_RATIO); - const placeholderHeight = - currentFont.settings.fontSize * PREVIEW_PLACEHOLDER_HEIGHT_RATIO; + const placeholderHeight = currentFont.settings.fontSize * PREVIEW_PLACEHOLDER_HEIGHT_RATIO; context.strokeStyle = 'rgba(255,255,255,0.2)'; context.strokeRect( @@ -85,14 +86,14 @@ export const PreviewFloat = ({ open, onClose }: Props): React.JSX.Element => { continue; } - const destX = x + glyph.xoffset; - const destY = glyph.yoffset; + const destX = x + glyph.bmf.xoffset + flat.xoffset; + const destY = glyph.bmf.yoffset + flat.yoffset; - const imageData = new ImageData(glyph.width, glyph.height); + const imageData = new ImageData(flat.width, flat.height); const threshold = effectiveThreshold(glyph, currentFont.settings); - for (let index = 0; index < glyph.pixels.length; index++) { - const ink = glyph.pixels[index] >= threshold ? 255 : 0; + for (let index = 0; index < flat.pixels.length; index++) { + const ink = flat.pixels[index] >= threshold ? 255 : 0; imageData.data[index * 4 + 0] = 255; imageData.data[index * 4 + 1] = 255; @@ -102,13 +103,13 @@ export const PreviewFloat = ({ open, onClose }: Props): React.JSX.Element => { const offscreen = document.createElement('canvas'); - offscreen.width = glyph.width; - offscreen.height = glyph.height; + offscreen.width = flat.width; + offscreen.height = flat.height; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion offscreen.getContext('2d')!.putImageData(imageData, 0, 0); // offscreen canvas always has a 2D context context.drawImage(offscreen, destX, destY); - x += glyph.xadvance + spacing.x; + x += glyph.bmf.xadvance + spacing.x; } }, [text, glyphs, currentFont]); diff --git a/src/features/editor/glyph-list/GlyphList.tsx b/src/features/editor/glyph-list/GlyphList.tsx index 3f55790..5f7c729 100644 --- a/src/features/editor/glyph-list/GlyphList.tsx +++ b/src/features/editor/glyph-list/GlyphList.tsx @@ -25,7 +25,12 @@ import { Label } from '@/components/ui/label'; import { Slider } from '@/components/ui/slider'; import type { Glyph } from '@/core/font'; import { makeBlankGlyph } from '@/core/font'; -import { cloneLayers, makeBaseLayerFromBitmap, syncLegacyFields } from '@/core/font/layers'; +import { + cloneLayers, + flattenGlyph, + makeBaseLayerFromBitmap, + syncLegacyFields, +} from '@/core/font/layers'; import { deleteGlyph, getFontFile, saveGlyphs } from '@/db'; import { useRasterize } from '@/hooks/useRasterize'; import { cn } from '@/lib/utils'; @@ -91,7 +96,7 @@ export const GlyphList = ({ useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect - setXadvance(selectedGlyph?.xadvance ?? 0); + setXadvance(selectedGlyph?.bmf.xadvance ?? 0); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedCodePoint]); @@ -100,7 +105,11 @@ export const GlyphList = ({ return; } - const updated: Glyph = { ...selectedGlyph, xadvance: value }; + const updated: Glyph = { + ...selectedGlyph, + bmf: { ...selectedGlyph.bmf, xadvance: value }, + xadvance: value, + }; upsertGlyph(updated); void saveGlyphs([updated]); @@ -127,18 +136,16 @@ export const GlyphList = ({ return; } - const result = await rasterize( - buf, - [selectedGlyph.codePoint], - currentFont.settings.fontSize, - ); + const result = await rasterize(buf, [selectedGlyph.codePoint], currentFont.settings.fontSize); const rg = result.glyphs[0]; if (!rg) { return; } - // Reset from the source font discards all user-edited layers and replaces them with one fresh base layer. + // Reset from the source font discards all user-edited layers and replaces them with one + // fresh base layer. TTF metrics (xoffset/yoffset bearings) go onto `bmf`; the layer starts + // at (0, 0). const updated: Glyph = syncLegacyFields({ ...selectedGlyph, layers: [ @@ -146,10 +153,11 @@ export const GlyphList = ({ pixels: rg.pixels, width: rg.width, height: rg.height, - xoffset: rg.xoffset, - yoffset: rg.yoffset, + xoffset: 0, + yoffset: 0, }), ], + bmf: { xoffset: rg.xoffset, yoffset: rg.yoffset, xadvance: rg.xadvance }, xadvance: rg.xadvance, isDirty: false, }); @@ -251,6 +259,7 @@ export const GlyphList = ({ {sortedGlyphs.map((glyph) => { const isSelected = glyph.codePoint === selectedCodePoint; const label = `${String.fromCodePoint(glyph.codePoint)} U+${glyph.codePoint.toString(16).toUpperCase().padStart(4, '0')}`; + const flat = flattenGlyph(glyph); return (