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
90 changes: 90 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# bmf-generator

A frontend-only tool for authoring BMF (AngelCode) bitmap fonts. The user creates a font, populates it with glyphs (rasterised from a TTF/OTF or hand-drawn), and exports a `.fnt` descriptor plus a PNG atlas.

## Language

### Font & data

**Font**:
The top-level artefact the user is authoring — what eventually exports as a `.fnt` + atlas pair. Holds font-wide settings and an ordered list of glyph code points. Persisted in IndexedDB.
_Avoid_: Project, document.

**Font Settings**:
Font-wide BMF metrics (`fontSize`, `padding`, `spacing`, `lineHeight`, `base`, `capHeight`, `alphaThreshold`) plus the optional `sourceFontId` linking to an uploaded TTF/OTF.
_Avoid_: Config, options, metrics.

**Source Font**:
A user-uploaded TTF/OTF used to rasterise glyphs. Optional — a font can be drawn from scratch without one. Referenced by `FontSettings.sourceFontId`; the blob itself lives in the `FontFile` store, an implementation detail of how the file is persisted.
_Avoid_: Upload, font file.

**Code Point**:
A Unicode scalar identifying a glyph within a font. The canonical identifier across the editor, storage, and export — BMF's `char id` is the same number.
_Avoid_: Char, character, char code.

**Glyph**:
The editable artefact for one code point: its layer stack plus BMF per-glyph fields (`xoffset`, `yoffset`, `xadvance`). One per code point per font.
_Avoid_: Char (reserved for the BMF output line), character, symbol.

**Layer**:
One bitmap inside a glyph's stack, with its own offset, visibility, lock, and editor tint. Flattened into a single bitmap at export time. `layers[0]` is the base layer.
_Avoid_: Bitmap (ambiguous), pixel buffer, image.

**Glyph Set**:
A named preset of code points (e.g. "ASCII Printable", "Letters & digits") the user picks from when seeding a font's glyph list. Sets marked `custom` can be edited after selection.
_Avoid_: Charset (reserved for BMF's `info charset=…` field), preset, range.

### Pixel model

**Pixel**:
An 8-bit greyscale value (0–255) in a layer's `Uint8Array`. Ink-vs-background is decided per glyph by comparing against `alphaThreshold`.
_Avoid_: Sample, intensity.

**Alpha Threshold**:
The cutoff (0–255) above which a pixel counts as ink for trimming, rendering, and export. Font-wide default in Font Settings; per-glyph override on `Glyph.alphaThreshold`.
_Avoid_: Cutoff, ink threshold.

**Ink**:
A pixel that passes the alpha threshold. Used informally — there is no `ink` field; "ink pixels" means "pixels ≥ threshold".

**Cell**:
A single square in the pixel-editor grid at the current zoom. The editor canvas is sized in cells; each layer is rendered into it at the layer's own `xoffset`/`yoffset`.

### Pipeline

**Rasterize**:
Convert a Source Font into greyscale bitmaps for a font's code points. Produces `RasterizedGlyph` records that seed glyph base layers.
_Avoid_: Render (reserved for the editor canvas), draw, bake.

**Flatten**:
Composite a glyph's visible layers into one bitmap (max-blend, union bbox). The export pipeline always operates on the flattened result, never on raw layers.
_Avoid_: Merge, composite, collapse.

**Trim**:
Crop a flattened glyph to its ink bbox before packing. The cropped pixels are recorded as `trimX`/`trimY` on the `GlyphPlacement` and folded back into `xoffset`/`yoffset` at serialise time.
_Avoid_: Crop (informal only — code uses `trim`).

**Pack**:
Place trimmed glyph bitmaps into a single rectangular atlas using MaxRects. Produces `GlyphPlacement`s and the chosen `atlasWidth`/`atlasHeight`.
_Avoid_: Layout, arrange.

### Export artefacts

**Atlas**:
The PNG image holding all packed glyph bitmaps. Referenced from the descriptor's `page` line. A font always exports exactly one atlas page.
_Avoid_: Sheet, sprite sheet, page (page is BMF's term for the file reference, not the image itself).

**Placement**:
A glyph's rect inside the atlas (`x`, `y`, `width`, `height`) plus its `trimX`/`trimY`. Drives both the atlas blit and the `char` line in the descriptor.
_Avoid_: Rect, region, slot.

**Descriptor**:
The `.fnt` text file in BMF/AngelCode format — `info`, `common`, `page`, `chars`, and per-glyph `char` lines. Paired with the atlas PNG to make a usable bitmap font.
_Avoid_: Manifest, fnt file (use "descriptor" or ".fnt" explicitly).

**Char (BMF)**:
The `char id=… x=… y=… …` line in the descriptor. Use only when talking about the output text format; for the in-memory editable artefact use **Glyph**.

**Portable Font**:
A self-contained JSON export of a font plus its glyphs, used for sharing/importing fonts between browsers. Distinct from the BMF export (which produces `.fnt` + PNG).
_Avoid_: Backup, dump, save file.
5 changes: 5 additions & 0 deletions docs/adr/0001-portable-font-v2-breaking-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# PortableFont v2 is a breaking change from v1

When `Project` was renamed to `Font` across the codebase, the `PortableFont` JSON export format changed too — top-level key `project` → `font` and per-glyph `projectId` → `fontId`. We chose to bump the wire format to `version: 2` and reject v1 bundles outright rather than write a compatibility shim that accepted both shapes.

The trade-off: existing `.json` exports become un-importable. We accepted that because the user base at this point is small and a compat shim would be the kind of code that lives forever unloved — every future field rename would need to remember it. The reject path produces a clear "Unsupported font version: 1" error, which is enough.
11 changes: 11 additions & 0 deletions docs/adr/0002-glyphs-store-rebuild-via-temp-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Rebuilding the `glyphs` store via a temporary store across two Dexie versions

The `Project` → `Font` rename required changing the `glyphs` store's compound primary key from `[projectId+codePoint]` to `[fontId+codePoint]`. Dexie does not support renaming a primary key in place — it throws `UpgradeError: Not yet support for changing primary key`.

We considered three alternatives:

1. **Keep a permanently-renamed store like `glyphs2` plus a `get glyphs()` getter** — leaks the rename history into the schema and class definition forever; a future reader would wonder why `glyphs2` exists.
2. **Persist `projectId` on disk while exposing `fontId` in memory**, translating in `db/glyphs.ts` — keeps a hidden divergence between disk format and code that compounds with every future rename.
3. **The chosen approach: stash records into a temp store in v4, recreate `glyphs` with the new key in v5, restore the records, and drop the temp store** — costs two version bumps and ~30 lines of migration but leaves no permanent artifact.

Dexie processes upgrade callbacks sequentially even when a user jumps from v3 straight to v5, so the v4→v5 pair works correctly for any starting version. The v3→v5 migration path was not exercised against a real prior database before merge — only the fresh-install path. A user with v1/v2/v3 data on disk should spot-check.
File renamed without changes.
2 changes: 1 addition & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
export * from './atlas';
export * from './editor-layout';
export * from './editor-state';
export * from './font-defaults';
export * from './layers';
export * from './pixel-editor';
export * from './preview';
export * from './project-defaults';
export * from './ui-timings';
export * from './zoom';
2 changes: 1 addition & 1 deletion src/config/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ export const PREVIEW_DEFAULT_TEXT = 'Hello World';
// Canvas pixel scale for the on-screen preview (higher = crisper at the cost of size).
export const PREVIEW_CANVAS_SCALE = 2;

// Fallback metrics used when a previewed glyph is missing from the project.
// Fallback metrics used when a previewed glyph is missing from the font.
export const PREVIEW_MISSING_GLYPH_ADVANCE_RATIO = 0.5;
export const PREVIEW_PLACEHOLDER_HEIGHT_RATIO = 0.7;
8 changes: 4 additions & 4 deletions src/core/atlas/pack.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';

import { makeBaseLayerFromBitmap } from '../project/layers';
import type { Glyph } from '../project/types';
import { makeBaseLayerFromBitmap } from '../font/layers';
import type { Glyph } from '../font/types';
import { chooseAtlasSize, packGlyphs } from './pack';

function makeGlyph(
Expand All @@ -18,7 +18,7 @@ function makeGlyph(

return {
codePoint,
projectId: 'project-1',
fontId: 'font-1',
layers: [makeBaseLayerFromBitmap({ pixels, width, height, xoffset: 0, yoffset: 0 })],
pixels,
width,
Expand All @@ -35,7 +35,7 @@ function filledGlyph(codePoint: number, width: number, height: number): Glyph {

return {
codePoint,
projectId: 'project-1',
fontId: 'font-1',
layers: [makeBaseLayerFromBitmap({ pixels, width, height, xoffset: 0, yoffset: 0 })],
pixels,
width,
Expand Down
6 changes: 3 additions & 3 deletions src/core/atlas/pack.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ATLAS_CANDIDATES } from '@/config';

import { flattenGlyph } from '../project/layers';
import { effectiveThreshold } from '../project/threshold';
import type { FontSettings, Glyph, GlyphPlacement } from '../project/types';
import { flattenGlyph } from '../font/layers';
import { effectiveThreshold } from '../font/threshold';
import type { FontSettings, Glyph, GlyphPlacement } from '../font/types';
import { pack } from './maxrects';

export interface PackGlyphsOptions {
Expand Down
30 changes: 15 additions & 15 deletions src/core/bmf/serialize.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { createProject } from '../project/project';
import { createFont } from '../font/font';
import type { BmfGlyphData } from './serialize';
import { serializeBmfText } from './serialize';

Expand All @@ -19,9 +19,9 @@ function makeGlyphData(

describe('serializeBmfText', () => {
it('produces an info line', () => {
const project = createProject('TestFont');
const font = createFont('TestFont');
const output = serializeBmfText({
project,
font,
glyphs: [],
atlasWidth: 256,
atlasHeight: 256,
Expand All @@ -33,9 +33,9 @@ describe('serializeBmfText', () => {
});

it('produces a common line with correct dimensions', () => {
const project = createProject('TestFont');
const font = createFont('TestFont');
const output = serializeBmfText({
project,
font,
glyphs: [],
atlasWidth: 512,
atlasHeight: 256,
Expand All @@ -49,9 +49,9 @@ describe('serializeBmfText', () => {
});

it('produces a page line with the atlas filename', () => {
const project = createProject('TestFont');
const font = createFont('TestFont');
const output = serializeBmfText({
project,
font,
glyphs: [],
atlasWidth: 256,
atlasHeight: 256,
Expand All @@ -62,10 +62,10 @@ describe('serializeBmfText', () => {
});

it('produces a chars count line', () => {
const project = createProject('TestFont');
const font = createFont('TestFont');
const glyphs = [makeGlyphData(65, 0, 0, 8, 12), makeGlyphData(66, 8, 0, 8, 12)];
const output = serializeBmfText({
project,
font,
glyphs,
atlasWidth: 256,
atlasHeight: 256,
Expand All @@ -76,10 +76,10 @@ describe('serializeBmfText', () => {
});

it('produces a char line per glyph with correct fields', () => {
const project = createProject('TestFont');
const font = createFont('TestFont');
const glyphs = [makeGlyphData(65, 4, 8, 10, 14)];
const output = serializeBmfText({
project,
font,
glyphs,
atlasWidth: 256,
atlasHeight: 256,
Expand All @@ -92,11 +92,11 @@ describe('serializeBmfText', () => {
});

it('includes padding in the info line', () => {
const project = createProject('TestFont', {
const font = createFont('TestFont', {
padding: { top: 2, right: 2, bottom: 2, left: 2 },
});
const output = serializeBmfText({
project,
font,
glyphs: [],
atlasWidth: 256,
atlasHeight: 256,
Expand All @@ -107,9 +107,9 @@ describe('serializeBmfText', () => {
});

it('ends with a newline', () => {
const project = createProject('TestFont');
const font = createFont('TestFont');
const output = serializeBmfText({
project,
font,
glyphs: [],
atlasWidth: 256,
atlasHeight: 256,
Expand Down
8 changes: 4 additions & 4 deletions src/core/bmf/serialize.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import type { Glyph, GlyphPlacement, Project } from '../project/types';
import type { Font,Glyph, GlyphPlacement } from '../font/types';

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

export interface BmfSerializeInput {
project: Project;
font: Font;
glyphs: BmfGlyphData[];
atlasWidth: number;
atlasHeight: number;
atlasFilename: string;
}

export function serializeBmfText(input: BmfSerializeInput): string {
const { project, glyphs, atlasWidth, atlasHeight, atlasFilename } = input;
const { settings, name } = project;
const { font, glyphs, atlasWidth, atlasHeight, atlasFilename } = input;
const { settings, name } = font;
const lines: string[] = [];

lines.push(
Expand Down
75 changes: 75 additions & 0 deletions src/core/font/font.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';

import { createFont, deserializeFont, serializeFont, updateFont } from './font';
import { defaultFontSettings } from './types';

describe('createFont', () => {
it('creates a font with a uuid id', () => {
const font = createFont('My Font');

expect(font.id).toMatch(/^[0-9a-f-]{36}$/);
});

it('trims the name', () => {
const font = createFont(' My Font ');

expect(font.name).toBe('My Font');
});

it('falls back to "Untitled" for empty name', () => {
const font = createFont('');

expect(font.name).toBe('Untitled');
});

it('applies default font settings', () => {
const font = createFont('Test');

expect(font.settings).toEqual(defaultFontSettings());
});

it('merges custom settings', () => {
const font = createFont('Test', { fontSize: 16 });

expect(font.settings.fontSize).toBe(16);
expect(font.settings.lineHeight).toBe(defaultFontSettings().lineHeight);
});

it('starts with no glyphs', () => {
const font = createFont('Test');

expect(font.glyphs).toEqual([]);
});
});

describe('updateFont', () => {
it('updates fields and bumps updatedAt', () => {
const font = createFont('Test');
const before = font.updatedAt;
const updated = updateFont(font, { name: 'New Name' });

expect(updated.name).toBe('New Name');
expect(updated.updatedAt).toBeGreaterThanOrEqual(before);
expect(updated.id).toBe(font.id);
});

it('does not mutate the original', () => {
const font = createFont('Test');

updateFont(font, { name: 'Changed' });
expect(font.name).toBe('Test');
});
});

describe('serialize/deserialize', () => {
it('round-trips a font through JSON', () => {
const font = createFont('Round Trip');
const restored = deserializeFont(serializeFont(font));

expect(restored).toEqual(font);
});

it('throws on invalid data', () => {
expect(() => deserializeFont('{}')).toThrow();
});
});
Loading
Loading