From f8c378fbda571ab0a5bf6735a97002bc94775e27 Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Sun, 21 Jun 2026 22:55:20 +0300 Subject: [PATCH 1/3] refactor: rename Project to Font across the codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the top-level domain entity from "Project" to "Font" to match how users and the product copy already describe what they're authoring. Touches the directory layout (core/project → core/font; the existing core/font rasterisation module moves to core/rasterize), types (Project → Font), functions (createProject → createFont and siblings), the Zustand slice, and all call sites. The Glyph foreign key projectId → fontId required rebuilding the IndexedDB `glyphs` store because Dexie cannot rename a primary key in place. The glyphs store's compound key changes from [projectId+codePoint] to [fontId+codePoint] via a v4→v5 dance: v4 stashes records into a temporary store and drops the original glyphs store; v5 recreates glyphs with the new key and restores the records. Dexie processes both versions sequentially even when a user jumps from v3 straight to v5. The PortableFont JSON wire format is also bumped to version 2 with the field renamed from `project` to `font` (and `projectId` → `fontId` per glyph). Existing v1 bundles will fail to import with a clear "Unsupported font version: 1" error. Adds CONTEXT.md as the project's glossary, with Font as the canonical term and Source Font preserved as the distinct concept for the uploaded TTF/OTF. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTEXT.md | 90 +++++++++++++++++ .../{project-defaults.ts => font-defaults.ts} | 0 src/config/index.ts | 2 +- src/config/preview.ts | 2 +- src/core/atlas/pack.test.ts | 8 +- src/core/atlas/pack.ts | 6 +- src/core/bmf/serialize.test.ts | 30 +++--- src/core/bmf/serialize.ts | 8 +- src/core/font/font.test.ts | 75 ++++++++++++++ src/core/font/font.ts | 35 +++++++ src/core/{project => font}/glyphSets.test.ts | 0 src/core/{project => font}/glyphSets.ts | 0 src/core/{project => font}/glyphs.test.ts | 20 ++-- src/core/{project => font}/glyphs.ts | 8 +- src/core/font/index.ts | 7 +- src/core/{project => font}/layers.test.ts | 4 +- src/core/{project => font}/layers.ts | 0 src/core/font/portable.test.ts | 82 ++++++++++++++++ src/core/{project => font}/portable.ts | 35 ++++--- src/core/{project => font}/threshold.test.ts | 2 +- src/core/{project => font}/threshold.ts | 0 src/core/{project => font}/types.ts | 4 +- src/core/integration.test.ts | 16 +-- src/core/project/index.ts | 6 -- src/core/project/portable.test.ts | 82 ---------------- src/core/project/project.test.ts | 75 -------------- src/core/project/project.ts | 35 ------- src/core/rasterize/index.ts | 1 + .../{font => rasterize}/rasterize.test.ts | 0 src/core/{font => rasterize}/rasterize.ts | 0 src/db/db.test.ts | 6 +- src/db/db.ts | 70 ++++++++++--- src/db/fonts.ts | 37 +++++++ src/db/glyphs.ts | 12 +-- src/db/index.ts | 2 +- src/db/projects.ts | 39 -------- src/features/editor/AtlasFloat.tsx | 8 +- src/features/editor/EditorScreen.tsx | 26 ++--- src/features/editor/PreviewFloat.tsx | 22 ++--- src/features/editor/SettingsDialog.tsx | 30 +++--- src/features/editor/glyph-list/GlyphList.tsx | 52 +++++----- .../editor/pixel-editor/LayerPanel.tsx | 2 +- .../editor/pixel-editor/PixelEditor.tsx | 56 +++++------ src/features/editor/toolbar/EditorToolbar.tsx | 22 ++--- src/features/export/ExportDialog.tsx | 20 ++-- ...ProjectDialog.tsx => DeleteFontDialog.tsx} | 8 +- src/features/home/GlyphSetSelect.tsx | 2 +- src/features/home/HomeScreen.tsx | 98 +++++++++---------- src/features/home/ImportWizard.tsx | 65 ++++++------ src/features/home/NewFontWizard.tsx | 18 ++-- src/hooks/useAtlas.ts | 4 +- src/hooks/useRasterize.ts | 2 +- src/hooks/useUndoRedo.ts | 2 +- src/store/atlasSlice.ts | 2 +- src/store/editorSlice.ts | 2 +- src/store/fontSlice.ts | 20 ++++ src/store/glyphSlice.ts | 2 +- src/store/index.ts | 10 +- src/store/projectSlice.ts | 20 ---- src/workers/atlas.worker.ts | 6 +- src/workers/rasterize.worker.ts | 6 +- 61 files changed, 714 insertions(+), 590 deletions(-) create mode 100644 CONTEXT.md rename src/config/{project-defaults.ts => font-defaults.ts} (100%) create mode 100644 src/core/font/font.test.ts create mode 100644 src/core/font/font.ts rename src/core/{project => font}/glyphSets.test.ts (100%) rename src/core/{project => font}/glyphSets.ts (100%) rename src/core/{project => font}/glyphs.test.ts (68%) rename src/core/{project => font}/glyphs.ts (77%) rename src/core/{project => font}/layers.test.ts (99%) rename src/core/{project => font}/layers.ts (100%) create mode 100644 src/core/font/portable.test.ts rename src/core/{project => font}/portable.ts (62%) rename src/core/{project => font}/threshold.test.ts (90%) rename src/core/{project => font}/threshold.ts (100%) rename src/core/{project => font}/types.ts (98%) delete mode 100644 src/core/project/index.ts delete mode 100644 src/core/project/portable.test.ts delete mode 100644 src/core/project/project.test.ts delete mode 100644 src/core/project/project.ts create mode 100644 src/core/rasterize/index.ts rename src/core/{font => rasterize}/rasterize.test.ts (100%) rename src/core/{font => rasterize}/rasterize.ts (100%) create mode 100644 src/db/fonts.ts delete mode 100644 src/db/projects.ts rename src/features/home/{DeleteProjectDialog.tsx => DeleteFontDialog.tsx} (86%) create mode 100644 src/store/fontSlice.ts delete mode 100644 src/store/projectSlice.ts diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..eb6bd7c --- /dev/null +++ b/CONTEXT.md @@ -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. Persisted as a `FontFile` blob in IndexedDB and referenced by `FontSettings.sourceFontId`. +_Avoid_: Upload, font file (the on-disk persisted name is `FontFile`, but the concept is "source font"). + +**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; glyphs are rendered into it at their `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). Wire format is at v2 — v1 bundles (which used the legacy "project" naming) are no longer accepted. +_Avoid_: Backup, dump, save file. diff --git a/src/config/project-defaults.ts b/src/config/font-defaults.ts similarity index 100% rename from src/config/project-defaults.ts rename to src/config/font-defaults.ts diff --git a/src/config/index.ts b/src/config/index.ts index 04e7d92..89c5a69 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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'; diff --git a/src/config/preview.ts b/src/config/preview.ts index 0af9258..a987716 100644 --- a/src/config/preview.ts +++ b/src/config/preview.ts @@ -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; diff --git a/src/core/atlas/pack.test.ts b/src/core/atlas/pack.test.ts index c5e8944..7deb97b 100644 --- a/src/core/atlas/pack.test.ts +++ b/src/core/atlas/pack.test.ts @@ -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( @@ -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, @@ -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, diff --git a/src/core/atlas/pack.ts b/src/core/atlas/pack.ts index 0516d1b..6f4ad67 100644 --- a/src/core/atlas/pack.ts +++ b/src/core/atlas/pack.ts @@ -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 { diff --git a/src/core/bmf/serialize.test.ts b/src/core/bmf/serialize.test.ts index acb7f5f..591eae3 100644 --- a/src/core/bmf/serialize.test.ts +++ b/src/core/bmf/serialize.test.ts @@ -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'; @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/src/core/bmf/serialize.ts b/src/core/bmf/serialize.ts index 387f156..4d74850 100644 --- a/src/core/bmf/serialize.ts +++ b/src/core/bmf/serialize.ts @@ -1,4 +1,4 @@ -import type { Glyph, GlyphPlacement, Project } from '../project/types'; +import type { Font,Glyph, GlyphPlacement } from '../font/types'; export interface BmfGlyphData { placement: GlyphPlacement; @@ -6,7 +6,7 @@ export interface BmfGlyphData { } export interface BmfSerializeInput { - project: Project; + font: Font; glyphs: BmfGlyphData[]; atlasWidth: number; atlasHeight: number; @@ -14,8 +14,8 @@ export interface BmfSerializeInput { } 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( diff --git a/src/core/font/font.test.ts b/src/core/font/font.test.ts new file mode 100644 index 0000000..cea2c0d --- /dev/null +++ b/src/core/font/font.test.ts @@ -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(); + }); +}); diff --git a/src/core/font/font.ts b/src/core/font/font.ts new file mode 100644 index 0000000..aa64372 --- /dev/null +++ b/src/core/font/font.ts @@ -0,0 +1,35 @@ +import { defaultFontSettings, type Font,type FontSettings } from './types'; + +export function createFont(name: string, settings?: Partial): Font { + const now = Date.now(); + + return { + id: crypto.randomUUID(), + name: name.trim() || 'Untitled', + createdAt: now, + updatedAt: now, + settings: { ...defaultFontSettings(), ...settings }, + glyphs: [], + }; +} + +export function updateFont( + font: Font, + changes: Partial>, +): Font { + return { ...font, ...changes, updatedAt: Date.now() }; +} + +export function serializeFont(font: Font): string { + return JSON.stringify(font); +} + +export function deserializeFont(raw: string): Font { + const data = JSON.parse(raw) as Font; + + if (!data.id || !data.name || !data.settings) { + throw new Error('Invalid font data'); + } + + return data; +} diff --git a/src/core/project/glyphSets.test.ts b/src/core/font/glyphSets.test.ts similarity index 100% rename from src/core/project/glyphSets.test.ts rename to src/core/font/glyphSets.test.ts diff --git a/src/core/project/glyphSets.ts b/src/core/font/glyphSets.ts similarity index 100% rename from src/core/project/glyphSets.ts rename to src/core/font/glyphSets.ts diff --git a/src/core/project/glyphs.test.ts b/src/core/font/glyphs.test.ts similarity index 68% rename from src/core/project/glyphs.test.ts rename to src/core/font/glyphs.test.ts index 63f4161..cc779b6 100644 --- a/src/core/project/glyphs.test.ts +++ b/src/core/font/glyphs.test.ts @@ -3,22 +3,22 @@ import { describe, expect, it } from 'vitest'; import { initializeGlyphs, makeBlankGlyph } from './glyphs'; describe('makeBlankGlyph', () => { - it('returns a glyph with the requested code point and project id', () => { - const glyph = makeBlankGlyph('project-1', 0x41, 8, 12); + it('returns a glyph with the requested code point and font id', () => { + const glyph = makeBlankGlyph('font-1', 0x41, 8, 12); expect(glyph.codePoint).toBe(0x41); - expect(glyph.projectId).toBe('project-1'); + expect(glyph.fontId).toBe('font-1'); }); it('allocates a pixel buffer sized width × height', () => { - const glyph = makeBlankGlyph('project-1', 0x41, 8, 12); + const glyph = makeBlankGlyph('font-1', 0x41, 8, 12); expect(glyph.pixels).toBeInstanceOf(Uint8Array); expect(glyph.pixels.length).toBe(8 * 12); }); it('zero-initialises the pixel buffer', () => { - const glyph = makeBlankGlyph('project-1', 0x41, 4, 4); + const glyph = makeBlankGlyph('font-1', 0x41, 4, 4); for (const value of glyph.pixels) { expect(value).toBe(0); @@ -26,7 +26,7 @@ describe('makeBlankGlyph', () => { }); it('defaults xadvance to 0.7× the cell width, rounded', () => { - const glyph = makeBlankGlyph('project-1', 0x41, 8, 12); + const glyph = makeBlankGlyph('font-1', 0x41, 8, 12); expect(glyph.xadvance).toBe(Math.round(8 * 0.7)); expect(glyph.xoffset).toBe(0); @@ -34,7 +34,7 @@ describe('makeBlankGlyph', () => { }); it('is not marked dirty', () => { - const glyph = makeBlankGlyph('project-1', 0x41, 8, 12); + const glyph = makeBlankGlyph('font-1', 0x41, 8, 12); expect(glyph.isDirty).toBe(false); }); @@ -43,18 +43,18 @@ describe('makeBlankGlyph', () => { describe('initializeGlyphs', () => { it('produces one glyph per code point, preserving order', () => { const codePoints = [0x41, 0x42, 0x43]; - const glyphs = initializeGlyphs('project-1', codePoints, 8, 12); + const glyphs = initializeGlyphs('font-1', codePoints, 8, 12); expect(glyphs).toHaveLength(3); expect(glyphs.map((glyph) => glyph.codePoint)).toEqual(codePoints); }); it('returns an empty array when no code points are given', () => { - expect(initializeGlyphs('project-1', [], 8, 12)).toEqual([]); + expect(initializeGlyphs('font-1', [], 8, 12)).toEqual([]); }); it('sizes every glyph buffer identically', () => { - const glyphs = initializeGlyphs('project-1', [0x41, 0x42], 6, 10); + const glyphs = initializeGlyphs('font-1', [0x41, 0x42], 6, 10); for (const glyph of glyphs) { expect(glyph.width).toBe(6); diff --git a/src/core/project/glyphs.ts b/src/core/font/glyphs.ts similarity index 77% rename from src/core/project/glyphs.ts rename to src/core/font/glyphs.ts index a38237d..ad28ac1 100644 --- a/src/core/project/glyphs.ts +++ b/src/core/font/glyphs.ts @@ -2,14 +2,14 @@ import { makeBlankLayer } from './layers'; import type { Glyph } from './types'; export function makeBlankGlyph( - projectId: string, + fontId: string, codePoint: number, width: number, height: number, ): Glyph { return { codePoint, - projectId, + fontId, layers: [makeBlankLayer()], pixels: new Uint8Array(width * height), width, @@ -22,10 +22,10 @@ export function makeBlankGlyph( } export function initializeGlyphs( - projectId: string, + fontId: string, codePoints: number[], width: number, height: number, ): Glyph[] { - return codePoints.map((codePoint) => makeBlankGlyph(projectId, codePoint, width, height)); + return codePoints.map((codePoint) => makeBlankGlyph(fontId, codePoint, width, height)); } diff --git a/src/core/font/index.ts b/src/core/font/index.ts index bed4597..14bdbc2 100644 --- a/src/core/font/index.ts +++ b/src/core/font/index.ts @@ -1 +1,6 @@ -export * from './rasterize'; +export * from './font'; +export * from './glyphs'; +export * from './layers'; +export * from './portable'; +export * from './threshold'; +export * from './types'; diff --git a/src/core/project/layers.test.ts b/src/core/font/layers.test.ts similarity index 99% rename from src/core/project/layers.test.ts rename to src/core/font/layers.test.ts index b875ea1..4d30dcd 100644 --- a/src/core/project/layers.test.ts +++ b/src/core/font/layers.test.ts @@ -21,7 +21,7 @@ import type { Glyph, Layer } from './types'; function makeGlyph(layers: Layer[]): Glyph { return { codePoint: 0x41, - projectId: 'project-1', + fontId: 'font-1', layers, pixels: new Uint8Array(0), width: 0, @@ -342,7 +342,7 @@ describe('syncLegacyFields', () => { const layer = inkLayer(2, 1, 7, 8, [99, 100]); const stale: Glyph = { codePoint: 0x41, - projectId: 'project-1', + fontId: 'font-1', layers: [layer], pixels: new Uint8Array([0, 0, 0, 0]), width: 9999, diff --git a/src/core/project/layers.ts b/src/core/font/layers.ts similarity index 100% rename from src/core/project/layers.ts rename to src/core/font/layers.ts diff --git a/src/core/font/portable.test.ts b/src/core/font/portable.test.ts new file mode 100644 index 0000000..584783b --- /dev/null +++ b/src/core/font/portable.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { createFont } from './font'; +import { makeBlankGlyph } from './glyphs'; +import { exportPortableFont, importPortableFont } from './portable'; +import type { Glyph } from './types'; + +function inkedGlyph(fontId: string, codePoint: number, bytes: number[]): Glyph { + const glyph = makeBlankGlyph(fontId, codePoint, 4, 4); + + glyph.pixels.set(bytes); + + return glyph; +} + +describe('portable font round-trip', () => { + it('preserves font fields through export → import', () => { + const font = createFont('Round Trip'); + const glyphs: Glyph[] = [makeBlankGlyph(font.id, 0x41, 4, 4)]; + + const json = exportPortableFont(font, glyphs); + const restored = importPortableFont(json); + + expect(restored.font).toEqual(font); + }); + + it('preserves glyph pixel bytes', () => { + const font = createFont('Pixels'); + const pattern = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160]; + const glyphs: Glyph[] = [inkedGlyph(font.id, 0x41, pattern)]; + + const restored = importPortableFont(exportPortableFont(font, glyphs)); + + expect(Array.from(restored.glyphs[0].pixels)).toEqual(pattern); + }); + + it('restores pixels as Uint8Array, not a plain array', () => { + const font = createFont('Type'); + const glyphs: Glyph[] = [makeBlankGlyph(font.id, 0x41, 4, 4)]; + + const restored = importPortableFont(exportPortableFont(font, glyphs)); + + expect(restored.glyphs[0].pixels).toBeInstanceOf(Uint8Array); + }); + + it('handles bytes across the full 0–255 range', () => { + const font = createFont('Range'); + const bytes = Array.from({ length: 16 }, (_value, index) => index * 17); // 0, 17, 34, … 255 + const glyphs: Glyph[] = [inkedGlyph(font.id, 0x41, bytes)]; + + const restored = importPortableFont(exportPortableFont(font, glyphs)); + + expect(Array.from(restored.glyphs[0].pixels)).toEqual(bytes); + }); + + it('preserves multiple glyphs in order', () => { + const font = createFont('Order'); + const glyphs: Glyph[] = [ + makeBlankGlyph(font.id, 0x41, 4, 4), + makeBlankGlyph(font.id, 0x42, 4, 4), + makeBlankGlyph(font.id, 0x43, 4, 4), + ]; + + const restored = importPortableFont(exportPortableFont(font, glyphs)); + + expect(restored.glyphs.map((glyph) => glyph.codePoint)).toEqual([0x41, 0x42, 0x43]); + }); +}); + +describe('importPortableFont error handling', () => { + it('throws on unsupported version', () => { + const bundle = JSON.stringify({ version: 1, font: { id: 'x' }, glyphs: [] }); + + expect(() => importPortableFont(bundle)).toThrow('Unsupported font version: 1'); + }); + + it('throws when the font bundle is missing a font id', () => { + const bundle = JSON.stringify({ version: 2, font: {}, glyphs: [] }); + + expect(() => importPortableFont(bundle)).toThrow('Invalid font bundle'); + }); +}); diff --git a/src/core/project/portable.ts b/src/core/font/portable.ts similarity index 62% rename from src/core/project/portable.ts rename to src/core/font/portable.ts index 3aa5f16..e174a07 100644 --- a/src/core/project/portable.ts +++ b/src/core/font/portable.ts @@ -1,11 +1,11 @@ import { makeBaseLayerFromBitmap } from './layers'; -import type { Glyph, Project } from './types'; +import type { Font, Glyph } from './types'; -export interface PortableProject { - version: 1; - project: Project; +export interface PortableFont { + version: 2; + font: Font; // Pixels serialized as base64 strings to survive JSON round-trip. - // Layers are intentionally not serialized in v1 — they're reconstructed from the legacy bitmap on import. + // Layers are reconstructed from the legacy bitmap on import. glyphs: Array & { pixels: string }>; } @@ -30,15 +30,14 @@ function fromBase64(b64: string): Uint8Array { return buf; } -export function exportPortableProject(project: Project, glyphs: Glyph[]): string { - const portable: PortableProject = { - version: 1, - project, +export function exportPortableFont(font: Font, glyphs: Glyph[]): string { + const portable: PortableFont = { + version: 2, + font, glyphs: glyphs.map((glyph) => { - // Drop `layers` from the serialized payload; v1 portable bundles store the legacy bitmap shape only. const rest: Omit = { codePoint: glyph.codePoint, - projectId: glyph.projectId, + fontId: glyph.fontId, width: glyph.width, height: glyph.height, xoffset: glyph.xoffset, @@ -55,15 +54,15 @@ export function exportPortableProject(project: Project, glyphs: Glyph[]): string return JSON.stringify(portable, null, 2); } -export function importPortableProject(json: string): { project: Project; glyphs: Glyph[] } { - const data = JSON.parse(json) as PortableProject; +export function importPortableFont(json: string): { font: Font; glyphs: Glyph[] } { + const data = JSON.parse(json) as PortableFont; - if (data.version !== 1) { - throw new Error(`Unsupported project version: ${String(data.version)}`); + if (data.version !== 2) { + throw new Error(`Unsupported font version: ${String(data.version)}`); } - if (!data.project?.id) { - throw new Error('Invalid project bundle: missing project data'); + if (!data.font?.id) { + throw new Error('Invalid font bundle: missing font data'); } const glyphs: Glyph[] = data.glyphs.map((glyph) => { @@ -84,5 +83,5 @@ export function importPortableProject(json: string): { project: Project; glyphs: }; }); - return { project: data.project, glyphs }; + return { font: data.font, glyphs }; } diff --git a/src/core/project/threshold.test.ts b/src/core/font/threshold.test.ts similarity index 90% rename from src/core/project/threshold.test.ts rename to src/core/font/threshold.test.ts index 2fd71a2..45381cf 100644 --- a/src/core/project/threshold.test.ts +++ b/src/core/font/threshold.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { effectiveThreshold } from './threshold'; describe('effectiveThreshold', () => { - it('falls back to project setting when glyph has no override', () => { + it('falls back to font setting when glyph has no override', () => { expect(effectiveThreshold({ alphaThreshold: undefined }, { alphaThreshold: 128 })).toBe(128); }); diff --git a/src/core/project/threshold.ts b/src/core/font/threshold.ts similarity index 100% rename from src/core/project/threshold.ts rename to src/core/font/threshold.ts diff --git a/src/core/project/types.ts b/src/core/font/types.ts similarity index 98% rename from src/core/project/types.ts rename to src/core/font/types.ts index 5931966..7bf7b79 100644 --- a/src/core/project/types.ts +++ b/src/core/font/types.ts @@ -20,7 +20,7 @@ export interface FontSettings { alphaThreshold: number; } -export interface Project { +export interface Font { id: string; name: string; createdAt: number; @@ -47,7 +47,7 @@ export interface Layer { export interface Glyph { codePoint: number; - projectId: string; + 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[]; diff --git a/src/core/integration.test.ts b/src/core/integration.test.ts index 07e2955..ed9807c 100644 --- a/src/core/integration.test.ts +++ b/src/core/integration.test.ts @@ -4,9 +4,9 @@ import { packGlyphs } from './atlas/pack'; import { parseBmfText } from './bmf/parse'; import type { BmfGlyphData } from './bmf/serialize'; import { serializeBmfText } from './bmf/serialize'; -import { makeBlankGlyph } from './project/glyphs'; -import { createProject } from './project/project'; -import type { Glyph } from './project/types'; +import { createFont } from './font/font'; +import { makeBlankGlyph } from './font/glyphs'; +import type { Glyph } from './font/types'; function inkBlock(glyph: Glyph, x: number, y: number, width: number, height: number): Glyph { for (let row = y; row < y + height; row++) { @@ -24,13 +24,13 @@ function inkBlock(glyph: Glyph, x: number, y: number, width: number, height: num describe('pack → serialize → parse round-trip', () => { it('preserves face name, common metrics, and char entries', () => { - const project = createProject('Integration', { + const font = createFont('Integration', { padding: { top: 1, right: 1, bottom: 1, left: 1 }, }); // 3 glyphs, each 8×8, ink in a 4×4 inner block so trimming has work to do const glyphs: Glyph[] = [0x41, 0x42, 0x43].map((codePoint) => - inkBlock(makeBlankGlyph(project.id, codePoint, 8, 8), 2, 2, 4, 4), + inkBlock(makeBlankGlyph(font.id, codePoint, 8, 8), 2, 2, 4, 4), ); const atlasWidth = 64; @@ -63,7 +63,7 @@ describe('pack → serialize → parse round-trip', () => { }); const fnt = serializeBmfText({ - project, + font, glyphs: glyphData, atlasWidth, atlasHeight, @@ -73,8 +73,8 @@ describe('pack → serialize → parse round-trip', () => { const parsed = parseBmfText(fnt); expect(parsed.info.face).toBe('Integration'); - expect(parsed.info.size).toBe(project.settings.fontSize); - expect(parsed.info.padding).toEqual(project.settings.padding); + expect(parsed.info.size).toBe(font.settings.fontSize); + expect(parsed.info.padding).toEqual(font.settings.padding); expect(parsed.common.scaleW).toBe(atlasWidth); expect(parsed.common.scaleH).toBe(atlasHeight); expect(parsed.atlasFilename).toBe('Integration.png'); diff --git a/src/core/project/index.ts b/src/core/project/index.ts deleted file mode 100644 index 2cebe5f..0000000 --- a/src/core/project/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './glyphs'; -export * from './layers'; -export * from './portable'; -export * from './project'; -export * from './threshold'; -export * from './types'; diff --git a/src/core/project/portable.test.ts b/src/core/project/portable.test.ts deleted file mode 100644 index 43148c2..0000000 --- a/src/core/project/portable.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { makeBlankGlyph } from './glyphs'; -import { exportPortableProject, importPortableProject } from './portable'; -import { createProject } from './project'; -import type { Glyph } from './types'; - -function inkedGlyph(projectId: string, codePoint: number, bytes: number[]): Glyph { - const glyph = makeBlankGlyph(projectId, codePoint, 4, 4); - - glyph.pixels.set(bytes); - - return glyph; -} - -describe('portable project round-trip', () => { - it('preserves project fields through export → import', () => { - const project = createProject('Round Trip'); - const glyphs: Glyph[] = [makeBlankGlyph(project.id, 0x41, 4, 4)]; - - const json = exportPortableProject(project, glyphs); - const restored = importPortableProject(json); - - expect(restored.project).toEqual(project); - }); - - it('preserves glyph pixel bytes', () => { - const project = createProject('Pixels'); - const pattern = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160]; - const glyphs: Glyph[] = [inkedGlyph(project.id, 0x41, pattern)]; - - const restored = importPortableProject(exportPortableProject(project, glyphs)); - - expect(Array.from(restored.glyphs[0].pixels)).toEqual(pattern); - }); - - it('restores pixels as Uint8Array, not a plain array', () => { - const project = createProject('Type'); - const glyphs: Glyph[] = [makeBlankGlyph(project.id, 0x41, 4, 4)]; - - const restored = importPortableProject(exportPortableProject(project, glyphs)); - - expect(restored.glyphs[0].pixels).toBeInstanceOf(Uint8Array); - }); - - it('handles bytes across the full 0–255 range', () => { - const project = createProject('Range'); - const bytes = Array.from({ length: 16 }, (_value, index) => index * 17); // 0, 17, 34, … 255 - const glyphs: Glyph[] = [inkedGlyph(project.id, 0x41, bytes)]; - - const restored = importPortableProject(exportPortableProject(project, glyphs)); - - expect(Array.from(restored.glyphs[0].pixels)).toEqual(bytes); - }); - - it('preserves multiple glyphs in order', () => { - const project = createProject('Order'); - const glyphs: Glyph[] = [ - makeBlankGlyph(project.id, 0x41, 4, 4), - makeBlankGlyph(project.id, 0x42, 4, 4), - makeBlankGlyph(project.id, 0x43, 4, 4), - ]; - - const restored = importPortableProject(exportPortableProject(project, glyphs)); - - expect(restored.glyphs.map((glyph) => glyph.codePoint)).toEqual([0x41, 0x42, 0x43]); - }); -}); - -describe('importPortableProject error handling', () => { - it('throws on unsupported version', () => { - const bundle = JSON.stringify({ version: 2, project: { id: 'x' }, glyphs: [] }); - - expect(() => importPortableProject(bundle)).toThrow('Unsupported project version: 2'); - }); - - it('throws when the project bundle is missing a project id', () => { - const bundle = JSON.stringify({ version: 1, project: {}, glyphs: [] }); - - expect(() => importPortableProject(bundle)).toThrow('Invalid project bundle'); - }); -}); diff --git a/src/core/project/project.test.ts b/src/core/project/project.test.ts deleted file mode 100644 index 7d39b94..0000000 --- a/src/core/project/project.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { createProject, deserializeProject, serializeProject, updateProject } from './project'; -import { defaultFontSettings } from './types'; - -describe('createProject', () => { - it('creates a project with a uuid id', () => { - const project = createProject('My Font'); - - expect(project.id).toMatch(/^[0-9a-f-]{36}$/); - }); - - it('trims the name', () => { - const project = createProject(' My Font '); - - expect(project.name).toBe('My Font'); - }); - - it('falls back to "Untitled" for empty name', () => { - const project = createProject(''); - - expect(project.name).toBe('Untitled'); - }); - - it('applies default font settings', () => { - const project = createProject('Test'); - - expect(project.settings).toEqual(defaultFontSettings()); - }); - - it('merges custom settings', () => { - const project = createProject('Test', { fontSize: 16 }); - - expect(project.settings.fontSize).toBe(16); - expect(project.settings.lineHeight).toBe(defaultFontSettings().lineHeight); - }); - - it('starts with no glyphs', () => { - const project = createProject('Test'); - - expect(project.glyphs).toEqual([]); - }); -}); - -describe('updateProject', () => { - it('updates fields and bumps updatedAt', () => { - const project = createProject('Test'); - const before = project.updatedAt; - const updated = updateProject(project, { name: 'New Name' }); - - expect(updated.name).toBe('New Name'); - expect(updated.updatedAt).toBeGreaterThanOrEqual(before); - expect(updated.id).toBe(project.id); - }); - - it('does not mutate the original', () => { - const project = createProject('Test'); - - updateProject(project, { name: 'Changed' }); - expect(project.name).toBe('Test'); - }); -}); - -describe('serialize/deserialize', () => { - it('round-trips a project through JSON', () => { - const project = createProject('Round Trip'); - const restored = deserializeProject(serializeProject(project)); - - expect(restored).toEqual(project); - }); - - it('throws on invalid data', () => { - expect(() => deserializeProject('{}')).toThrow(); - }); -}); diff --git a/src/core/project/project.ts b/src/core/project/project.ts deleted file mode 100644 index 23b7707..0000000 --- a/src/core/project/project.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { defaultFontSettings, type FontSettings, type Project } from './types'; - -export function createProject(name: string, settings?: Partial): Project { - const now = Date.now(); - - return { - id: crypto.randomUUID(), - name: name.trim() || 'Untitled', - createdAt: now, - updatedAt: now, - settings: { ...defaultFontSettings(), ...settings }, - glyphs: [], - }; -} - -export function updateProject( - project: Project, - changes: Partial>, -): Project { - return { ...project, ...changes, updatedAt: Date.now() }; -} - -export function serializeProject(project: Project): string { - return JSON.stringify(project); -} - -export function deserializeProject(raw: string): Project { - const data = JSON.parse(raw) as Project; - - if (!data.id || !data.name || !data.settings) { - throw new Error('Invalid project data'); - } - - return data; -} diff --git a/src/core/rasterize/index.ts b/src/core/rasterize/index.ts new file mode 100644 index 0000000..bed4597 --- /dev/null +++ b/src/core/rasterize/index.ts @@ -0,0 +1 @@ +export * from './rasterize'; diff --git a/src/core/font/rasterize.test.ts b/src/core/rasterize/rasterize.test.ts similarity index 100% rename from src/core/font/rasterize.test.ts rename to src/core/rasterize/rasterize.test.ts diff --git a/src/core/font/rasterize.ts b/src/core/rasterize/rasterize.ts similarity index 100% rename from src/core/font/rasterize.ts rename to src/core/rasterize/rasterize.ts diff --git a/src/db/db.test.ts b/src/db/db.test.ts index 437711a..ec89979 100644 --- a/src/db/db.test.ts +++ b/src/db/db.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { Glyph } from '@/core/project/types'; +import type { Glyph } from '@/core/font/types'; import { upgradeGlyphV1ToV2 } from './db'; @@ -9,9 +9,9 @@ function makeV1Record(): Glyph & { id: string } { // Cast through unknown to model a real v1 record (no `layers` field present). return { - id: 'project-1:65', + id: 'font-1:65', codePoint: 65, - projectId: 'project-1', + fontId: 'font-1', pixels, width: 3, height: 2, diff --git a/src/db/db.ts b/src/db/db.ts index b2f1e4d..a57c973 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,7 +1,7 @@ import Dexie, { type EntityTable } from 'dexie'; -import type { Glyph, Layer, Project } from '@/core/project'; -import { makeBaseLayerFromBitmap } from '@/core/project/layers'; +import type { Font, Glyph, Layer } from '@/core/font'; +import { makeBaseLayerFromBitmap } from '@/core/font/layers'; interface FontFile { id: string; @@ -10,13 +10,6 @@ interface FontFile { createdAt: number; } -/** - * v1→v2 per-record upgrade: derive layers[0] from the legacy bitmap fields. - * Legacy pixels/width/height/xoffset/yoffset are intentionally preserved so a - * one-shot rollback to v1 remains safe. - * - * Exported for unit testing without spinning up a real IndexedDB. - */ export function upgradeGlyphV1ToV2(record: Glyph & { id: string }): void { record.layers = [ makeBaseLayerFromBitmap({ @@ -29,11 +22,6 @@ export function upgradeGlyphV1ToV2(record: Glyph & { id: string }): void { ]; } -/** - * v2→v3 per-record upgrade: drop the stored `color` string from each layer and - * replace it with `colorIndex` derived from the layer's position. Old defaults - * always tracked the creation index, so position is an exact equivalent. - */ export function upgradeGlyphV2ToV3(record: Glyph & { id: string }): void { record.layers = record.layers.map((layer, index) => { const next = { ...layer, colorIndex: index }; @@ -45,16 +33,17 @@ export function upgradeGlyphV2ToV3(record: Glyph & { id: string }): void { } class BmfDatabase extends Dexie { - projects!: EntityTable; + fonts!: EntityTable; glyphs!: EntityTable; fontFiles!: EntityTable; constructor() { super('bmf-generator'); + // v1–v3 schema strings are historical and must not change — Dexie replays + // them to upgrade browsers that landed on those versions. this.version(1).stores({ projects: 'id, updatedAt', - // compound key: one glyph record per (projectId, codePoint) pair glyphs: '[projectId+codePoint], projectId, id', fontFiles: 'id', }); @@ -84,6 +73,55 @@ class BmfDatabase extends Dexie { .toCollection() .modify((record) => upgradeGlyphV2ToV3(record)), ); + + // The `glyphs` store needs a new compound primary key (`[fontId+codePoint]` + // instead of `[projectId+codePoint]`), and Dexie cannot rename a primary + // key in place. v4 stashes the records into a temporary store and drops + // `glyphs`; v5 recreates `glyphs` with the new key and restores the records + // with the field rewritten. Both versions are required — Dexie processes + // them sequentially even when a user jumps from v3 straight to v5. + this.version(4) + .stores({ + projects: null, + fonts: 'id, updatedAt', + glyphs: null, + glyphsTmp: 'id', + fontFiles: 'id', + }) + .upgrade(async (transaction) => { + const oldFonts = await transaction.table('projects').toArray(); + + if (oldFonts.length > 0) { + await transaction.table('fonts').bulkAdd(oldFonts); + } + + const oldGlyphs = await transaction + .table('glyphs') + .toArray(); + + if (oldGlyphs.length > 0) { + const rewritten = oldGlyphs.map((record) => { + const { projectId, ...rest } = record; + + return { ...rest, fontId: projectId ?? record.fontId }; + }); + + await transaction.table('glyphsTmp').bulkAdd(rewritten); + } + }); + + this.version(5) + .stores({ + glyphs: '[fontId+codePoint], fontId, id', + glyphsTmp: null, + }) + .upgrade(async (transaction) => { + const stashed = await transaction.table('glyphsTmp').toArray(); + + if (stashed.length > 0) { + await transaction.table('glyphs').bulkAdd(stashed); + } + }); } } diff --git a/src/db/fonts.ts b/src/db/fonts.ts new file mode 100644 index 0000000..e60c789 --- /dev/null +++ b/src/db/fonts.ts @@ -0,0 +1,37 @@ +import { defaultFontSettings, type Font } from '@/core/font'; + +import { db } from './db'; + +function hydrateFont(font: Font): Font { + if (font.settings.alphaThreshold === undefined) { + return { + ...font, + settings: { ...font.settings, alphaThreshold: defaultFontSettings().alphaThreshold }, + }; + } + + return font; +} + +export async function getAllFonts(): Promise { + const fonts = await db.fonts.orderBy('updatedAt').reverse().toArray(); + + return fonts.map(hydrateFont); +} + +export async function getFont(id: string): Promise { + const font = await db.fonts.get(id); + + return font ? hydrateFont(font) : font; +} + +export async function saveFont(font: Font): Promise { + await db.fonts.put(font); +} + +export async function deleteFont(id: string): Promise { + await db.transaction('rw', [db.fonts, db.glyphs], async () => { + await db.fonts.delete(id); + await db.glyphs.where('fontId').equals(id).delete(); + }); +} diff --git a/src/db/glyphs.ts b/src/db/glyphs.ts index e5c6d33..c6f15fe 100644 --- a/src/db/glyphs.ts +++ b/src/db/glyphs.ts @@ -1,22 +1,22 @@ -import type { Glyph } from '@/core/project/types'; +import type { Glyph } from '@/core/font/types'; import { db } from './db'; export async function saveGlyphs(glyphs: Glyph[]): Promise { const records = glyphs.map((glyph) => ({ ...glyph, - id: `${glyph.projectId}:${glyph.codePoint}`, + id: `${glyph.fontId}:${glyph.codePoint}`, })); await db.glyphs.bulkPut(records); } -export async function getGlyphsForProject(projectId: string): Promise { - return db.glyphs.where('projectId').equals(projectId).toArray(); +export async function getGlyphsForFont(fontId: string): Promise { + return db.glyphs.where('fontId').equals(fontId).toArray(); } -export async function deleteGlyph(projectId: string, codePoint: number): Promise { - await db.glyphs.delete(`${projectId}:${codePoint}`); +export async function deleteGlyph(fontId: string, codePoint: number): Promise { + await db.glyphs.delete(`${fontId}:${codePoint}`); } export async function saveFontFile(id: string, data: ArrayBuffer, filename: string): Promise { diff --git a/src/db/index.ts b/src/db/index.ts index 8484bcd..b02c300 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,3 +1,3 @@ export * from './db'; +export * from './fonts'; export * from './glyphs'; -export * from './projects'; diff --git a/src/db/projects.ts b/src/db/projects.ts deleted file mode 100644 index 0588838..0000000 --- a/src/db/projects.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { defaultFontSettings, type Project } from '@/core/project'; - -import { db } from './db'; - -function hydrateProject(project: Project): Project { - // Backfill fields added after the initial schema so projects created before - // the change keep loading cleanly. - if (project.settings.alphaThreshold === undefined) { - return { - ...project, - settings: { ...project.settings, alphaThreshold: defaultFontSettings().alphaThreshold }, - }; - } - - return project; -} - -export async function getAllProjects(): Promise { - const projects = await db.projects.orderBy('updatedAt').reverse().toArray(); - - return projects.map(hydrateProject); -} - -export async function getProject(id: string): Promise { - const project = await db.projects.get(id); - - return project ? hydrateProject(project) : project; -} - -export async function saveProject(project: Project): Promise { - await db.projects.put(project); -} - -export async function deleteProject(id: string): Promise { - await db.transaction('rw', [db.projects, db.glyphs], async () => { - await db.projects.delete(id); - await db.glyphs.where('projectId').equals(id).delete(); - }); -} diff --git a/src/features/editor/AtlasFloat.tsx b/src/features/editor/AtlasFloat.tsx index 9cc87ae..dd1f2ed 100644 --- a/src/features/editor/AtlasFloat.tsx +++ b/src/features/editor/AtlasFloat.tsx @@ -27,7 +27,7 @@ export const AtlasFloat = ({ open, onClose }: Props): React.JSX.Element => { const [packing, setPacking] = useState(false); const canvasRef = useRef(null); - const currentProject = useStore((state) => state.currentProject); + const currentFont = useStore((state) => state.currentFont); const glyphs = useStore((state) => state.glyphs); const atlasImageData = useStore((state) => state.atlasImageData); const atlasWidth = useStore((state) => state.atlasWidth); @@ -47,7 +47,7 @@ export const AtlasFloat = ({ open, onClose }: Props): React.JSX.Element => { const debouncedSelected = useDebounce(selectedGlyphs, ATLAS_REPACK_DEBOUNCE_MS); async function runPack(glyphsToPack = selectedGlyphs): Promise { - if (!currentProject || glyphsToPack.length === 0) { + if (!currentFont || glyphsToPack.length === 0) { return; } @@ -63,8 +63,8 @@ export const AtlasFloat = ({ open, onClose }: Props): React.JSX.Element => { unpacked, } = await packAtlas( glyphsToPack, - currentProject.settings.padding.top, - currentProject.settings.alphaThreshold, + currentFont.settings.padding.top, + currentFont.settings.alphaThreshold, ); if (unpacked.length > 0) { diff --git a/src/features/editor/EditorScreen.tsx b/src/features/editor/EditorScreen.tsx index b4bba67..9370721 100644 --- a/src/features/editor/EditorScreen.tsx +++ b/src/features/editor/EditorScreen.tsx @@ -10,8 +10,8 @@ import { GLYPH_LIST_MIN_WIDTH_PX, ZOOM_REFERENCE, } from '@/config'; -import { cloneLayers, syncLegacyFields, trimLayerToInk } from '@/core/project/layers'; -import { getGlyphsForProject } from '@/db/glyphs'; +import { cloneLayers, syncLegacyFields, trimLayerToInk } from '@/core/font/layers'; +import { getGlyphsForFont } from '@/db/glyphs'; import { saveGlyphs } from '@/db/glyphs'; import { ExportDialog } from '@/features/export/ExportDialog'; import { useUndoRedo } from '@/hooks/useUndoRedo'; @@ -94,7 +94,7 @@ export const EditorScreen = (): React.JSX.Element => { }, []); const navigate = useNavigate(); - const currentProject = useStore((state) => state.currentProject); + const currentFont = useStore((state) => state.currentFont); const setGlyphs = useStore((state) => state.setGlyphs); const activeTool = useStore((state) => state.activeTool); const setActiveTool = useStore((state) => state.setActiveTool); @@ -111,13 +111,13 @@ export const EditorScreen = (): React.JSX.Element => { // Track the tool to restore after Space/Alt temporary overrides const toolBeforeOverride = useRef(null); - // Load glyphs from IndexedDB whenever the active project changes + // Load glyphs from IndexedDB whenever the active font changes useEffect(() => { - if (!currentProject) { + if (!currentFont) { return; } - void getGlyphsForProject(currentProject.id).then((loaded) => + void getGlyphsForFont(currentFont.id).then((loaded) => // Re-tighten every layer's buffer to its inked bounds on load — repairs // any glyphs saved before the trim-on-write fix in updateLayerPixels. setGlyphs( @@ -130,7 +130,7 @@ export const EditorScreen = (): React.JSX.Element => { ), ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentProject?.id]); + }, [currentFont?.id]); // Keyboard shortcuts useEffect(() => { @@ -227,13 +227,13 @@ export const EditorScreen = (): React.JSX.Element => { if (e.key === '1') { setZoomLevel(ZOOM_REFERENCE); requestRecenter(); - } else if (currentProject) { + } else if (currentFont) { const container = document.querySelector('[data-editor-canvas-container]'); const viewport = container ? { width: container.clientWidth, height: container.clientHeight } : { width: window.innerWidth, height: window.innerHeight }; - setZoomLevel(zoomToFitLevel(currentProject.settings, viewport)); + setZoomLevel(zoomToFitLevel(currentFont.settings, viewport)); requestRecenter(); } @@ -358,10 +358,10 @@ export const EditorScreen = (): React.JSX.Element => { layerPanelCollapsed, atlasOpen, previewOpen, - currentProject, + currentFont, ]); - if (!currentProject) { + if (!currentFont) { return ; } @@ -373,9 +373,9 @@ export const EditorScreen = (): React.JSX.Element => { - {currentProject?.name ?? 'Untitled'} + {currentFont?.name ?? 'Untitled'} - {currentProject?.settings.fontSize}px · {currentProject?.glyphs.length} glyphs + {currentFont?.settings.fontSize}px · {currentFont?.glyphs.length} glyphs
Auto-saved diff --git a/src/features/editor/PreviewFloat.tsx b/src/features/editor/PreviewFloat.tsx index 9f3aaaf..cf60875 100644 --- a/src/features/editor/PreviewFloat.tsx +++ b/src/features/editor/PreviewFloat.tsx @@ -9,7 +9,7 @@ import { PREVIEW_MISSING_GLYPH_ADVANCE_RATIO, PREVIEW_PLACEHOLDER_HEIGHT_RATIO, } from '@/config'; -import { effectiveThreshold } from '@/core/project/threshold'; +import { effectiveThreshold } from '@/core/font/threshold'; import { useStore } from '@/store'; interface Props { @@ -22,16 +22,16 @@ export const PreviewFloat = ({ open, onClose }: Props): React.JSX.Element => { const canvasRef = useRef(null); const glyphs = useStore((state) => state.glyphs); - const currentProject = useStore((state) => state.currentProject); + const currentFont = useStore((state) => state.currentFont); useEffect(() => { const canvas = canvasRef.current; - if (!canvas || !currentProject) { + if (!canvas || !currentFont) { return; } - const { lineHeight, base, spacing } = currentProject.settings; + const { lineHeight, base, spacing } = currentFont.settings; const glyphMap = new Map(glyphs.map((glyph) => [glyph.codePoint, glyph])); const scale = PREVIEW_CANVAS_SCALE; @@ -45,7 +45,7 @@ export const PreviewFloat = ({ open, onClose }: Props): React.JSX.Element => { totalWidth += glyph ? glyph.xadvance + spacing.x - : Math.round(currentProject.settings.fontSize * PREVIEW_MISSING_GLYPH_ADVANCE_RATIO); + : Math.round(currentFont.settings.fontSize * PREVIEW_MISSING_GLYPH_ADVANCE_RATIO); } totalWidth = Math.max(totalWidth, 1); @@ -69,9 +69,9 @@ export const PreviewFloat = ({ open, onClose }: Props): React.JSX.Element => { if (!glyph || glyph.width === 0 || glyph.height === 0) { const advance = glyph ? glyph.xadvance - : Math.round(currentProject.settings.fontSize * PREVIEW_MISSING_GLYPH_ADVANCE_RATIO); + : Math.round(currentFont.settings.fontSize * PREVIEW_MISSING_GLYPH_ADVANCE_RATIO); const placeholderHeight = - currentProject.settings.fontSize * PREVIEW_PLACEHOLDER_HEIGHT_RATIO; + currentFont.settings.fontSize * PREVIEW_PLACEHOLDER_HEIGHT_RATIO; context.strokeStyle = 'rgba(255,255,255,0.2)'; context.strokeRect( @@ -89,7 +89,7 @@ export const PreviewFloat = ({ open, onClose }: Props): React.JSX.Element => { const destY = glyph.yoffset; const imageData = new ImageData(glyph.width, glyph.height); - const threshold = effectiveThreshold(glyph, currentProject.settings); + const threshold = effectiveThreshold(glyph, currentFont.settings); for (let index = 0; index < glyph.pixels.length; index++) { const ink = glyph.pixels[index] >= threshold ? 255 : 0; @@ -110,7 +110,7 @@ export const PreviewFloat = ({ open, onClose }: Props): React.JSX.Element => { x += glyph.xadvance + spacing.x; } - }, [text, glyphs, currentProject]); + }, [text, glyphs, currentFont]); return (
{ placeholder="Type preview text…" />
- {currentProject ? ( + {currentFont ? ( ) : ( - No project open. + No font open. )}
diff --git a/src/features/editor/SettingsDialog.tsx b/src/features/editor/SettingsDialog.tsx index fa1c4e4..047a61a 100644 --- a/src/features/editor/SettingsDialog.tsx +++ b/src/features/editor/SettingsDialog.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { saveProject } from '@/db'; +import { saveFont } from '@/db'; import { FontMetricsFields } from '@/features/home/FontMetricsFields'; import { PaddingFields } from '@/features/home/PaddingFields'; import { SpacingFields } from '@/features/home/SpacingFields'; @@ -15,11 +15,11 @@ interface Props { } export const SettingsDialog = ({ open, onOpenChange }: Props): React.JSX.Element | null => { - const currentProject = useStore((state) => state.currentProject); - const updateCurrentProject = useStore((state) => state.updateCurrentProject); + const currentFont = useStore((state) => state.currentFont); + const updateCurrentFont = useStore((state) => state.updateCurrentFont); - const initialSettings = currentProject?.settings; - const [name, setName] = useState(currentProject?.name ?? ''); + const initialSettings = currentFont?.settings; + const [name, setName] = useState(currentFont?.name ?? ''); const [fontSize, setFontSize] = useState(initialSettings?.fontSize ?? 32); const [lineHeight, setLineHeight] = useState(initialSettings?.lineHeight ?? 36); const [base, setBase] = useState(initialSettings?.base ?? 28); @@ -32,13 +32,13 @@ export const SettingsDialog = ({ open, onOpenChange }: Props): React.JSX.Element const [spacingY, setSpacingY] = useState(initialSettings?.spacing.y ?? 1); useEffect(() => { - if (!currentProject) { + if (!currentFont) { return; } // eslint-disable-next-line react-hooks/set-state-in-effect - setName(currentProject.name); - const settings = currentProject.settings; + setName(currentFont.name); + const settings = currentFont.settings; setFontSize(settings.fontSize); setLineHeight(settings.lineHeight); @@ -51,21 +51,21 @@ export const SettingsDialog = ({ open, onOpenChange }: Props): React.JSX.Element setSpacingX(settings.spacing.x); setSpacingY(settings.spacing.y); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentProject?.id]); + }, [currentFont?.id]); - if (!currentProject) { + if (!currentFont) { return null; } - function commit(changes: Parameters[0]): void { - updateCurrentProject(changes); + function commit(changes: Parameters[0]): void { + updateCurrentFont(changes); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - void saveProject({ ...currentProject!, ...changes, updatedAt: Date.now() }); // non-null: guarded above + void saveFont({ ...currentFont!, ...changes, updatedAt: Date.now() }); // non-null: guarded above } - function commitSettings(partial: Partial['settings']>): void { + function commitSettings(partial: Partial['settings']>): void { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - commit({ settings: { ...currentProject!.settings, ...partial } }); // non-null: guarded above + commit({ settings: { ...currentFont!.settings, ...partial } }); // non-null: guarded above } return ( diff --git a/src/features/editor/glyph-list/GlyphList.tsx b/src/features/editor/glyph-list/GlyphList.tsx index 6e46206..3f55790 100644 --- a/src/features/editor/glyph-list/GlyphList.tsx +++ b/src/features/editor/glyph-list/GlyphList.tsx @@ -23,9 +23,9 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Slider } from '@/components/ui/slider'; -import type { Glyph } from '@/core/project'; -import { makeBlankGlyph } from '@/core/project'; -import { cloneLayers, makeBaseLayerFromBitmap, syncLegacyFields } from '@/core/project/layers'; +import type { Glyph } from '@/core/font'; +import { makeBlankGlyph } from '@/core/font'; +import { cloneLayers, makeBaseLayerFromBitmap, syncLegacyFields } from '@/core/font/layers'; import { deleteGlyph, getFontFile, saveGlyphs } from '@/db'; import { useRasterize } from '@/hooks/useRasterize'; import { cn } from '@/lib/utils'; @@ -77,13 +77,13 @@ export const GlyphList = ({ const [resetting, setResetting] = useState(false); const glyphs = useStore((state) => state.glyphs); - const currentProject = useStore((state) => state.currentProject); + const currentFont = useStore((state) => state.currentFont); const selectedCodePoint = useStore((state) => state.selectedCodePoint); const setSelectedCodePoint = useStore((state) => state.setSelectedCodePoint); const upsertGlyph = useStore((state) => state.upsertGlyph); const pushUndo = useStore((state) => state.pushUndo); const removeGlyph = useStore((state) => state.removeGlyph); - const updateCurrentProject = useStore((state) => state.updateCurrentProject); + const updateCurrentFont = useStore((state) => state.updateCurrentFont); const { rasterize } = useRasterize(); const sortedGlyphs = useMemo(() => sortGlyphs(glyphs), [glyphs]); @@ -114,14 +114,14 @@ export const GlyphList = ({ } async function handleResetToFont(): Promise { - if (!selectedGlyph || !currentProject?.settings.sourceFontId) { + if (!selectedGlyph || !currentFont?.settings.sourceFontId) { return; } setResetting(true); try { - const buf = await getFontFile(currentProject.settings.sourceFontId); + const buf = await getFontFile(currentFont.settings.sourceFontId); if (!buf) { return; @@ -130,7 +130,7 @@ export const GlyphList = ({ const result = await rasterize( buf, [selectedGlyph.codePoint], - currentProject.settings.fontSize, + currentFont.settings.fontSize, ); const rg = result.glyphs[0]; @@ -163,15 +163,15 @@ export const GlyphList = ({ } async function confirmRemoveGlyph(): Promise { - if (!selectedGlyph || !currentProject) { + if (!selectedGlyph || !currentFont) { return; } setRemoveOpen(false); removeGlyph(selectedGlyph.codePoint); - await deleteGlyph(currentProject.id, selectedGlyph.codePoint); - updateCurrentProject({ - glyphs: currentProject.glyphs.filter((codePoint) => codePoint !== selectedGlyph.codePoint), + await deleteGlyph(currentFont.id, selectedGlyph.codePoint); + updateCurrentFont({ + glyphs: currentFont.glyphs.filter((codePoint) => codePoint !== selectedGlyph.codePoint), }); setSelectedCodePoint(null); } @@ -201,14 +201,14 @@ export const GlyphList = ({ await saveGlyphs([cleared]); } - if (!currentProject) { + if (!currentFont) { return null; } - const hasSourceFont = !!currentProject.settings.sourceFontId; + const hasSourceFont = !!currentFont.settings.sourceFontId; async function handleAddGlyph(codePoint: number): Promise { - if (!currentProject) { + if (!currentFont) { return; } @@ -218,15 +218,15 @@ export const GlyphList = ({ return; } - const { fontSize, lineHeight } = currentProject.settings; - const glyph = makeBlankGlyph(currentProject.id, codePoint, fontSize, lineHeight); + const { fontSize, lineHeight } = currentFont.settings; + const glyph = makeBlankGlyph(currentFont.id, codePoint, fontSize, lineHeight); await saveGlyphs([glyph]); upsertGlyph(glyph); - // Add code point to project metadata if not already listed - if (!currentProject.glyphs.includes(codePoint)) { - updateCurrentProject({ glyphs: [...currentProject.glyphs, codePoint] }); + // Add code point to font metadata if not already listed + if (!currentFont.glyphs.includes(codePoint)) { + updateCurrentFont({ glyphs: [...currentFont.glyphs, codePoint] }); } setSelectedCodePoint(codePoint); @@ -271,7 +271,7 @@ export const GlyphList = ({ pixels={glyph.pixels} width={glyph.width} height={glyph.height} - threshold={glyph.alphaThreshold ?? currentProject.settings.alphaThreshold} + threshold={glyph.alphaThreshold ?? currentFont.settings.alphaThreshold} /> ) : ( ? @@ -353,7 +353,7 @@ export const GlyphList = ({ pixels={glyph.pixels} width={glyph.width} height={glyph.height} - threshold={glyph.alphaThreshold ?? currentProject.settings.alphaThreshold} + threshold={glyph.alphaThreshold ?? currentFont.settings.alphaThreshold} /> ) : ( ? @@ -405,7 +405,7 @@ export const GlyphList = ({
- {glyph.alphaThreshold ?? currentProject.settings.alphaThreshold} + {glyph.alphaThreshold ?? currentFont.settings.alphaThreshold} {glyph.alphaThreshold !== undefined && ( - {currentProject?.settings.sourceFontId && ( + {currentFont?.settings.sourceFontId && ( <>
@@ -252,15 +252,15 @@ export const EditorToolbar = ({ max={255} step={1} onValueChange={(value: number | readonly number[]) => { - if (!currentProject) { + if (!currentFont) { return; } const next = typeof value === 'number' ? value : value[0]; - const settings = { ...currentProject.settings, alphaThreshold: next }; + const settings = { ...currentFont.settings, alphaThreshold: next }; - updateCurrentProject({ settings }); - void saveProject({ ...currentProject, settings, updatedAt: Date.now() }); + updateCurrentFont({ settings }); + void saveFont({ ...currentFont, settings, updatedAt: Date.now() }); }} aria-label="Alpha threshold" /> diff --git a/src/features/export/ExportDialog.tsx b/src/features/export/ExportDialog.tsx index 0790f77..508b76b 100644 --- a/src/features/export/ExportDialog.tsx +++ b/src/features/export/ExportDialog.tsx @@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { TOAST_DURATION_MS } from '@/config'; import type { BmfGlyphData } from '@/core/bmf'; import { serializeBmfText } from '@/core/bmf'; -import { exportPortableProject } from '@/core/project'; +import { exportPortableFont } from '@/core/font'; import { GlyphSelection } from '@/features/editor/GlyphSelection'; import { useStore } from '@/store'; @@ -55,7 +55,7 @@ export const ExportDialog = ({ open, onOpenChange }: Props): React.JSX.Element = const [exporting, setExporting] = useState(false); const textareaRef = useRef(null); - const currentProject = useStore((state) => state.currentProject); + const currentFont = useStore((state) => state.currentFont); const glyphs = useStore((state) => state.glyphs); const atlasPlacements = useStore((state) => state.atlasPlacements); const addToast = useStore((state) => state.addToast); @@ -64,7 +64,7 @@ export const ExportDialog = ({ open, onOpenChange }: Props): React.JSX.Element = const atlasHeight = useStore((state) => state.atlasHeight); const exportSelection = useStore((state) => state.exportSelection); - const baseName = currentProject ? safeName(currentProject.name) : 'font'; + const baseName = currentFont ? safeName(currentFont.name) : 'font'; const atlasFilename = `${baseName}.png`; // Only include placements that are in the current selection @@ -75,9 +75,9 @@ export const ExportDialog = ({ open, onOpenChange }: Props): React.JSX.Element = const fntText = useMemo( () => - currentProject && selectedPlacements.length > 0 + currentFont && selectedPlacements.length > 0 ? serializeBmfText({ - project: currentProject, + font: currentFont, glyphs: selectedPlacements.map((placement): BmfGlyphData => { const glyph = glyphs.find((glyphItem) => glyphItem.codePoint === placement.codePoint); @@ -96,7 +96,7 @@ export const ExportDialog = ({ open, onOpenChange }: Props): React.JSX.Element = atlasFilename, }) : '', - [currentProject, selectedPlacements, glyphs, atlasWidth, atlasHeight, atlasFilename], + [currentFont, selectedPlacements, glyphs, atlasWidth, atlasHeight, atlasFilename], ); // Scroll textarea to top when content changes @@ -147,11 +147,11 @@ export const ExportDialog = ({ open, onOpenChange }: Props): React.JSX.Element = } function handleExportJson(): void { - if (!currentProject) { + if (!currentFont) { return; } - const json = exportPortableProject(currentProject, glyphs); + const json = exportPortableFont(currentFont, glyphs); download(new Blob([json], { type: 'application/json' }), `${baseName}.bmffont.json`); addToast(`Exported ${baseName}.bmffont.json`, 'success'); @@ -163,7 +163,7 @@ export const ExportDialog = ({ open, onOpenChange }: Props): React.JSX.Element = - Export — {currentProject?.name} + Export — {currentFont?.name}
@@ -213,7 +213,7 @@ export const ExportDialog = ({ open, onOpenChange }: Props): React.JSX.Element = size="sm" className="text-xs" onClick={handleExportJson} - disabled={!currentProject} + disabled={!currentFont} > Download .json diff --git a/src/features/home/DeleteProjectDialog.tsx b/src/features/home/DeleteFontDialog.tsx similarity index 86% rename from src/features/home/DeleteProjectDialog.tsx rename to src/features/home/DeleteFontDialog.tsx index 290bf92..5c7a9d7 100644 --- a/src/features/home/DeleteProjectDialog.tsx +++ b/src/features/home/DeleteFontDialog.tsx @@ -12,14 +12,14 @@ import { } from '@/components/ui/alert-dialog'; interface Props { - projectName: string; + fontName: string; open: boolean; onOpenChange: (open: boolean) => void; onConfirm: () => void; } -export const DeleteProjectDialog = ({ - projectName, +export const DeleteFontDialog = ({ + fontName, open, onOpenChange, onConfirm, @@ -30,7 +30,7 @@ export const DeleteProjectDialog = ({ Delete Font? - {projectName} will be permanently + {fontName} will be permanently deleted. This cannot be undone. diff --git a/src/features/home/GlyphSetSelect.tsx b/src/features/home/GlyphSetSelect.tsx index 741f595..96175e6 100644 --- a/src/features/home/GlyphSetSelect.tsx +++ b/src/features/home/GlyphSetSelect.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { GLYPH_SETS } from '@/core/project/glyphSets'; +import { GLYPH_SETS } from '@/core/font/glyphSets'; export const GlyphSetSelect = ({ id, diff --git a/src/features/home/HomeScreen.tsx b/src/features/home/HomeScreen.tsx index de3706b..9707660 100644 --- a/src/features/home/HomeScreen.tsx +++ b/src/features/home/HomeScreen.tsx @@ -4,20 +4,20 @@ import { useNavigate } from 'react-router'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; -import type { Project } from '@/core/project'; -import { importPortableProject } from '@/core/project'; -import { deleteProject, getAllProjects, saveGlyphs, saveProject } from '@/db'; +import type { Font } from '@/core/font'; +import { importPortableFont } from '@/core/font'; +import { deleteFont, getAllFonts, saveFont,saveGlyphs } from '@/db'; import { useStore } from '@/store'; -import { DeleteProjectDialog } from './DeleteProjectDialog'; +import { DeleteFontDialog } from './DeleteFontDialog'; import { ImportWizard } from './ImportWizard'; import { NewFontWizard } from './NewFontWizard'; export const HomeScreen = (): React.JSX.Element => { - const [projects, setProjects] = useState([]); - const [newProjectOpen, setNewProjectOpen] = useState(false); + const [fonts, setFonts] = useState([]); + const [newFontOpen, setNewFontOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); const renameInputRef = useRef(null); @@ -26,16 +26,16 @@ export const HomeScreen = (): React.JSX.Element => { const jsonInputRef = useRef(null); const navigate = useNavigate(); - const setCurrentProject = useStore((state) => state.setCurrentProject); + const setCurrentFont = useStore((state) => state.setCurrentFont); const setGlyphs = useStore((state) => state.setGlyphs); - async function loadProjects(): Promise { - setProjects(await getAllProjects()); + async function loadFonts(): Promise { + setFonts(await getAllFonts()); } useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect - void loadProjects(); + void loadFonts(); }, []); useEffect(() => { @@ -44,30 +44,30 @@ export const HomeScreen = (): React.JSX.Element => { } }, [renamingId]); - function openProject(project: Project): void { - setCurrentProject(project); + function openFont(font: Font): void { + setCurrentFont(font); void navigate('/editor'); } - function startRename(project: Project): void { - setRenamingId(project.id); - setRenameValue(project.name); + function startRename(font: Font): void { + setRenamingId(font.id); + setRenameValue(font.name); } - async function commitRename(project: Project): Promise { - const name = renameValue.trim() || project.name; + async function commitRename(font: Font): Promise { + const name = renameValue.trim() || font.name; // eslint-disable-next-line react-hooks/purity - const updated = { ...project, name, updatedAt: Date.now() }; // Date.now() is intentional for timestamp + const updated = { ...font, name, updatedAt: Date.now() }; // Date.now() is intentional for timestamp - await saveProject(updated); + await saveFont(updated); setRenamingId(null); - void loadProjects(); + void loadFonts(); } - async function handleDelete(project: Project): Promise { - await deleteProject(project.id); + async function handleDelete(font: Font): Promise { + await deleteFont(font.id); setDeleteTarget(null); - void loadProjects(); + void loadFonts(); } async function handleJsonImport(file: File): Promise { @@ -75,15 +75,15 @@ export const HomeScreen = (): React.JSX.Element => { try { const json = await file.text(); - const { project, glyphs } = importPortableProject(json); + const { font, glyphs } = importPortableFont(json); - await saveProject(project); + await saveFont(font); await saveGlyphs(glyphs); - setCurrentProject(project); + setCurrentFont(font); setGlyphs(glyphs); void navigate('/editor'); } catch (err) { - setJsonImportError(err instanceof Error ? err.message : 'Failed to import project'); + setJsonImportError(err instanceof Error ? err.message : 'Failed to import font'); } } @@ -104,7 +104,7 @@ export const HomeScreen = (): React.JSX.Element => {
{/* Actions */}
- @@ -137,8 +137,8 @@ export const HomeScreen = (): React.JSX.Element => { - {/* Project list */} - {projects.length === 0 ? ( + {/* Font list */} + {fonts.length === 0 ? (

No fonts yet.

@@ -150,33 +150,33 @@ export const HomeScreen = (): React.JSX.Element => {

Recent fonts

- {projects.map((project) => ( + {fonts.map((font) => (
openProject(project)} + onClick={() => openFont(font)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - openProject(project); + openFont(font); } }} >
- {renamingId === project.id ? ( + {renamingId === font.id ? ( setRenameValue(event.target.value)} onBlur={() => { - void commitRename(project); + void commitRename(font); }} onKeyDown={(event) => { if (event.key === 'Enter') { - void commitRename(project); + void commitRename(font); } if (event.key === 'Escape') { @@ -188,11 +188,11 @@ export const HomeScreen = (): React.JSX.Element => { onClick={(event) => event.stopPropagation()} /> ) : ( -

{project.name}

+

{font.name}

)}

- {project.settings.fontSize}px · {project.glyphs.length} glyphs ·{' '} - {formatDate(project.updatedAt)} + {font.settings.fontSize}px · {font.glyphs.length} glyphs ·{' '} + {formatDate(font.updatedAt)}

@@ -202,7 +202,7 @@ export const HomeScreen = (): React.JSX.Element => { className="h-7 w-7 hover:bg-white/20" onClick={(event) => { event.stopPropagation(); - startRename(project); + startRename(font); }} > @@ -213,7 +213,7 @@ export const HomeScreen = (): React.JSX.Element => { className="text-destructive hover:text-destructive h-7 w-7 hover:bg-white/20" onClick={(event) => { event.stopPropagation(); - setDeleteTarget(project); + setDeleteTarget(font); }} > @@ -226,12 +226,12 @@ export const HomeScreen = (): React.JSX.Element => {
{ - setNewProjectOpen(open); + setNewFontOpen(open); if (!open) { - void loadProjects(); + void loadFonts(); } }} /> @@ -241,13 +241,13 @@ export const HomeScreen = (): React.JSX.Element => { setImportOpen(open); if (!open) { - void loadProjects(); + void loadFonts(); } }} /> {deleteTarget && ( - !open && setDeleteTarget(null)} onConfirm={() => { diff --git a/src/features/home/ImportWizard.tsx b/src/features/home/ImportWizard.tsx index b88daca..f615f5f 100644 --- a/src/features/home/ImportWizard.tsx +++ b/src/features/home/ImportWizard.tsx @@ -13,17 +13,12 @@ import { } from '@/config'; import type { BmfParseResult } from '@/core/bmf/parse'; import { parseBmfText } from '@/core/bmf/parse'; -import type { RasterizedGlyph } from '@/core/font/rasterize'; -import { - createProject, - makeBaseLayerFromBitmap, - makeBlankGlyph, - makeBlankLayer, -} from '@/core/project'; -import { GLYPH_SETS } from '@/core/project/glyphSets'; -import type { Glyph } from '@/core/project/types'; +import { createFont, makeBaseLayerFromBitmap, makeBlankGlyph, makeBlankLayer } from '@/core/font'; +import { GLYPH_SETS } from '@/core/font/glyphSets'; +import type { Glyph } from '@/core/font/types'; +import type { RasterizedGlyph } from '@/core/rasterize/rasterize'; +import { saveFont } from '@/db/fonts'; import { saveFontFile, saveGlyphs } from '@/db/glyphs'; -import { saveProject } from '@/db/projects'; import { useRasterize } from '@/hooks/useRasterize'; import { useStore } from '@/store'; import type { ImportPreset } from '@/store/exportSlice'; @@ -72,7 +67,7 @@ function detectAtlasMode(data: Uint8ClampedArray, width: number, height: number) function sliceGlyphsFromAtlas( imageData: ImageData, chars: BmfParseResult['chars'], - projectId: string, + fontId: string, ): Glyph[] { const { data, width: atlasW, height: atlasH } = imageData; const mode = detectAtlasMode(data, atlasW, atlasH); @@ -81,7 +76,7 @@ function sliceGlyphsFromAtlas( if (char.width === 0 || char.height === 0) { return { codePoint: char.id, - projectId, + fontId, layers: [makeBlankLayer()], pixels: new Uint8Array(0), width: 0, @@ -111,7 +106,7 @@ function sliceGlyphsFromAtlas( return { codePoint: char.id, - projectId, + fontId, layers: [ makeBaseLayerFromBitmap({ pixels, @@ -142,7 +137,7 @@ export const ImportWizard = ({ open, onOpenChange }: Props): React.JSX.Element = const [ttfFile, setTtfFile] = useState(null); const [fntFile, setFntFile] = useState(null); const [pngFile, setPngFile] = useState(null); - const [projectName, setProjectName] = useState(''); + const [fontName, setFontName] = useState(''); const [importPreset, setImportPreset] = useState('all'); const [nameError, setNameError] = useState(''); @@ -176,7 +171,7 @@ export const ImportWizard = ({ open, onOpenChange }: Props): React.JSX.Element = const { rasterize } = useRasterize(); const navigate = useNavigate(); - const setCurrentProject = useStore((state) => state.setCurrentProject); + const setCurrentFont = useStore((state) => state.setCurrentFont); const setStoreGlyphs = useStore((state) => state.setGlyphs); // --- Step 1 file handlers --- @@ -188,8 +183,8 @@ export const ImportWizard = ({ open, onOpenChange }: Props): React.JSX.Element = setTtfFile(file); setFormat('ttf'); - if (!projectName) { - setProjectName(file.name.replace(/\.[^.]+$/, '')); + if (!fontName) { + setFontName(file.name.replace(/\.[^.]+$/, '')); } const reader = new FileReader(); @@ -207,8 +202,8 @@ export const ImportWizard = ({ open, onOpenChange }: Props): React.JSX.Element = setFntFile(file); setFormat('bmf'); - if (!projectName) { - setProjectName(file.name.replace(/\.fnt$/i, '')); + if (!fontName) { + setFontName(file.name.replace(/\.fnt$/i, '')); } // Read the atlas filename from the .fnt so we can hint it on the PNG drop zone @@ -227,7 +222,7 @@ export const ImportWizard = ({ open, onOpenChange }: Props): React.JSX.Element = // --- Step 1 → 2 transition: parse BMF early to pre-populate settings --- async function goToSettings(): Promise { - if (!projectName.trim()) { + if (!fontName.trim()) { setNameError('Font name is required'); return; @@ -360,7 +355,7 @@ export const ImportWizard = ({ open, onOpenChange }: Props): React.JSX.Element = const fontId = crypto.randomUUID(); const filteredCodePoints = filterCodePointsByPreset(importPreset, foundGlyphSet.codePoints); - const project = createProject(projectName || 'Untitled', { + const font = createFont(fontName || 'Untitled', { sourceFontId: fontId, fontSize, padding, @@ -370,10 +365,10 @@ export const ImportWizard = ({ open, onOpenChange }: Props): React.JSX.Element = capHeight, }); - project.glyphs = filteredCodePoints; + font.glyphs = filteredCodePoints; const glyphs: Glyph[] = (previewGlyphs as RasterizedGlyph[]).map((rasterizedGlyph) => ({ codePoint: rasterizedGlyph.codePoint, - projectId: project.id, + fontId: font.id, layers: [ makeBaseLayerFromBitmap({ pixels: rasterizedGlyph.pixels, @@ -394,9 +389,9 @@ export const ImportWizard = ({ open, onOpenChange }: Props): React.JSX.Element = // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await saveFontFile(fontId, ttfBufferRef.current, ttfFile!.name); // ttfFile is set when format === 'ttf' - await saveProject(project); + await saveFont(font); await saveGlyphs(glyphs); - setCurrentProject(project); + setCurrentFont(font); setStoreGlyphs(glyphs); } else { if (!bmfParsedRef.current || !bmfImageDataRef.current) { @@ -405,8 +400,8 @@ export const ImportWizard = ({ open, onOpenChange }: Props): React.JSX.Element = const parsed = bmfParsedRef.current; const imageData = bmfImageDataRef.current; - const name = projectName.trim() || parsed.info.face || 'Imported Font'; - const project = createProject(name, { + const name = fontName.trim() || parsed.info.face || 'Imported Font'; + const font = createFont(name, { sourceFontId: null, fontSize, padding, @@ -416,26 +411,26 @@ export const ImportWizard = ({ open, onOpenChange }: Props): React.JSX.Element = capHeight, }); - project.glyphs = foundGlyphSet.codePoints; + font.glyphs = foundGlyphSet.codePoints; const allFntCodePoints = parsed.chars.map((bmfChar) => bmfChar.id); const filteredCodePoints = filterCodePointsByPreset(importPreset, allFntCodePoints); const filteredSet = new Set(filteredCodePoints); const importedGlyphs = sliceGlyphsFromAtlas( imageData, parsed.chars.filter((bmfChar) => filteredSet.has(bmfChar.id)), - project.id, + font.id, ); const importedSet = new Set(importedGlyphs.map((glyph) => glyph.codePoint)); const blankGlyphs = foundGlyphSet.codePoints .filter((codePoint) => !importedSet.has(codePoint)) .map((codePoint) => - makeBlankGlyph(project.id, codePoint, fontSize, parsed.common.lineHeight), + makeBlankGlyph(font.id, codePoint, fontSize, parsed.common.lineHeight), ); const allGlyphs = [...importedGlyphs, ...blankGlyphs]; - await saveProject(project); + await saveFont(font); await saveGlyphs(allGlyphs); - setCurrentProject(project); + setCurrentFont(font); setStoreGlyphs(allGlyphs); } @@ -458,7 +453,7 @@ export const ImportWizard = ({ open, onOpenChange }: Props): React.JSX.Element = setTtfFile(null); setFntFile(null); setPngFile(null); - setProjectName(''); + setFontName(''); setNameError(''); setImportPreset('all'); setFontSize(DEFAULT_FONT_SIZE); @@ -569,11 +564,11 @@ export const ImportWizard = ({ open, onOpenChange }: Props): React.JSX.Element = { - setProjectName(event.target.value); + setFontName(event.target.value); if (event.target.value.trim()) { setNameError(''); diff --git a/src/features/home/NewFontWizard.tsx b/src/features/home/NewFontWizard.tsx index f7e74eb..ad03fda 100644 --- a/src/features/home/NewFontWizard.tsx +++ b/src/features/home/NewFontWizard.tsx @@ -11,9 +11,9 @@ import { DIALOG_RESET_DELAY_MS, LINE_HEIGHT_RATIO, } from '@/config'; -import { createProject, initializeGlyphs } from '@/core/project'; -import { GLYPH_SETS } from '@/core/project/glyphSets'; -import { saveGlyphs, saveProject } from '@/db'; +import { createFont, initializeGlyphs } from '@/core/font'; +import { GLYPH_SETS } from '@/core/font/glyphSets'; +import { saveFont,saveGlyphs } from '@/db'; import { useStore } from '@/store'; import { FontMetricsFields } from './FontMetricsFields'; @@ -51,7 +51,7 @@ export const NewFontWizard = ({ open, onOpenChange }: Props): React.JSX.Element const [nameError, setNameError] = useState(''); const navigate = useNavigate(); - const setCurrentProject = useStore((state) => state.setCurrentProject); + const setCurrentFont = useStore((state) => state.setCurrentFont); const setGlyphs = useStore((state) => state.setGlyphs); async function handleCreate(): Promise { @@ -60,7 +60,7 @@ export const NewFontWizard = ({ open, onOpenChange }: Props): React.JSX.Element try { const glyphSet = GLYPH_SETS.find((glyphSetItem) => glyphSetItem.id === glyphSetId) ?? GLYPH_SETS[0]; - const project = createProject(name || 'Untitled', { + const font = createFont(name || 'Untitled', { fontSize, padding: { top: paddingTop, right: paddingRight, bottom: paddingBottom, left: paddingLeft }, spacing: { x: spacingX, y: spacingY }, @@ -69,17 +69,17 @@ export const NewFontWizard = ({ open, onOpenChange }: Props): React.JSX.Element capHeight, }); - project.glyphs = glyphSet.codePoints; + font.glyphs = glyphSet.codePoints; const glyphs = initializeGlyphs( - project.id, + font.id, glyphSet.codePoints, fontSize, Math.round(fontSize * LINE_HEIGHT_RATIO), ); - await saveProject(project); + await saveFont(font); await saveGlyphs(glyphs); - setCurrentProject(project); + setCurrentFont(font); setGlyphs(glyphs); void navigate('/editor'); onOpenChange(false); diff --git a/src/hooks/useAtlas.ts b/src/hooks/useAtlas.ts index 4b61e96..9a00c58 100644 --- a/src/hooks/useAtlas.ts +++ b/src/hooks/useAtlas.ts @@ -1,7 +1,7 @@ import { useCallback, useRef } from 'react'; -import { flattenGlyph } from '@/core/project/layers'; -import type { Glyph, GlyphPlacement } from '@/core/project/types'; +import { flattenGlyph } from '@/core/font/layers'; +import type { Glyph, GlyphPlacement } from '@/core/font/types'; import type { AtlasWorkerRequest, AtlasWorkerResponse } from '@/workers/atlas.worker'; export interface AtlasResult { diff --git a/src/hooks/useRasterize.ts b/src/hooks/useRasterize.ts index 781af48..949eb8f 100644 --- a/src/hooks/useRasterize.ts +++ b/src/hooks/useRasterize.ts @@ -1,6 +1,6 @@ import { useCallback, useRef } from 'react'; -import type { RasterizeResult } from '@/core/font/rasterize'; +import type { RasterizeResult } from '@/core/rasterize/rasterize'; import type { RasterizeWorkerRequest, RasterizeWorkerResponse } from '@/workers/rasterize.worker'; export function useRasterize(): { diff --git a/src/hooks/useUndoRedo.ts b/src/hooks/useUndoRedo.ts index 1ec25da..ccffb5d 100644 --- a/src/hooks/useUndoRedo.ts +++ b/src/hooks/useUndoRedo.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { cloneLayers, syncLegacyFields } from '@/core/project/layers'; +import { cloneLayers, syncLegacyFields } from '@/core/font/layers'; import { saveGlyphs } from '@/db/glyphs'; import { useStore } from '@/store'; diff --git a/src/store/atlasSlice.ts b/src/store/atlasSlice.ts index 53a583d..5127304 100644 --- a/src/store/atlasSlice.ts +++ b/src/store/atlasSlice.ts @@ -1,6 +1,6 @@ import type { StateCreator } from 'zustand'; -import type { GlyphPlacement } from '@/core/project/types'; +import type { GlyphPlacement } from '@/core/font/types'; export interface AtlasSlice { atlasPlacements: GlyphPlacement[]; diff --git a/src/store/editorSlice.ts b/src/store/editorSlice.ts index 11ea1d0..1d6b237 100644 --- a/src/store/editorSlice.ts +++ b/src/store/editorSlice.ts @@ -9,7 +9,7 @@ import { ZOOM_DEFAULT, ZOOM_MAX, } from '@/config'; -import type { Layer } from '@/core/project'; +import type { Layer } from '@/core/font'; export type EditorTool = 'pencil' | 'eraser' | 'move' | 'zoom'; diff --git a/src/store/fontSlice.ts b/src/store/fontSlice.ts new file mode 100644 index 0000000..3a0f361 --- /dev/null +++ b/src/store/fontSlice.ts @@ -0,0 +1,20 @@ +import type { StateCreator } from 'zustand'; + +import type { Font } from '@/core/font'; + +export interface FontSlice { + currentFont: Font | null; + setCurrentFont: (font: Font | null) => void; + updateCurrentFont: (changes: Partial>) => void; +} + +export const createFontSlice: StateCreator = (set) => ({ + currentFont: null, + setCurrentFont: (font) => set({ currentFont: font }), + updateCurrentFont: (changes) => + set((state) => ({ + currentFont: state.currentFont + ? { ...state.currentFont, ...changes, updatedAt: Date.now() } + : null, + })), +}); diff --git a/src/store/glyphSlice.ts b/src/store/glyphSlice.ts index 7663f17..ebf9f6b 100644 --- a/src/store/glyphSlice.ts +++ b/src/store/glyphSlice.ts @@ -1,6 +1,6 @@ import type { StateCreator } from 'zustand'; -import type { Glyph } from '@/core/project'; +import type { Glyph } from '@/core/font'; export interface GlyphSlice { glyphs: Glyph[]; diff --git a/src/store/index.ts b/src/store/index.ts index 4e25b9f..f3858ed 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -4,16 +4,16 @@ import { persist } from 'zustand/middleware'; import { type AtlasSlice, createAtlasSlice } from './atlasSlice'; import { createEditorSlice, type EditorSlice } from './editorSlice'; import { createExportSlice, type ExportSlice } from './exportSlice'; +import { createFontSlice, type FontSlice } from './fontSlice'; import { createGlyphSlice, type GlyphSlice } from './glyphSlice'; -import { createProjectSlice, type ProjectSlice } from './projectSlice'; import { createUiSlice, type UiSlice } from './uiSlice'; -export type AppStore = ProjectSlice & GlyphSlice & EditorSlice & UiSlice & AtlasSlice & ExportSlice; +export type AppStore = FontSlice & GlyphSlice & EditorSlice & UiSlice & AtlasSlice & ExportSlice; export const useStore = create()( persist( (...args) => ({ - ...createProjectSlice(...args), + ...createFontSlice(...args), ...createGlyphSlice(...args), ...createEditorSlice(...args), ...createUiSlice(...args), @@ -24,7 +24,7 @@ export const useStore = create()( name: 'bmf-generator', // Only persist lightweight metadata; pixel buffers live in IndexedDB partialize: (state) => ({ - currentProject: state.currentProject, + currentFont: state.currentFont, activeTool: state.activeTool, zoomLevel: state.zoomLevel, showGrid: state.showGrid, @@ -36,6 +36,6 @@ export const useStore = create()( export * from './atlasSlice'; export * from './editorSlice'; export * from './exportSlice'; +export * from './fontSlice'; export * from './glyphSlice'; -export * from './projectSlice'; export * from './uiSlice'; diff --git a/src/store/projectSlice.ts b/src/store/projectSlice.ts deleted file mode 100644 index 5f20a32..0000000 --- a/src/store/projectSlice.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { StateCreator } from 'zustand'; - -import type { Project } from '@/core/project'; - -export interface ProjectSlice { - currentProject: Project | null; - setCurrentProject: (project: Project | null) => void; - updateCurrentProject: (changes: Partial>) => void; -} - -export const createProjectSlice: StateCreator = (set) => ({ - currentProject: null, - setCurrentProject: (project) => set({ currentProject: project }), - updateCurrentProject: (changes) => - set((state) => ({ - currentProject: state.currentProject - ? { ...state.currentProject, ...changes, updatedAt: Date.now() } - : null, - })), -}); diff --git a/src/workers/atlas.worker.ts b/src/workers/atlas.worker.ts index 85bca2c..b3657f5 100644 --- a/src/workers/atlas.worker.ts +++ b/src/workers/atlas.worker.ts @@ -1,6 +1,6 @@ import { chooseAtlasSize, packGlyphs } from '@/core/atlas/pack'; -import { effectiveThreshold } from '@/core/project/threshold'; -import type { Glyph, GlyphPlacement } from '@/core/project/types'; +import { effectiveThreshold } from '@/core/font/threshold'; +import type { Glyph, GlyphPlacement } from '@/core/font/types'; export interface AtlasWorkerRequest { id: string; @@ -9,7 +9,7 @@ export interface AtlasWorkerRequest { atlasWidth: number; atlasHeight: number; padding: number; - /** Project-wide alpha cutoff (0–255); glyphs may override individually. */ + /** Font-wide alpha cutoff (0–255); glyphs may override individually. */ defaultAlphaThreshold: number; } diff --git a/src/workers/rasterize.worker.ts b/src/workers/rasterize.worker.ts index 1738ac3..1658278 100644 --- a/src/workers/rasterize.worker.ts +++ b/src/workers/rasterize.worker.ts @@ -1,4 +1,8 @@ -import { rasterizeFont, type RasterizeRequest, type RasterizeResult } from '@/core/font/rasterize'; +import { + rasterizeFont, + type RasterizeRequest, + type RasterizeResult, +} from '@/core/rasterize/rasterize'; export interface RasterizeWorkerRequest extends RasterizeRequest { id: string; From decc4c3819f04dd48eb959825e99a93ef4713c93 Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Sun, 21 Jun 2026 22:55:27 +0300 Subject: [PATCH 2/3] docs: add ADRs for the PortableFont version break and the glyphs store rebuild Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/adr/0001-portable-font-v2-breaking-change.md | 5 +++++ docs/adr/0002-glyphs-store-rebuild-via-temp-store.md | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 docs/adr/0001-portable-font-v2-breaking-change.md create mode 100644 docs/adr/0002-glyphs-store-rebuild-via-temp-store.md diff --git a/docs/adr/0001-portable-font-v2-breaking-change.md b/docs/adr/0001-portable-font-v2-breaking-change.md new file mode 100644 index 0000000..0cb36fc --- /dev/null +++ b/docs/adr/0001-portable-font-v2-breaking-change.md @@ -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. diff --git a/docs/adr/0002-glyphs-store-rebuild-via-temp-store.md b/docs/adr/0002-glyphs-store-rebuild-via-temp-store.md new file mode 100644 index 0000000..6386311 --- /dev/null +++ b/docs/adr/0002-glyphs-store-rebuild-via-temp-store.md @@ -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. From f22d072471ba7c4fa1f433b0d1d60eee802d69dc Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Sun, 21 Jun 2026 22:57:06 +0300 Subject: [PATCH 3/3] docs: tighten CONTEXT.md after cross-referencing with the renamed code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source Font: clarify FontFile is a persistence detail, not part of the domain language. Cell: the editor renders layer-by-layer, not at a single per-glyph offset. Portable Font: drop the v1/v2 wire-format note — that belongs to ADR-0001, not the glossary. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTEXT.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index eb6bd7c..3fd48f7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,8 +15,8 @@ Font-wide BMF metrics (`fontSize`, `padding`, `spacing`, `lineHeight`, `base`, ` _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. Persisted as a `FontFile` blob in IndexedDB and referenced by `FontSettings.sourceFontId`. -_Avoid_: Upload, font file (the on-disk persisted name is `FontFile`, but the concept is "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. @@ -48,7 +48,7 @@ _Avoid_: Cutoff, ink threshold. 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; glyphs are rendered into it at their `xoffset`/`yoffset`. +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 @@ -86,5 +86,5 @@ _Avoid_: Manifest, fnt file (use "descriptor" or ".fnt" explicitly). 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). Wire format is at v2 — v1 bundles (which used the legacy "project" naming) are no longer accepted. +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.