diff --git a/Cargo.lock b/Cargo.lock index 2fee5bfa..7e23b4e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1634,6 +1634,7 @@ dependencies = [ "glyphs-reader", "norad 0.16.0", "plist", + "quick-xml", "shift-ir", "skrifa", "tempfile", diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index c2d7bd52..9949887f 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -9,7 +9,7 @@ Electron main process: application lifecycle, window management, menus, document - **Architecture Invariant:** IPC channels are type-safe. All `ipcMain.handle` calls use the typed `ipc.handle` wrapper from `shared/ipc/main`, and all `webContents.send` calls use the typed `ipc.send` wrapper. Channel names and payload types are defined in `IpcCommands` (renderer-to-main) and `IpcEvents` (main-to-renderer). - **Architecture Invariant: CRITICAL:** `main.ts` enforces a single-instance lock via `app.requestSingleInstanceLock()`. The second instance forwards its argv to the first instance via the `second-instance` event and then quits. Removing this breaks file-association double-click on all platforms. - **Architecture Invariant: CRITICAL:** The `before-quit` handler in `AppLifecycle` must call `event.preventDefault()` before the async `confirmClose` check. If the guard is removed, the app quits before the save dialog can appear. -- **Architecture Invariant:** Only `.ufo` is a writable format (`DocumentState.isWritableFormat`). Saving a non-UFO file always triggers Save As. Autosave skips non-UFO files silently. +- **Architecture Invariant:** `.designspace` is the default writable format, with `.ufo` still accepted for direct UFO saves (`DocumentState.isWritableFormat`). Saving other imported formats triggers Save As. Autosave skips non-writable files silently. ## Codemap @@ -33,7 +33,7 @@ src/main/ - `DebugOverlays` -- per-overlay booleans (`tightBounds`, `hitRadii`, `segmentBounds`, `glyphBbox`) - `IpcCommands` -- renderer-to-main request/response channels (invoke/handle) - `IpcEvents` -- main-to-renderer broadcast channels (send/on) -- `SUPPORTED_FONT_EXTENSIONS` -- the set of file extensions accepted for opening (`.ufo`, `.ttf`, `.otf`, `.glyphs`, `.glyphspackage`) +- `SUPPORTED_FONT_EXTENSIONS` -- the set of file extensions accepted for opening (`.ufo`, `.ttf`, `.otf`, `.glyphs`, `.glyphspackage`, `.designspace`) ## How it works @@ -55,11 +55,11 @@ Files arrive via three paths: CLI launch args (`handleLaunchArgs`), second-insta ### Save and autosave -`DocumentState.save` checks `isWritableFormat` -- only `.ufo` can be saved in-place. Non-UFO files and Save As always show the save dialog with a UFO filter. On save, the main process sends `menu:save-font` to the renderer, which does the actual write and calls back `document:saveCompleted`. Autosave runs on a 30-second interval (`AUTOSAVE_INTERVAL_MS`) and only fires if dirty and the file is writable UFO. +`DocumentState.save` checks `isWritableFormat` -- `.designspace` and `.ufo` can be saved in-place. Other imported formats and Save As show the save dialog with Designspace as the default filter. On save, the main process sends `menu:save-font` to the renderer, which does the actual write and calls back `document:saveCompleted`. Autosave runs on a 30-second interval (`AUTOSAVE_INTERVAL_MS`) and only fires if dirty and the file is writable. ### Menu -`MenuManager.create` rebuilds the entire menu template each time it is called (to update radio/checkbox state). It includes File (open/save), Edit (undo/redo/delete/select-all forwarded to renderer), View (zoom, theme, devtools), and a Debug menu (only in dev builds) for React Scan, debug panel, snapshot dumps, and overlay toggles. +`MenuManager.create` rebuilds the entire menu template each time it is called (to update radio/checkbox state). It includes File (new/open/save), Edit (undo/redo/delete/select-all forwarded to renderer), View (zoom, theme, devtools), and a Debug menu (only in dev builds) for React Scan, debug panel, snapshot dumps, and overlay toggles. File -> New Font and File -> Open Font both run through `DocumentState.confirmClose` before asking the renderer to replace the current document. ### IPC registration @@ -104,7 +104,7 @@ IPC handlers are split across managers. `WindowManager` registers window-control - `npx vitest run apps/desktop/src/main/managers/openFontPath.test.ts` -- openFontPath unit tests - Manual: launch with a font path argument, verify it opens - Manual: edit a document, Cmd+Q, verify save dialog appears -- Manual: open a .ttf, Cmd+S, verify Save As dialog forces .ufo +- Manual: open a .ttf, Cmd+S, verify Save As dialog defaults to .designspace ## Related diff --git a/apps/desktop/src/main/logger.ts b/apps/desktop/src/main/logger.ts new file mode 100644 index 00000000..daae2ebd --- /dev/null +++ b/apps/desktop/src/main/logger.ts @@ -0,0 +1,14 @@ +function formatDetails(details: unknown[]): string { + if (details.length === 0) return ""; + + try { + return ` ${JSON.stringify(details)}`; + } catch { + return ` ${details.map(String).join(" ")}`; + } +} + +export function mainLog(scope: string, message: string, ...details: unknown[]): void { + const timestamp = new Date().toISOString(); + process.stdout.write(`[shift:${scope}] ${timestamp} ${message}${formatDetails(details)}\n`); +} diff --git a/apps/desktop/src/main/managers/DocumentState.ts b/apps/desktop/src/main/managers/DocumentState.ts index d312e3a0..c061c04d 100644 --- a/apps/desktop/src/main/managers/DocumentState.ts +++ b/apps/desktop/src/main/managers/DocumentState.ts @@ -45,7 +45,7 @@ export class DocumentState { private isWritableFormat(filePath: string | null): boolean { if (!filePath) return false; - return filePath.endsWith(".ufo"); + return filePath.endsWith(".designspace") || filePath.endsWith(".ufo"); } async save(saveAs = false): Promise { @@ -56,15 +56,18 @@ export class DocumentState { let savePath = this.filePath; if (!savePath || saveAs || !this.isWritableFormat(savePath)) { - let defaultPath = "Untitled.ufo"; + let defaultPath = "Untitled.designspace"; if (this.filePath) { const baseName = path.basename(this.filePath, path.extname(this.filePath)); - defaultPath = `${baseName}.ufo`; + defaultPath = `${baseName}.designspace`; } const result = await dialog.showSaveDialog({ defaultPath, - filters: [{ name: "UFO Files", extensions: ["ufo"] }], + filters: [ + { name: "Designspace Files", extensions: ["designspace"] }, + { name: "UFO Files", extensions: ["ufo"] }, + ], }); if (result.canceled || !result.filePath) { @@ -72,8 +75,8 @@ export class DocumentState { } savePath = result.filePath; - if (!savePath.endsWith(".ufo")) { - savePath += ".ufo"; + if (!savePath.endsWith(".designspace") && !savePath.endsWith(".ufo")) { + savePath += ".designspace"; } } diff --git a/apps/desktop/src/main/managers/MenuManager.ts b/apps/desktop/src/main/managers/MenuManager.ts index 78a737b0..ea2a3d85 100644 --- a/apps/desktop/src/main/managers/MenuManager.ts +++ b/apps/desktop/src/main/managers/MenuManager.ts @@ -4,6 +4,7 @@ import type { WindowManager } from "./WindowManager"; import type { ThemeName, DebugOverlays, Debug } from "../../shared/ipc/types"; import type { IpcEvents } from "../../shared/ipc/channels"; import * as ipc from "../../shared/ipc/main"; +import { mainLog } from "../logger"; export class MenuManager { private documentState: DocumentState; @@ -38,7 +39,12 @@ export class MenuManager { ...args: Parameters ): void { const webContents = this.windowManager.getWindow()?.webContents; - if (!webContents) return; + if (!webContents) { + mainLog("menu", `drop ${String(channel)}: no renderer window`); + return; + } + + mainLog("menu", `send ${String(channel)}`, ...args); ipc.send(webContents, channel, ...args); } @@ -113,10 +119,19 @@ export class MenuManager { { label: "File", submenu: [ + { + label: "New Font", + accelerator: "CmdOrCtrl+N", + click: async () => { + if (!(await this.documentState.confirmClose())) return; + this.sendToRenderer("document:new"); + }, + }, { label: "Open Font...", accelerator: "CmdOrCtrl+O", click: async () => { + if (!(await this.documentState.confirmClose())) return; const result = await dialog.showOpenDialog({ properties: ["openFile", "openDirectory"], filters: [ @@ -142,6 +157,26 @@ export class MenuManager { accelerator: "CmdOrCtrl+Shift+S", click: () => this.documentState.save(true), }, + { + label: "Export TTF...", + accelerator: "CmdOrCtrl+E", + click: async () => { + const baseName = this.documentState + .getFileName() + .replace(/\.[^.]+$/, "") + .replace(/\.designspace$/, ""); + const result = await dialog.showSaveDialog({ + defaultPath: `${baseName || "Untitled"}.ttf`, + filters: [{ name: "TrueType Font", extensions: ["ttf"] }], + }); + if (!result.canceled && result.filePath) { + const exportPath = result.filePath.endsWith(".ttf") + ? result.filePath + : `${result.filePath}.ttf`; + this.sendToRenderer("menu:export-font", exportPath); + } + }, + }, { type: "separator" }, isMac ? { role: "close" } : { role: "quit" }, ], diff --git a/apps/desktop/src/main/managers/openFontPath.test.ts b/apps/desktop/src/main/managers/openFontPath.test.ts index 105e64fb..97bb68b6 100644 --- a/apps/desktop/src/main/managers/openFontPath.test.ts +++ b/apps/desktop/src/main/managers/openFontPath.test.ts @@ -12,6 +12,8 @@ describe("openFontPath", () => { expect(isSupportedFontPath("/tmp/font.glyphs")).toBe(true); expect(isSupportedFontPath("/tmp/font.glyphspackage")).toBe(true); expect(isSupportedFontPath("/tmp/font.GLYPHSPACKAGE")).toBe(true); + expect(isSupportedFontPath("/tmp/font.designspace")).toBe(true); + expect(isSupportedFontPath("/tmp/font.DESIGNSPACE")).toBe(true); }); it("rejects unsupported extensions", () => { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f55abdde..0ae178ad 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -45,9 +45,11 @@ const electronAPI: ElectronAPI = { pathsExist: invoke("fs:pathsExist"), // Events + onDocumentNew: on("document:new"), onMenuOpenFont: on("menu:open-font"), onExternalOpenFont: on("external:open-font"), onMenuSaveFont: on("menu:save-font"), + onMenuExportFont: on("menu:export-font"), onMenuUndo: on("menu:undo"), onMenuRedo: on("menu:redo"), onMenuDelete: on("menu:delete"), diff --git a/apps/desktop/src/renderer/src/app/App.tsx b/apps/desktop/src/renderer/src/app/App.tsx index de4bd1aa..5eebba67 100644 --- a/apps/desktop/src/renderer/src/app/App.tsx +++ b/apps/desktop/src/renderer/src/app/App.tsx @@ -101,6 +101,11 @@ export const App = () => { const unsubscribeOpen = window.electronAPI?.onMenuOpenFont(handleOpenFont); const unsubscribeExternalOpen = window.electronAPI?.onExternalOpenFont(handleOpenFont); + const unsubscribeNew = window.electronAPI?.onDocumentNew(() => { + fontDocument.createFont(); + didOpenFont = true; + navigateToHome(); + }); const unsubscribeSave = window.electronAPI?.onMenuSaveFont(async (savePath) => { try { @@ -110,6 +115,14 @@ export const App = () => { } }); + const unsubscribeExport = window.electronAPI?.onMenuExportFont(async (exportPath) => { + try { + await fontDocument.exportFont(exportPath); + } catch (error) { + console.error("Failed to export font:", error); + } + }); + const unsubscribePatternDump = window.electronAPI?.onDebugDumpSelectionPatterns(() => { dumpSelectionPatternsToConsole(); }); @@ -117,9 +130,11 @@ export const App = () => { return () => { window.removeEventListener("beforeunload", handleBeforeUnload); documentPersistence.dispose(); + if (unsubscribeNew) unsubscribeNew(); if (unsubscribeOpen) unsubscribeOpen(); if (unsubscribeExternalOpen) unsubscribeExternalOpen(); if (unsubscribeSave) unsubscribeSave(); + if (unsubscribeExport) unsubscribeExport(); if (unsubscribePatternDump) unsubscribePatternDump(); }; }, []); diff --git a/apps/desktop/src/renderer/src/app/Document.ts b/apps/desktop/src/renderer/src/app/Document.ts index b5f23147..63fdba8f 100644 --- a/apps/desktop/src/renderer/src/app/Document.ts +++ b/apps/desktop/src/renderer/src/app/Document.ts @@ -99,6 +99,10 @@ export class Document { await this.#notifySaveCompleted(savePath); } + async exportFont(path: string): Promise { + await this.editor.exportFont(path); + } + close(): void { this.#persistence.closeDocument(); this.editor.closeFont(); diff --git a/apps/desktop/src/renderer/src/app/WorkspaceLayout.tsx b/apps/desktop/src/renderer/src/app/WorkspaceLayout.tsx index d9a0dc79..66406b9d 100644 --- a/apps/desktop/src/renderer/src/app/WorkspaceLayout.tsx +++ b/apps/desktop/src/renderer/src/app/WorkspaceLayout.tsx @@ -7,6 +7,7 @@ export const WorkspaceLayout = () => {
} /> + } /> } /> } /> diff --git a/apps/desktop/src/renderer/src/assets/minus.svg b/apps/desktop/src/renderer/src/assets/minus.svg index 1a18d3d9..ceda7902 100644 --- a/apps/desktop/src/renderer/src/assets/minus.svg +++ b/apps/desktop/src/renderer/src/assets/minus.svg @@ -1,4 +1,3 @@ - - + diff --git a/apps/desktop/src/renderer/src/assets/plus.svg b/apps/desktop/src/renderer/src/assets/plus.svg index ac82f299..cc6e39f3 100644 --- a/apps/desktop/src/renderer/src/assets/plus.svg +++ b/apps/desktop/src/renderer/src/assets/plus.svg @@ -1,6 +1,4 @@ - - - - + + diff --git a/apps/desktop/src/renderer/src/components/editor/EditorView.tsx b/apps/desktop/src/renderer/src/components/editor/EditorView.tsx index 0dab5842..5bb825dc 100644 --- a/apps/desktop/src/renderer/src/components/editor/EditorView.tsx +++ b/apps/desktop/src/renderer/src/components/editor/EditorView.tsx @@ -11,14 +11,26 @@ import { StaticScene } from "./StaticScene"; import { DebugPanel } from "../debug/DebugPanel"; import { TextInput } from "../text/HiddenTextInput"; import { Vec2 } from "@shift/geo"; +import type { GlyphName } from "@shift/types"; +import type { GlyphHandle } from "@shift/bridge"; const GLYPH_ID_RE = /^[0-9a-f]+$/i; +function handleFromGlyphId(glyphId: string | undefined): GlyphHandle | null { + if (!glyphId || !GLYPH_ID_RE.test(glyphId)) return null; + + const parsed = Number.parseInt(glyphId, 16); + if (Number.isNaN(parsed)) return null; + + return getEditor().font.glyphHandleForUnicode(parsed); +} + interface EditorViewProps { - glyphId: string; + glyphId?: string; + glyphName?: GlyphName; } -export const EditorView: FC = ({ glyphId }) => { +export const EditorView: FC = ({ glyphId, glyphName }) => { const editor = getEditor(); const debug = useDebugSafe(); const containerRef = useRef(null); @@ -35,13 +47,11 @@ export const EditorView: FC = ({ glyphId }) => { useEffect(() => { if (!fontLoaded) return undefined; - if (!GLYPH_ID_RE.test(glyphId)) return undefined; - - const parsed = Number.parseInt(glyphId, 16); - if (Number.isNaN(parsed)) return undefined; - const unicode = parsed; - const handle = editor.font.glyphHandleForUnicode(unicode); + const handle = glyphName + ? editor.font.glyphHandleForName(glyphName) + : handleFromGlyphId(glyphId); + if (!handle) return undefined; const source = editor.font.sourceAtOrDefault(editor.font.defaultLocation()); @@ -58,7 +68,7 @@ export const EditorView: FC = ({ glyphId }) => { toolManager.reset(); editor.close(); }; - }, [editor, fontLoaded, glyphId]); + }, [editor, fontLoaded, glyphId, glyphName]); useEffect(() => { const element = containerRef.current; diff --git a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx index dc2e48de..910646aa 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx @@ -40,14 +40,15 @@ * Each virtual row: unicodes.slice(rowIndex * columns, (rowIndex + 1) * columns) * Row DOM: flex gap-2 px-4; each cell width/maxWidth = cellWidth, min-w-0. */ + import { memo, useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { codepointToHex } from "@/lib/utils/unicode"; import { CELL_HEIGHT, GlyphPreview } from "@/components/home/GlyphPreview"; -import { getEditor, getGlyphInfo } from "@/store/store"; -import { useGlyphCatalog } from "@/context/GlyphCatalogContext"; -import { Button } from "@shift/ui"; +import { getEditor, getGlyphInfo, markDocumentDirty } from "@/store/store"; +import { type GlyphCatalogItem, useGlyphCatalog } from "@/context/GlyphCatalogContext"; +import { Button, Input } from "@shift/ui"; +import type { GlyphName } from "@shift/types"; const ROW_HEIGHT = CELL_HEIGHT + 40 + 8; const NOMINAL_CELL_WIDTH = 100; @@ -66,7 +67,7 @@ function computeLayout(width: number) { export const GlyphGrid = memo(function GlyphGrid() { const navigate = useNavigate(); const editor = getEditor(); - const { filteredUnicodes: unicodes } = useGlyphCatalog(); + const { filteredGlyphs: glyphs } = useGlyphCatalog(); const scrollContainerRef = useRef(null); @@ -94,7 +95,7 @@ export const GlyphGrid = memo(function GlyphGrid() { const { columns, cellWidth } = layout; - const rowCount = Math.ceil(unicodes.length / columns); + const rowCount = Math.ceil(glyphs.length / columns); const virtualizer = useVirtualizer({ count: rowCount, @@ -103,11 +104,9 @@ export const GlyphGrid = memo(function GlyphGrid() { overscan: OVERSCAN, }); - const glyphInfo = getGlyphInfo(); - const handleCellClick = useCallback( - (unicode: number) => { - navigate(`/editor/${codepointToHex(unicode)}`); + (glyph: GlyphCatalogItem) => { + navigate(`/editor/glyph/${encodeURIComponent(glyph.name)}`); }, [navigate], ); @@ -117,7 +116,7 @@ export const GlyphGrid = memo(function GlyphGrid() { ref={scrollContainerRef} className="h-full min-h-0 w-full overflow-y-auto overflow-x-hidden p-5" > - {unicodes.length === 0 ? ( + {glyphs.length === 0 ? (
No glyphs match this filter.
@@ -131,7 +130,7 @@ export const GlyphGrid = memo(function GlyphGrid() { > {virtualizer.getVirtualItems().map((virtualRow) => { const startIndex = virtualRow.index * columns; - const rowUnicodes = unicodes.slice(startIndex, startIndex + columns); + const rowGlyphs = glyphs.slice(startIndex, startIndex + columns); return (
- {rowUnicodes.map((unicode) => ( + {rowGlyphs.map((glyph) => (
- - {glyphInfo.getGlyphName(unicode) ?? String.fromCodePoint(unicode)} - +
))}
@@ -171,3 +172,60 @@ export const GlyphGrid = memo(function GlyphGrid() { ); }); + +function GlyphNameInput({ glyph }: { readonly glyph: GlyphCatalogItem }) { + const editor = getEditor(); + const glyphInfo = getGlyphInfo(); + const glyphName = glyph.name; + const glyphExists = glyph.exists; + const [draft, setDraft] = useState(glyphName); + + useEffect(() => { + setDraft(glyphName); + }, [glyphName]); + + const commit = () => { + const next = draft.trim() as GlyphName; + if (!glyphExists || next === glyphName) { + setDraft(glyphName); + return; + } + + if (!next || editor.font.hasGlyph(next)) { + setDraft(glyphName); + return; + } + + const resolved = glyphInfo.getGlyphByName(next); + editor.font.updateGlyphIdentity(glyphName, next, resolved ? [resolved.codepoint] : []); + markDocumentDirty(); + }; + + return ( + setDraft(event.currentTarget.value as GlyphName)} + onBlur={commit} + onKeyDown={(event) => { + event.nativeEvent.stopImmediatePropagation(); + + if (event.key === "Enter") { + event.currentTarget.blur(); + return; + } + + if (event.key === "Escape") { + setDraft(glyphName); + event.currentTarget.blur(); + return; + } + + if (event.metaKey && event.key === "a") { + event.currentTarget.select(); + } + }} + className="h-7 w-full truncate text-center text-xs text-muted-foreground focus:ring-inset read-only:cursor-default read-only:bg-transparent read-only:focus:ring-0" + /> + ); +} diff --git a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx index 7b74eb6c..34d386c6 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx @@ -3,6 +3,7 @@ import type { Font } from "@/lib/model/Font"; import type { Glyph } from "@/lib/model/Glyph"; import { useSignalState } from "@/lib/signals"; import { getEditor } from "@/store/store"; +import type { GlyphHandle } from "@shift/bridge"; export const CELL_HEIGHT = 75; @@ -44,38 +45,25 @@ export function computeCellWidth( } interface GlyphPreviewProps { - unicode: number; + handle: GlyphHandle; font: Font; height?: number; } -export function GlyphPreview({ unicode, font, height = CELL_HEIGHT }: GlyphPreviewProps) { +export function GlyphPreview({ handle, font, height = CELL_HEIGHT }: GlyphPreviewProps) { if (!font.loaded) { - return ; + return ; } - const handle = font.glyphHandleForUnicode(unicode); - if (!handle) return ; - const glyph = font.glyph(handle); if (!glyph) { - return ; + return ; } - return ; + return ; } -function GlyphCell({ - unicode, - font, - height, - glyph, -}: { - unicode: number; - font: Font; - height: number; - glyph: Glyph; -}) { +function GlyphCell({ font, height, glyph }: { font: Font; height: number; glyph: Glyph }) { const editor = getEditor(); const outline = glyph.instance(editor.$designLocation).render.outline; @@ -88,7 +76,7 @@ function GlyphCell({ const containerStyle = { width: cellWidth, height }; if (!svgPath) { - return ; + return ; } const viewBox = glyphPreviewViewBox(fontMetrics, advance); @@ -111,24 +99,31 @@ function GlyphCell({ } function FallbackCell({ - unicode, + handle, font, height, advance, }: { - unicode: number; + handle: GlyphHandle; font: Font; height: number; advance: number | null; }) { const cellWidth = computeCellWidth(font.metrics, advance, height); + let label: string = ""; + + const record = font.recordForName(handle.name); + if (record && record.unicodes.length > 0) { + label = String.fromCodePoint(record.unicodes[0]); + } + return (
- {String.fromCodePoint(unicode)} + {label}
); diff --git a/apps/desktop/src/renderer/src/components/home/LeftSidebar.tsx b/apps/desktop/src/renderer/src/components/home/LeftSidebar.tsx index d4b28792..75866b71 100644 --- a/apps/desktop/src/renderer/src/components/home/LeftSidebar.tsx +++ b/apps/desktop/src/renderer/src/components/home/LeftSidebar.tsx @@ -2,15 +2,20 @@ import { Separator } from "@shift/ui"; import { AxesPanel } from "@/components/variation/AxesPanel"; import { GlyphCatalog } from "./glyph-catalog"; -import { SidebarSection } from "../editor/sidebar-right/SidebarSection"; +import { Sources } from "../variation/Sources"; +import { CollapsibleSection } from "../sidebar"; export const LeftSidebar = () => ( ); diff --git a/apps/desktop/src/renderer/src/components/home/glyph-catalog/CategoryIcon.tsx b/apps/desktop/src/renderer/src/components/home/glyph-catalog/CategoryIcon.tsx index 1273b3e3..44de1759 100644 --- a/apps/desktop/src/renderer/src/components/home/glyph-catalog/CategoryIcon.tsx +++ b/apps/desktop/src/renderer/src/components/home/glyph-catalog/CategoryIcon.tsx @@ -1,5 +1,6 @@ import type { GlyphCategory } from "@shift/glyph-info"; import type { SVG } from "@/types/common"; + import LetterIcon from "@/assets/sidebar-left/letters.svg"; import MarkIcon from "@/assets/sidebar-left/marks.svg"; import NumberIcon from "@/assets/sidebar-left/numbers.svg"; diff --git a/apps/desktop/src/renderer/src/components/home/glyph-catalog/GlyphCatalog.tsx b/apps/desktop/src/renderer/src/components/home/glyph-catalog/GlyphCatalog.tsx index 7d285389..26a37add 100644 --- a/apps/desktop/src/renderer/src/components/home/glyph-catalog/GlyphCatalog.tsx +++ b/apps/desktop/src/renderer/src/components/home/glyph-catalog/GlyphCatalog.tsx @@ -7,20 +7,24 @@ import { Input, Search, } from "@shift/ui"; + import AllIcon from "@/assets/sidebar-left/all.svg"; +import PlusIcon from "@/assets/plus.svg"; + import { useGlyphCatalog } from "@/context/GlyphCatalogContext"; import { Category } from "./Category"; import { SubCategory } from "./SubCategory"; export const GlyphCatalog = () => { const { - availableUnicodes, - filteredUnicodes, + availableGlyphs, + filteredGlyphs, categories, query, selectedCategory, selectedSubCategoryKey, setQuery, + createQuickGlyph, selectAll, selectCategory, selectSubCategory, @@ -41,19 +45,30 @@ export const GlyphCatalog = () => {
Glyphs - {`${filteredUnicodes.length}/${availableUnicodes.length}`} +
diff --git a/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx b/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx index 74029b01..f2c71a82 100644 --- a/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx +++ b/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx @@ -1,17 +1,25 @@ import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; import type { GlyphCategory, GlyphCategoryCatalog, GlyphCategorySummary } from "@shift/glyph-info"; +import type { GlyphName, GlyphRecord } from "@shift/types"; import { useSignalState } from "@/lib/signals"; -import { getEditor, getGlyphInfo } from "@/store/store"; +import { getDocument, getEditor, getGlyphInfo, markDocumentDirty } from "@/store/store"; import { ADOBE_LATIN_1 } from "@data/adobe-latin-1"; +export interface GlyphCatalogItem { + readonly name: GlyphName; + readonly unicode: number | null; + readonly exists: boolean; +} + export interface GlyphCatalogState { - availableUnicodes: number[]; - filteredUnicodes: number[]; + availableGlyphs: GlyphCatalogItem[]; + filteredGlyphs: GlyphCatalogItem[]; categories: GlyphCategorySummary[]; selectedCategory: GlyphCategory | null; selectedSubCategoryKey: string | null; query: string; setQuery: (nextQuery: string) => void; + createQuickGlyph: () => GlyphName; selectAll: () => void; selectCategory: (category: GlyphCategory) => void; selectSubCategory: (category: GlyphCategory, subCategoryKey: string) => void; @@ -33,8 +41,10 @@ export const useGlyphCatalog = (): GlyphCatalogState => { const useGlyphCatalogState = (): GlyphCatalogState => { const glyphInfo = getGlyphInfo(); const font = getEditor().font; + const fontLoaded = useSignalState(font.$loaded); - const fontUnicodes = useSignalState(font.$unicodes); + const glyphRecords = useSignalState(font.glyphRecordsCell); + const [query, setQuery] = useState(""); const [selectedCategory, setSelectedCategory] = useState(null); const [selectedSubCategoryKey, setSelectedSubCategoryKey] = useState(null); @@ -43,9 +53,22 @@ const useGlyphCatalogState = (): GlyphCatalogState => { () => Object.values(ADOBE_LATIN_1).map((g) => parseInt(g.unicode, 16)), [], ); + + const availableGlyphs = useMemo( + () => + fontLoaded && glyphRecords.length > 0 + ? glyphRecords.map(glyphCatalogItemFromRecord) + : starterUnicodes.map((unicode) => ({ + name: font.nameForUnicode(unicode), + unicode, + exists: false, + })), + [font, fontLoaded, glyphRecords, starterUnicodes], + ); + const availableUnicodes = useMemo( - () => (fontLoaded && fontUnicodes.length > 0 ? fontUnicodes : starterUnicodes), - [fontLoaded, fontUnicodes, starterUnicodes], + () => availableGlyphs.flatMap((glyph) => (glyph.unicode === null ? [] : [glyph.unicode])), + [availableGlyphs], ); const categoryCatalog = useMemo( @@ -53,25 +76,59 @@ const useGlyphCatalogState = (): GlyphCatalogState => { [availableUnicodes, glyphInfo], ); - const filteredUnicodes = useMemo( - () => + const filteredGlyphs = useMemo(() => { + const categoryFilteredUnicodes = new Set( categoryCatalog.filter({ query, category: selectedCategory, subCategoryKey: selectedSubCategoryKey, searchLimit: Math.max(availableUnicodes.length, 200), }), - [availableUnicodes.length, categoryCatalog, query, selectedCategory, selectedSubCategoryKey], - ); + ); + + const normalizedQuery = query.trim().toLowerCase(); + const filteringByCategory = selectedCategory !== null || selectedSubCategoryKey !== null; + + return availableGlyphs.filter((glyph) => { + const unicodeMatched = glyph.unicode !== null && categoryFilteredUnicodes.has(glyph.unicode); + const nameMatched = + normalizedQuery !== "" && glyph.name.toLowerCase().includes(normalizedQuery); + + if (filteringByCategory) return unicodeMatched; + if (normalizedQuery !== "") return unicodeMatched || nameMatched; + return true; + }); + }, [ + availableGlyphs, + availableUnicodes.length, + categoryCatalog, + query, + selectedCategory, + selectedSubCategoryKey, + ]); return { - availableUnicodes, - filteredUnicodes, + availableGlyphs, + filteredGlyphs, categories: categoryCatalog.categories, query, selectedCategory, selectedSubCategoryKey, setQuery, + createQuickGlyph: () => { + const document = getDocument(); + if (!font.loaded) { + document.createFont(); + } + + const handle = getEditor().createGlyph("newGlyph"); + markDocumentDirty(); + setQuery(""); + setSelectedCategory(null); + setSelectedSubCategoryKey(null); + + return handle.name; + }, selectAll: () => { setSelectedCategory(null); setSelectedSubCategoryKey(null); @@ -86,3 +143,11 @@ const useGlyphCatalogState = (): GlyphCatalogState => { }, }; }; + +function glyphCatalogItemFromRecord(record: GlyphRecord): GlyphCatalogItem { + return { + name: record.name, + unicode: record.unicodes[0] ?? null, + exists: true, + }; +} diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index 3da7a023..2e74a64f 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -1,5 +1,5 @@ import type { CursorType, ToolRegistryItem } from "@/types/editor"; -import type { PointId, ContourId, Source, SourceId } from "@shift/types"; +import type { PointId, ContourId, Source, SourceId, GlyphName } from "@shift/types"; import type { AxisLocation } from "@/types/variation"; import type { Coordinates } from "@/types/coordinates"; import type { Glyph, GlyphInstance, GlyphSource } from "@/lib/model/Glyph"; @@ -606,6 +606,20 @@ export class Editor { return this.#glyph.open.glyph.peek(); } + /** + * Creates an empty committed glyph in the loaded font. + * + * @remarks + * This is the editor-level entry point for quick-add flows. It delegates + * naming and bridge commit semantics to {@link Font.createGlyph}. + * + * @param name - Preferred glyph name. Existing names are auto-incremented. + * @returns The handle for the glyph that was actually created. + */ + public createGlyph(name: GlyphName): GlyphHandle { + return this.font.createGlyph(name); + } + /** * Focus a glyph item and derive editor placement from current layout. * @@ -1119,6 +1133,10 @@ export class Editor { return this.font.save(filePath); } + public async exportFont(filePath: string): Promise { + await this.font.export(filePath); + } + public setCursor(cursor: CursorType): void { this.#view.cursorCell.set(cursorToCSS(cursor)); } diff --git a/apps/desktop/src/renderer/src/lib/model/Font.test.ts b/apps/desktop/src/renderer/src/lib/model/Font.test.ts index 6daa3239..8a29521c 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.test.ts @@ -52,7 +52,7 @@ describe("Font", () => { expect(font.glyphHandleForName("A")).toEqual({ name: "A", unicode: 65 }); expect(font.glyphHandleForUnicode(65)).toEqual({ name: "A", unicode: 65 }); expect(font.nameForUnicode(65)).toBe("A"); - expect(font.glyphHandleForName("notdef")).toBeNull(); + expect(font.glyphHandleForName("notdef")).toEqual({ name: "notdef" }); expect(font.glyphHandleForUnicode(0xffff)).toEqual({ name: "uniFFFF", unicode: 0xffff, @@ -87,6 +87,47 @@ describe("Font", () => { expect(font.glyph({ name: "A" })).toBe(a); }); + it("updates a glyph identity through the font index", () => { + const font = loadFont(); + + font.updateGlyphIdentity("A", "A.alt", []); + + expect(font.glyphHandleForName("A")).toEqual({ name: "A", unicode: 65 }); + expect(font.glyphHandleForName("A.alt")).toEqual({ name: "A.alt" }); + expect(font.nameForUnicode(65)).toBe("A"); + expect(font.glyph({ name: "A", unicode: 65 })).toBeNull(); + expect(font.glyph({ name: "A.alt" })).not.toBeNull(); + }); + + it("creates an empty committed glyph through the bridge", () => { + const font = loadFont(); + + const handle = font.createGlyph("newGlyph"); + + expect(handle).toEqual({ name: "newGlyph" }); + expect(font.hasGlyph("newGlyph")).toBe(true); + expect(font.glyphRecords().some((record) => record.name === "newGlyph")).toBe(true); + expect(font.glyph(handle)).not.toBeNull(); + }); + + it("auto-increments duplicate quick glyph names", () => { + const font = loadFont(); + + expect(font.createGlyph("newGlyph")).toEqual({ name: "newGlyph" }); + expect(font.createGlyph("newGlyph")).toEqual({ name: "newGlyph.1" }); + expect(font.createGlyph("newGlyph")).toEqual({ name: "newGlyph.2" }); + }); + + it("moves Unicode assignment when updating glyph identity", () => { + const font = loadFont(); + + font.updateGlyphIdentity("A", "agrave", [0x00e0]); + + expect(font.glyphHandleForName("agrave")).toEqual({ name: "agrave", unicode: 0x00e0 }); + expect(font.nameForUnicode(0x00e0)).toBe("agrave"); + expect(font.nameForUnicode(65)).toBe("A"); + }); + it("returns a stable GlyphSource instance per glyph source", () => { const font = loadFont(); const source = font.defaultSource; @@ -149,7 +190,7 @@ describe("Font", () => { expect(font.loaded).toBe(false); expect(font.glyphRecords()).toEqual([]); - expect(font.glyphHandleForName("A")).toBeNull(); + expect(font.glyphHandleForName("A")).toEqual({ name: "A", unicode: 65 }); expect(font.metrics.unitsPerEm).toBe(1000); }); }); diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index 455ebd8f..65433948 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -26,6 +26,16 @@ import type { AxisLocation } from "@/types/variation"; import { defaultResources, GlyphInfo } from "@shift/glyph-info"; import { fallbackGlyphNameForUnicode } from "../utils/unicode"; +/** + * Immutable lookup index for committed glyph records. + * + * @remarks + * `GlyphDirectory` is rebuilt whenever the bridge glyph list changes. It keeps + * source-of-truth font records separate from fallback glyph database knowledge: + * methods named `record*`, `has*`, and dependency lookups only describe glyphs + * committed in the font, while handle/name resolution methods may fall back to + * bundled glyph metadata so UI flows can address missing glyphs. + */ class GlyphDirectory { #glyphDatabase: GlyphInfo = new GlyphInfo(defaultResources); @@ -70,14 +80,35 @@ class GlyphDirectory { this.dependentsByName = dependentsByName; } + /** + * Builds a directory snapshot from bridge glyph records. + * + * @param records - Committed glyph records from the current font snapshot. + * @returns A new immutable lookup index; later record changes are not observed. + */ static fromRecords(records: readonly GlyphRecord[]): GlyphDirectory { return new GlyphDirectory(records); } + /** + * Builds an empty directory for an unloaded or freshly reset font model. + * + * @returns A directory with no committed glyph records. + */ static empty(): GlyphDirectory { return new GlyphDirectory([]); } + /** + * Resolves the preferred glyph name for a Unicode scalar. + * + * @remarks + * Existing font mappings win. Missing codepoints fall back to bundled glyph + * metadata and finally to a deterministic `uniXXXX`-style name. + * + * @param unicode - Unicode scalar value to resolve. + * @returns A production glyph name suitable for opening or creating a glyph. + */ nameForUnicode(unicode: Unicode): GlyphName { const nameFromFont = this.nameByUnicode.get(unicode); if (nameFromFont) return nameFromFont; @@ -89,45 +120,104 @@ class GlyphDirectory { return fallbackName as GlyphName; } - /** @knipclassignore — public glyph directory API. */ + /** + * Reports whether the current font has a committed glyph with this name. + * + * @param name - Glyph name to test against committed font records. + * @returns `true` only for glyphs present in the loaded font, not database fallbacks. + * @knipclassignore + */ hasGlyph(name: GlyphName): boolean { return this.recordsByName.has(name); } - /** @knipclassignore — public glyph directory API. */ + /** + * Returns the committed glyph record for a name. + * + * @param name - Glyph name to look up in the font directory. + * @returns The committed record, or `null` when the font does not contain the glyph. + * @knipclassignore + */ recordForName(name: GlyphName): GlyphRecord | null { return this.recordsByName.get(name) ?? null; } - /** @knipclassignore — public glyph directory API. */ + /** + * Returns the committed Unicode assignments for a glyph name. + * + * @param name - Glyph name to look up in the font directory. + * @returns A read-only assignment list; empty when the glyph is missing or unencoded. + * @knipclassignore + */ unicodesForName(name: GlyphName): readonly Unicode[] { return this.recordsByName.get(name)?.unicodes ?? []; } - /** @knipclassignore — public glyph directory API. */ + /** + * Returns the first committed Unicode assignment for a glyph name. + * + * @param name - Glyph name to look up in the font directory. + * @returns The primary codepoint, or `null` when the glyph is missing or unencoded. + * @knipclassignore + */ primaryUnicodeForName(name: GlyphName): Unicode | null { return this.unicodesForName(name)[0] ?? null; } + /** + * Returns all committed Unicode values in ascending order. + * + * @returns A read-only snapshot derived from the current font records. + */ allUnicodes(): readonly Unicode[] { return this.unicodes; } + /** + * Returns committed component bases used by a glyph. + * + * @param name - Glyph name whose component references should be inspected. + * @returns Base glyph names from the committed record; empty when absent. + */ componentBaseNamesForName(name: GlyphName): readonly GlyphName[] { return this.componentBasesByName.get(name) ?? []; } + /** + * Returns committed glyphs that reference a base glyph as a component. + * + * @param name - Base glyph name to reverse-resolve. + * @returns Sorted dependent glyph names; empty when no committed glyph references it. + */ dependentNamesForName(name: GlyphName): readonly GlyphName[] { return [...(this.dependentsByName.get(name) ?? [])].sort(); } - glyphHandleForName(name: GlyphName): GlyphHandle | null { + /** + * Resolves a glyph name to an editor handle. + * + * @remarks + * Existing records include their committed primary Unicode. Missing glyphs + * may still get a Unicode hint from bundled glyph metadata; otherwise the + * handle remains name-only. + * + * @param name - Glyph name to address. + * @returns A handle suitable for opening, creating, or querying glyph state. + */ + glyphHandleForName(name: GlyphName): GlyphHandle { const record = this.recordForName(name); - if (!record) return null; - const unicode = this.primaryUnicodeForName(name); + const unicode = record + ? this.primaryUnicodeForName(name) + : (this.#glyphDatabase.getGlyphByName(name)?.codepoint ?? null); return unicode === null ? { name } : { name, unicode }; } + /** + * Resolves a Unicode scalar to an editor handle. + * + * @param unicode - Unicode scalar value to address. + * @returns A handle with a resolved name and Unicode value. + */ glyphHandleForUnicode(unicode: Unicode): GlyphHandle | null { const name = this.nameForUnicode(unicode); return name ? { name, unicode } : null; @@ -156,6 +246,7 @@ export class Font { readonly #$metrics: WritableSignal; readonly #$sources: WritableSignal; readonly #$unicodes: Signal; + readonly #$glyphRecords: Signal; readonly #directory = signal(GlyphDirectory.empty()); readonly #glyphs = new Map(); @@ -168,6 +259,7 @@ export class Font { this.#$metrics = signal(this.#defaultMetrics); this.#$sources = signal([]); this.#$unicodes = computed(() => [...this.#directory.value.unicodes]); + this.#$glyphRecords = computed(() => this.#directory.value.records); } /** @knipclassignore */ @@ -201,6 +293,11 @@ export class Font { return this.#$unicodes; } + /** Reactive committed glyph directory records for UI lists and grids. */ + get glyphRecordsCell(): Signal { + return this.#$glyphRecords; + } + /** @knipclassignore */ get metadata(): FontMetadata { return this.#bridge.getMetadata(); @@ -210,46 +307,177 @@ export class Font { return this.#bridge; } + /** + * Returns committed glyph records from the current font snapshot. + * + * @returns A read-only record list rebuilt after load, create, rename, or reset. + */ glyphRecords(): readonly GlyphRecord[] { return this.#directory.peek().records; } + /** + * Resolves the preferred glyph name for a Unicode scalar. + * + * @remarks + * Existing font mappings win. Missing codepoints fall back to bundled glyph + * metadata and finally to a deterministic fallback name. + * + * @param unicode - Unicode scalar value to resolve. + * @returns A production glyph name suitable for opening or creating a glyph. + */ nameForUnicode(unicode: Unicode): GlyphName { return this.#directory.peek().nameForUnicode(unicode); } - /** @knipclassignore — public glyph directory API. */ + /** + * Reports whether the current font has a committed glyph with this name. + * + * @param name - Glyph name to test against committed font records. + * @returns `true` only for glyphs present in the loaded font. + * @knipclassignore + */ hasGlyph(name: GlyphName): boolean { return this.#directory.peek().hasGlyph(name); } - /** @knipclassignore — public glyph directory API. */ + /** + * Returns the committed glyph record for a name. + * + * @param name - Glyph name to look up in the font directory. + * @returns The committed record, or `null` when the font does not contain the glyph. + * @knipclassignore + */ recordForName(name: GlyphName): GlyphRecord | null { return this.#directory.peek().recordForName(name); } - /** @knipclassignore — public glyph directory API. */ + /** + * Returns the committed Unicode assignments for a glyph name. + * + * @param name - Glyph name to look up in the font directory. + * @returns A read-only assignment list; empty when the glyph is missing or unencoded. + * @knipclassignore + */ unicodesForName(name: GlyphName): readonly Unicode[] { return this.#directory.peek().unicodesForName(name); } - /** @knipclassignore — public glyph directory API. */ + /** + * Returns the first committed Unicode assignment for a glyph name. + * + * @param name - Glyph name to look up in the font directory. + * @returns The primary codepoint, or `null` when the glyph is missing or unencoded. + * @knipclassignore + */ primaryUnicodeForName(name: GlyphName): Unicode | null { return this.#directory.peek().primaryUnicodeForName(name); } + /** + * Returns committed component bases used by a glyph. + * + * @param name - Glyph name whose component references should be inspected. + * @returns Base glyph names from the committed record; empty when absent. + */ componentBaseNamesForName(name: GlyphName): readonly GlyphName[] { return this.#directory.peek().componentBaseNamesForName(name); } + /** + * Returns committed glyphs that reference a base glyph as a component. + * + * @param name - Base glyph name to reverse-resolve. + * @returns Sorted dependent glyph names; empty when no committed glyph references it. + */ dependentNamesForName(name: GlyphName): readonly GlyphName[] { return this.#directory.peek().dependentNamesForName(name); } - glyphHandleForName(name: GlyphName): GlyphHandle | null { + /** + * Resolve a glyph name to an editor handle, even when the glyph is not yet + * committed in the font. + * + * @remarks + * Name-first flows such as New Glyph need a stable handle before source data + * exists. Existing font records provide their committed Unicode assignment; + * otherwise the glyph database is used as a best-effort Unicode hint. + * + * @param name - Production glyph name to open, create, or query. + * @returns A glyph identity handle. The handle may refer to a missing glyph. + */ + glyphHandleForName(name: GlyphName): GlyphHandle { return this.#directory.peek().glyphHandleForName(name); } + /** + * Updates an existing glyph's name and Unicode assignment. + * + * @remarks + * Glyphs are keyed by name in the native font model. This method re-keys the + * glyph through the bridge and replaces its Unicode list, then clears cached + * glyph models because existing model objects still carry their original + * identity handle. + * + * @param fromName - Existing committed glyph name. + * @param name - New unique glyph name after trimming whitespace. + * @param unicodes - Complete Unicode assignment for the renamed glyph. + * @throws {Error} when `fromName` is missing, `name` is empty, `name` already + * exists, or an edit session is active. + */ + updateGlyphIdentity(fromName: GlyphName, name: GlyphName, unicodes: readonly Unicode[]): void { + this.#bridge.updateGlyphIdentity(fromName, name.trim() as GlyphName, [...unicodes]); + this.#glyphs.clear(); + this.#glyphSources.clear(); + this.#hydrateFromBridge(); + } + + /** + * Creates an empty committed glyph at the default source. + * + * @remarks + * If the requested name already exists, a numeric suffix is appended using + * {@link nextAvailableGlyphName}. The bridge edit session is opened and + * immediately ended so downstream save/export paths see a real committed + * glyph record, not a UI-only placeholder. + * + * @param name - Preferred glyph name. Blank input falls back to `newGlyph`. + * @returns The handle for the glyph that was actually created. + * @throws {Error} when the bridge rejects session creation or commit. + */ + createGlyph(name: GlyphName): GlyphHandle { + const glyphName = this.nextAvailableGlyphName(name); + if (this.#bridge.hasEditSession()) { + this.#bridge.endEditSession(); + } + + const handle = this.glyphHandleForName(glyphName); + this.#bridge.startEditSession(handle, this.defaultSource.id); + this.#bridge.endEditSession(); + + this.#glyphs.clear(); + this.#glyphSources.clear(); + this.#hydrateFromBridge(); + + return handle; + } + + /** + * Finds the next unused glyph name for an auto-incrementing base name. + * + * @param name - Preferred base name. Blank input falls back to `newGlyph`. + * @returns The base name when unused, otherwise `base.1`, `base.2`, and so on. + */ + nextAvailableGlyphName(name: GlyphName): GlyphName { + const baseName = (name.trim() || "newGlyph") as GlyphName; + if (!this.hasGlyph(baseName)) return baseName; + + for (let index = 1; ; index += 1) { + const candidate = `${baseName}.${index}` as GlyphName; + if (!this.hasGlyph(candidate)) return candidate; + } + } + /** * Return the preferred glyph handle for a Unicode codepoint. * @@ -498,6 +726,10 @@ export class Font { return this.#bridge.saveFont(path); } + async export(path: string): Promise { + await this.#bridge.exportFont({ path, format: "ttf" }); + } + /** @knipclassignore — called when closing a document */ close(): void { this.#$loaded.set(false); diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts index 2871ceda..8080f7bd 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; import { createBridge, type ShiftBridge } from "@shift/bridge"; import { effect, signal } from "@/lib/signals/signal"; -import { defaultAxisLocation, withAxisValue } from "@/lib/variation/location"; +import { + axisLocationFromLocation, + defaultAxisLocation, + withAxisValue, +} from "@/lib/variation/location"; import type { AxisLocation } from "@/types/variation"; import { MUTATORSANS_DESIGNSPACE } from "@/testing/fixtures"; import { Font } from "./Font"; @@ -228,6 +232,41 @@ describe("glyph sources keep public geometry coherent across position edits", () expect(pointPosition(layer, second.id)).toEqual({ x: 35, y: 80 }); }); + + it("keeps source-backed instance geometry contours fresh after position edits", () => { + const [, second] = addTriangle(layer); + const instance = glyph.instanceAt(axisLocationFromLocation(layer.source.location)); + + layer.applyPositionPatch([{ kind: "point", id: second.id, x: 25, y: 75 }]); + + expect(instance.geometry.point(second.id)).toMatchObject({ x: 25, y: 75 }); + expect(instance.geometry.allPoints.find((point) => point.id === second.id)).toMatchObject({ + x: 25, + y: 75, + }); + expect( + instance.geometry.contours.at(-1)?.points.find((point) => point.id === second.id), + ).toMatchObject({ + x: 25, + y: 75, + }); + }); + + it("invalidates source-backed instance contours that were read before a position edit", () => { + const [, second] = addTriangle(layer); + const instance = glyph.instanceAt(axisLocationFromLocation(layer.source.location)); + + expect( + instance.geometry.contours.at(-1)?.points.find((point) => point.id === second.id), + ).toMatchObject({ x: 100, y: 0 }); + + layer.applyPositionPatch([{ kind: "point", id: second.id, x: 25, y: 75 }]); + + expect(instance.geometry.point(second.id)).toMatchObject({ x: 25, y: 75 }); + expect( + instance.geometry.contours.at(-1)?.points.find((point) => point.id === second.id), + ).toMatchObject({ x: 25, y: 75 }); + }); }); describe("Glyph variation interpolation", () => { diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.ts index b3d1711f..cb434c3b 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.ts @@ -1382,7 +1382,9 @@ class SourceGeometryCache implements GlyphInstanceGeometry { this.#sourceContours = computed(() => this.#contoursFromSource(source.structureCell.value, source.coordinateBuffersCell.value), ); - this.#contours = computed(() => this.#sourceContours.value.map((contour) => contour.contour)); + this.#contours = computed(() => + this.#sourceContours.value.map((contour) => contour.contourCell.value), + ); this.#points = computed(() => this.#sourceContours.value.flatMap((contour) => contour.pointsCell.value), @@ -1428,7 +1430,12 @@ class SourceGeometryCache implements GlyphInstanceGeometry { } contour(contourId: ContourId): Contour | null { - return this.#sourceContours.peek().find((contour) => contour.id === contourId)?.contour ?? null; + return ( + this.#sourceContours + .peek() + .find((contour) => contour.id === contourId) + ?.contourCell.peek() ?? null + ); } point(pointId: PointId): Point | null { diff --git a/apps/desktop/src/renderer/src/persistence/kernel.ts b/apps/desktop/src/renderer/src/persistence/kernel.ts index 7d47a8d1..32787822 100644 --- a/apps/desktop/src/renderer/src/persistence/kernel.ts +++ b/apps/desktop/src/renderer/src/persistence/kernel.ts @@ -178,7 +178,7 @@ export class DocumentStatePersistence { p .split("/") .pop() - ?.replace(/\.(otf|ttf|ufo|glyphs|woff2?)$/i, "") ?? p, + ?.replace(/\.(designspace|otf|ttf|ufo|glyphs|woff2?)$/i, "") ?? p, path: p, })); diff --git a/apps/desktop/src/renderer/src/store/store.ts b/apps/desktop/src/renderer/src/store/store.ts index 7b3d3172..f483f4d9 100644 --- a/apps/desktop/src/renderer/src/store/store.ts +++ b/apps/desktop/src/renderer/src/store/store.ts @@ -100,6 +100,7 @@ const AppState = create()(createStore); export const getDocument = () => AppState.getState().document; export const getEditor = () => AppState.getState().editor; +export const markDocumentDirty = () => AppState.getState().markDirty(); // Expose editor on window for Playwright E2E tests. declare const __PLAYWRIGHT__: boolean | undefined; diff --git a/apps/desktop/src/renderer/src/views/Editor.tsx b/apps/desktop/src/renderer/src/views/Editor.tsx index 94ef556c..f43bd0d0 100644 --- a/apps/desktop/src/renderer/src/views/Editor.tsx +++ b/apps/desktop/src/renderer/src/views/Editor.tsx @@ -14,9 +14,10 @@ import { useSignalState } from "@/lib/signals"; import { KeyboardRouter } from "@/lib/keyboard"; import { codepointToHex } from "@/lib/utils/unicode"; +import type { GlyphName } from "@shift/types"; export const Editor = () => { - const { glyphId } = useParams(); + const { glyphId, glyphName } = useParams(); const editor = getEditor(); const { activeZone } = useFocusZone(); @@ -72,7 +73,7 @@ export const Editor = () => { document.removeEventListener("keydown", keyDownHandler); document.removeEventListener("keyup", keyUpHandler); }; - }, [glyphId, activeZone]); + }, [glyphId, glyphName, activeZone]); useEffect(() => { const editor = getEditor(); @@ -97,7 +98,7 @@ export const Editor = () => { }; }, []); - if (!glyphId) return null; + if (!glyphId && !glyphName) return null; return (
@@ -123,7 +124,7 @@ export const Editor = () => { - + diff --git a/apps/desktop/src/renderer/src/views/Landing.tsx b/apps/desktop/src/renderer/src/views/Landing.tsx index 6c0f5279..bca66c91 100644 --- a/apps/desktop/src/renderer/src/views/Landing.tsx +++ b/apps/desktop/src/renderer/src/views/Landing.tsx @@ -1,7 +1,7 @@ import { useNavigate } from "react-router-dom"; import { getDocument } from "@/store/store"; import logo from "@/assets/logo@1024.png"; -import { Button } from "@shift/ui"; +import { Button, Separator } from "@shift/ui"; import { RecentFiles } from "./RecentFiles"; export const Landing = () => { @@ -26,7 +26,7 @@ export const Landing = () => { }; return ( -
+
Shift @@ -43,7 +43,8 @@ export const Landing = () => { Load font
-
+
+
diff --git a/apps/desktop/src/renderer/src/views/RecentFiles.tsx b/apps/desktop/src/renderer/src/views/RecentFiles.tsx index 4f1bdbd8..22070924 100644 --- a/apps/desktop/src/renderer/src/views/RecentFiles.tsx +++ b/apps/desktop/src/renderer/src/views/RecentFiles.tsx @@ -60,11 +60,13 @@ export const RecentFiles = ({ onOpenFile }: RecentFilesProps) => { const visibleFiles = recentFiles.slice(0, VISIBLE_COUNT); return ( -
+
Recent files - {visibleFiles.map((file) => ( - - ))} +
+ {visibleFiles.map((file) => ( + + ))} +
); }; diff --git a/apps/desktop/src/shared/ipc/channels.ts b/apps/desktop/src/shared/ipc/channels.ts index 05f130ed..30d70656 100644 --- a/apps/desktop/src/shared/ipc/channels.ts +++ b/apps/desktop/src/shared/ipc/channels.ts @@ -2,9 +2,11 @@ import type { ThemeName, Debug, DebugOverlays } from "./types"; /** Main -> Renderer broadcasts (webContents.send / ipcRenderer.on) */ export type IpcEvents = { + "document:new": () => void; "menu:open-font": (path: string) => void; "external:open-font": (path: string) => void; "menu:save-font": (path: string) => void; + "menu:export-font": (path: string) => void; "menu:undo": () => void; "menu:redo": () => void; "menu:delete": () => void; diff --git a/apps/desktop/src/shared/ipc/electronAPI.ts b/apps/desktop/src/shared/ipc/electronAPI.ts index 73ca469b..820bc83c 100644 --- a/apps/desktop/src/shared/ipc/electronAPI.ts +++ b/apps/desktop/src/shared/ipc/electronAPI.ts @@ -30,9 +30,11 @@ export interface ElectronAPI { pathsExist: CommandInvoker<"fs:pathsExist">; // Events + onDocumentNew: EventListener<"document:new">; onMenuOpenFont: EventListener<"menu:open-font">; onExternalOpenFont: EventListener<"external:open-font">; onMenuSaveFont: EventListener<"menu:save-font">; + onMenuExportFont: EventListener<"menu:export-font">; onMenuUndo: EventListener<"menu:undo">; onMenuRedo: EventListener<"menu:redo">; onMenuDelete: EventListener<"menu:delete">; diff --git a/crates/shift-backends/Cargo.toml b/crates/shift-backends/Cargo.toml index 6d89885b..0574d987 100644 --- a/crates/shift-backends/Cargo.toml +++ b/crates/shift-backends/Cargo.toml @@ -16,7 +16,6 @@ skrifa = "0.32.0" fontc = "0.2.0" glyphs-reader = "0.2.0" plist = "1" -thiserror = "2.0.18" - -[dev-dependencies] +quick-xml = "0.37.5" tempfile = "3" +thiserror = "2.0.18" diff --git a/crates/shift-backends/docs/DOCS.md b/crates/shift-backends/docs/DOCS.md index 0701b48f..d1d32120 100644 --- a/crates/shift-backends/docs/DOCS.md +++ b/crates/shift-backends/docs/DOCS.md @@ -40,6 +40,7 @@ src/ - `FontBackend` -- auto-implemented marker trait for types implementing both `FontReader` + `FontWriter` - `UfoReader` -- loads `.ufo` bundles via `norad` - `UfoWriter` -- writes `.ufo` bundles via `norad`; rounds coordinates to integers +- `DesignspaceWriter` -- writes a `.designspace` file plus a companion `.ufo`; additional Shift sources are represented as layers in that companion UFO and referenced by source layer names - `UfoBackend` -- unit struct implementing `FontBackend` by delegating to `UfoReader`/`UfoWriter` - `GlyphsReader` -- loads `.glyphs` and `.glyphspackage` files via `glyphs-reader`; read-only (no writer) diff --git a/crates/shift-backends/src/binary/mod.rs b/crates/shift-backends/src/binary/mod.rs index b89297cc..e27545c3 100644 --- a/crates/shift-backends/src/binary/mod.rs +++ b/crates/shift-backends/src/binary/mod.rs @@ -1,3 +1,17 @@ -pub mod reader; +mod reader; -pub use reader::BytesFontAdaptor; +use crate::errors::{FormatBackendError, FormatBackendResult}; +use crate::font_loader::FontAdaptor; +use shift_ir::Font; + +pub struct BytesFontAdaptor; + +impl FontAdaptor for BytesFontAdaptor { + fn read_font(&self, path: &str) -> FormatBackendResult { + reader::read_font_file(path) + } + + fn write_font(&self, _font: &Font, _path: &str) -> FormatBackendResult<()> { + Err(FormatBackendError::WriteUnsupported) + } +} diff --git a/crates/shift-backends/src/binary/reader.rs b/crates/shift-backends/src/binary/reader.rs index e6aab950..d5332e49 100644 --- a/crates/shift-backends/src/binary/reader.rs +++ b/crates/shift-backends/src/binary/reader.rs @@ -1,18 +1,19 @@ -use std::path::{Path, PathBuf}; -use std::time::Instant; - -use crate::font_loader::FontAdaptor; -use fontc::JobTimer; +use crate::errors::{FormatBackendError, FormatBackendResult}; use shift_ir::{Contour, Font, Glyph, GlyphLayer, PointType}; use skrifa::{ outline::{DrawSettings, OutlinePen}, prelude::{LocationRef, Size}, raw::TableProvider, + string::StringId, FontRef, MetadataProvider, }; -pub fn load_font(font_bytes: &[u8]) -> Result, String> { - FontRef::new(font_bytes).map_err(|e| format!("Failed to load font: {e}")) +pub fn read_font_file(path: &str) -> FormatBackendResult { + let bytes = std::fs::read(path) + .map_err(|e| FormatBackendError::Binary(format!("failed to read '{path}': {e}")))?; + let font = FontRef::new(&bytes) + .map_err(|e| FormatBackendError::Binary(format!("failed to parse '{path}': {e}")))?; + Ok(font_from_skrifa(&font)) } #[derive(Default)] @@ -132,6 +133,13 @@ fn font_from_skrifa(font: &FontRef<'_>) -> Font { ir_font.metrics_mut().cap_height = Some(metrics.cap_height.unwrap_or(0.0) as f64); ir_font.metrics_mut().x_height = Some(metrics.x_height.unwrap_or(0.0) as f64); + if let Some(family_name) = localized_string(font, StringId::FAMILY_NAME) { + ir_font.metadata_mut().family_name = Some(family_name); + } + if let Some(style_name) = localized_string(font, StringId::SUBFAMILY_NAME) { + ir_font.metadata_mut().style_name = Some(style_name); + } + for (unicode, glyph_id) in char_map.mappings() { let outline = outlines.get(glyph_id).unwrap(); let settings = DrawSettings::unhinted(Size::unscaled(), LocationRef::default()); @@ -159,28 +167,11 @@ fn font_from_skrifa(font: &FontRef<'_>) -> Font { ir_font } -pub struct BytesFontAdaptor; -impl FontAdaptor for BytesFontAdaptor { - fn read_font(&self, path: &str) -> Result { - let bytes = - std::fs::read(path).map_err(|e| format!("Failed to read font file '{path}': {e}"))?; - let font = FontRef::new(&bytes) - .map_err(|e| format!("Failed to parse font data from '{path}': {e}"))?; - Ok(font_from_skrifa(&font)) - } - - fn write_font(&self, _font: &Font, _path: &str) -> Result<(), String> { - Ok(()) - } -} - -pub fn compile_font(path: &str, build_dir: &Path, output_name: &str) -> Result<(), String> { - let mut args = fontc::Args::new(build_dir, PathBuf::from(path)); - - args.output_file = Some(PathBuf::from(output_name)); - let timer = JobTimer::new(Instant::now()); - fontc::run(args, timer).map_err(|e| format!("Failed to compile font: {e}"))?; - Ok(()) +fn localized_string(font: &FontRef<'_>, id: StringId) -> Option { + font.localized_strings(id) + .english_or_first() + .map(|string| string.to_string()) + .filter(|string| !string.is_empty()) } #[cfg(test)] diff --git a/crates/shift-backends/src/designspace/error.rs b/crates/shift-backends/src/designspace/error.rs new file mode 100644 index 00000000..034a3c37 --- /dev/null +++ b/crates/shift-backends/src/designspace/error.rs @@ -0,0 +1,60 @@ +use std::path::PathBuf; + +pub type DesignspaceResult = Result; + +#[derive(Debug, thiserror::Error)] +pub enum DesignspaceError { + #[error("cannot determine directory of '{path}'")] + MissingParent { path: PathBuf }, + + #[error("invalid UTF-8 in path '{path}'")] + InvalidPathUtf8 { path: PathBuf }, + + #[error("invalid designspace path '{path}'")] + InvalidDesignspacePath { path: PathBuf }, + + #[error("designspace has no sources")] + NoSources, + + #[error("layer '{layer}' not found in '{filename}'")] + MissingLayer { layer: String, filename: String }, + + #[error("failed to read '{path}': {source}")] + ReadFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("failed to write '{path}': {source}")] + WriteFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("failed to create directory '{path}': {source}")] + CreateDir { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("failed to load designspace '{path}': {details}")] + LoadDesignspace { path: PathBuf, details: String }, + + #[error("failed to save designspace '{path}': {details}")] + SaveDesignspace { path: PathBuf, details: String }, + + #[error("failed to load UFO '{path}': {details}")] + LoadUfo { path: PathBuf, details: String }, + + #[error("failed to save UFO '{path}': {details}")] + SaveUfo { path: PathBuf, details: String }, + + #[error("axisless compatibility loader skipped: {reason}")] + AxislessNotApplicable { reason: String }, + + #[error("failed to parse axisless designspace XML: {details}")] + ParseAxislessXml { details: String }, +} diff --git a/crates/shift-backends/src/designspace/mod.rs b/crates/shift-backends/src/designspace/mod.rs index df76ba27..684587b5 100644 --- a/crates/shift-backends/src/designspace/mod.rs +++ b/crates/shift-backends/src/designspace/mod.rs @@ -1,3 +1,71 @@ +mod error; mod reader; +mod writer; +pub use error::DesignspaceError; pub use reader::DesignspaceReader; +pub use writer::DesignspaceWriter; + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::{FontReader, FontWriter}; + use shift_ir::{Contour, Font, Glyph, GlyphLayer, PointType}; + use std::fs; + + fn test_font() -> Font { + let mut font = Font::new(); + font.metadata_mut().family_name = Some("Placeholder Sans".to_string()); + font.metadata_mut().style_name = Some("Regular".to_string()); + font.metrics_mut().units_per_em = 1000.0; + + let mut glyph = Glyph::with_unicode("o".to_string(), 'o' as u32); + let mut layer = GlyphLayer::with_width(520.0); + let mut contour = Contour::new(); + contour.add_point(100.0, 0.0, PointType::OnCurve, false); + contour.add_point(420.0, 0.0, PointType::OnCurve, false); + contour.add_point(420.0, 500.0, PointType::OnCurve, false); + contour.add_point(100.0, 500.0, PointType::OnCurve, false); + contour.close(); + layer.add_contour(contour); + glyph.set_layer(font.default_layer_id(), layer); + font.insert_glyph(glyph); + + font + } + + #[test] + fn writes_designspace_with_companion_ufo() { + let temp_dir = std::env::temp_dir().join("shift_test_designspace_writer"); + let designspace_path = temp_dir.join("PlaceholderSans.designspace"); + let ufo_path = temp_dir.join("PlaceholderSans.ufo"); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).unwrap(); + + let font = test_font(); + DesignspaceWriter::new() + .save(&font, designspace_path.to_str().unwrap()) + .unwrap(); + + assert!(designspace_path.exists()); + assert!(ufo_path.exists()); + let designspace_xml = fs::read_to_string(&designspace_path).unwrap(); + assert!(!designspace_xml.contains(" Result { + fn load(&self, path: &str) -> FormatBackendResult { + self.load_designspace(path) + .map_err(FormatBackendError::from) + } +} + +impl DesignspaceReader { + fn load_designspace(&self, path: &str) -> DesignspaceResult { let ds_path = Path::new(path); let ds_dir = ds_path .parent() - .ok_or_else(|| format!("Cannot determine directory of '{path}'"))?; - - let doc = DesignSpaceDocument::load(ds_path) - .map_err(|e| format!("Failed to load designspace '{path}': {e}"))?; + .ok_or_else(|| DesignspaceError::MissingParent { + path: ds_path.to_path_buf(), + })?; + + let doc = match DesignSpaceDocument::load(ds_path) { + Ok(doc) => doc, + Err(error) => { + let original_error = error.to_string(); + return load_axisless_designspace(ds_path, ds_dir, &original_error).map_err( + |fallback_error| DesignspaceError::LoadDesignspace { + path: ds_path.to_path_buf(), + details: format!("{original_error}; {fallback_error}"), + }, + ); + } + }; if doc.sources.is_empty() { - return Err("Designspace has no sources".to_string()); + return Err(DesignspaceError::NoSources); } let default_idx = find_default_source_index(&doc); @@ -38,12 +62,21 @@ impl FontReader for DesignspaceReader { // Load the default source first to establish the base font. let default_ds_source = &doc.sources[default_idx]; let default_ufo_path = ds_dir.join(&default_ds_source.filename); - let default_ufo_str = default_ufo_path - .to_str() - .ok_or_else(|| "Invalid UTF-8 in default UFO path".to_string())?; + let default_ufo_str = + default_ufo_path + .to_str() + .ok_or_else(|| DesignspaceError::InvalidPathUtf8 { + path: default_ufo_path.clone(), + })?; let ufo_reader = UfoReader::new(); - let mut font = ufo_reader.load(default_ufo_str)?; + let mut font = + ufo_reader + .load(default_ufo_str) + .map_err(|source| DesignspaceError::LoadUfo { + path: default_ufo_path.clone(), + details: source.to_string(), + })?; font.clear_sources(); if let Some(ref family) = default_ds_source.familyname { @@ -88,13 +121,21 @@ impl FontReader for DesignspaceReader { let ufo_path = ds_dir.join(&ds_source.filename); let ufo_str = ufo_path .to_str() - .ok_or_else(|| format!("Invalid UTF-8 in UFO path: {ufo_path:?}"))? + .ok_or_else(|| DesignspaceError::InvalidPathUtf8 { + path: ufo_path.clone(), + })? .to_string(); let source_font = match ufo_cache.get(&ufo_str) { Some(f) => f, None => { - let loaded = ufo_reader.load(&ufo_str)?; + let loaded = + ufo_reader + .load(&ufo_str) + .map_err(|source| DesignspaceError::LoadUfo { + path: ufo_path.clone(), + details: source.to_string(), + })?; ufo_cache.insert(ufo_str.clone(), loaded); ufo_cache.get(&ufo_str).unwrap() } @@ -104,10 +145,10 @@ impl FontReader for DesignspaceReader { let source_layer_id = match &ds_source.layer { Some(layer_name) => { find_layer_by_name(source_font, layer_name).ok_or_else(|| { - format!( - "Layer '{}' not found in '{}'", - layer_name, ds_source.filename - ) + DesignspaceError::MissingLayer { + layer: layer_name.clone(), + filename: ds_source.filename.clone(), + } })? } None => source_font.default_layer_id(), @@ -139,6 +180,194 @@ impl FontReader for DesignspaceReader { } } +#[derive(Clone, Debug)] +struct AxislessSource { + filename: String, + familyname: Option, + stylename: Option, + name: Option, + layer: Option, +} + +fn load_axisless_designspace( + ds_path: &Path, + ds_dir: &Path, + original_error: &str, +) -> DesignspaceResult { + let xml = fs::read_to_string(ds_path).map_err(|source| DesignspaceError::ReadFile { + path: ds_path.to_path_buf(), + source, + })?; + let sources = parse_axisless_sources(&xml).map_err(|fallback_error| { + DesignspaceError::LoadDesignspace { + path: ds_path.to_path_buf(), + details: format!( + "{fallback_error}; axisless fallback was used after parser error: {original_error}" + ), + } + })?; + if sources.is_empty() { + return Err(DesignspaceError::NoSources); + } + + let default_source = &sources[0]; + let default_ufo_path = ds_dir.join(&default_source.filename); + let default_ufo_str = + default_ufo_path + .to_str() + .ok_or_else(|| DesignspaceError::InvalidPathUtf8 { + path: default_ufo_path.clone(), + })?; + + let ufo_reader = UfoReader::new(); + let mut font = + ufo_reader + .load(default_ufo_str) + .map_err(|source| DesignspaceError::LoadUfo { + path: default_ufo_path.clone(), + details: source.to_string(), + })?; + font.clear_sources(); + + if let Some(family) = &default_source.familyname { + font.metadata_mut().family_name = Some(family.clone()); + } + + let default_source_id = font.add_source(Source::with_filename( + axisless_source_name(default_source, 0), + Location::new(), + font.default_layer_id(), + default_source.filename.clone(), + )); + font.set_default_source_id(default_source_id); + + let mut ufo_cache: HashMap = HashMap::new(); + for (idx, ds_source) in sources.iter().enumerate().skip(1) { + let ufo_path = ds_dir.join(&ds_source.filename); + let ufo_str = ufo_path + .to_str() + .ok_or_else(|| DesignspaceError::InvalidPathUtf8 { + path: ufo_path.clone(), + })? + .to_string(); + + let source_font = match ufo_cache.get(&ufo_str) { + Some(f) => f, + None => { + let loaded = + ufo_reader + .load(&ufo_str) + .map_err(|source| DesignspaceError::LoadUfo { + path: ufo_path.clone(), + details: source.to_string(), + })?; + ufo_cache.insert(ufo_str.clone(), loaded); + ufo_cache.get(&ufo_str).unwrap() + } + }; + + let source_layer_id = match &ds_source.layer { + Some(layer_name) => find_layer_by_name(source_font, layer_name).ok_or_else(|| { + DesignspaceError::MissingLayer { + layer: layer_name.clone(), + filename: ds_source.filename.clone(), + } + })?, + None => source_font.default_layer_id(), + }; + + let name = axisless_source_name(ds_source, idx); + let layer = Layer::new(name.clone()); + let layer_id = font.add_layer(layer); + + for (glyph_name, source_glyph) in source_font.glyphs() { + if let Some(source_layer) = source_glyph.layer(source_layer_id) { + if let Some(existing_glyph) = font.glyph_mut(glyph_name) { + existing_glyph.set_layer(layer_id, source_layer.clone()); + } + } + } + + font.add_source(Source::with_filename( + name, + Location::new(), + layer_id, + ds_source.filename.clone(), + )); + } + + Ok(font) +} + +fn axisless_source_name(source: &AxislessSource, index: usize) -> String { + source + .name + .clone() + .or_else(|| source.stylename.clone()) + .unwrap_or_else(|| format!("Source {index}")) +} + +fn parse_axisless_sources(xml: &str) -> DesignspaceResult> { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + let mut sources = Vec::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(event)) | Ok(Event::Empty(event)) => match event.name().as_ref() { + b"axis" => { + return Err(DesignspaceError::AxislessNotApplicable { + reason: "axes are present".to_string(), + }) + } + b"source" => { + if let Some(filename) = xml_attr(&reader, &event, b"filename")? { + sources.push(AxislessSource { + filename, + familyname: xml_attr(&reader, &event, b"familyname")?, + stylename: xml_attr(&reader, &event, b"stylename")?, + name: xml_attr(&reader, &event, b"name")?, + layer: xml_attr(&reader, &event, b"layer")?, + }); + } + } + _ => {} + }, + Ok(Event::Eof) => break, + Err(error) => { + return Err(DesignspaceError::ParseAxislessXml { + details: error.to_string(), + }) + } + _ => {} + } + } + + Ok(sources) +} + +fn xml_attr( + reader: &Reader<&[u8]>, + event: &BytesStart, + name: &[u8], +) -> DesignspaceResult> { + for attribute in event.attributes() { + let attribute = attribute.map_err(|error| DesignspaceError::ParseAxislessXml { + details: error.to_string(), + })?; + if attribute.key.as_ref() == name { + return attribute + .decode_and_unescape_value(reader.decoder()) + .map(|value| Some(value.into_owned())) + .map_err(|error| DesignspaceError::ParseAxislessXml { + details: error.to_string(), + }); + } + } + + Ok(None) +} + fn source_name(source: &norad::designspace::Source, index: usize) -> String { source .name diff --git a/crates/shift-backends/src/designspace/writer.rs b/crates/shift-backends/src/designspace/writer.rs new file mode 100644 index 00000000..689eae9f --- /dev/null +++ b/crates/shift-backends/src/designspace/writer.rs @@ -0,0 +1,239 @@ +use super::error::{DesignspaceError, DesignspaceResult}; +use crate::errors::{FormatBackendError, FormatBackendResult}; +use crate::traits::FontWriter; +use crate::ufo::UfoWriter; +use norad::designspace::{Axis as DsAxis, DesignSpaceDocument, Dimension, Source as DsSource}; +use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, Event}; +use quick_xml::Writer; +use shift_ir::{Axis, Font, Location, Source}; +use std::fs; +use std::path::Path; + +pub struct DesignspaceWriter; + +impl DesignspaceWriter { + pub fn new() -> Self { + Self + } + + fn companion_ufo_filename(path: &Path) -> DesignspaceResult { + let stem = path + .file_stem() + .and_then(|name| name.to_str()) + .ok_or_else(|| DesignspaceError::InvalidDesignspacePath { + path: path.to_path_buf(), + })?; + + Ok(format!("{stem}.ufo")) + } + + fn axis(axis: &Axis) -> DsAxis { + DsAxis { + name: axis.name().to_string(), + tag: axis.tag().to_string(), + minimum: Some(axis.minimum() as f32), + default: axis.default() as f32, + maximum: Some(axis.maximum() as f32), + hidden: axis.is_hidden(), + ..Default::default() + } + } + + fn location(location: &Location, axes: &[Axis]) -> Vec { + axes.iter() + .map(|axis| Dimension { + name: axis.name().to_string(), + xvalue: Some(location.get(axis.tag()).unwrap_or(axis.default()) as f32), + ..Default::default() + }) + .collect() + } + + fn source(source: &Source, font: &Font, filename: &str, axes: &[Axis]) -> DsSource { + let layer = if source.layer_id() == font.default_layer_id() { + None + } else { + font.layers() + .get(&source.layer_id()) + .map(|layer| layer.name().to_string()) + }; + + DsSource { + familyname: font.metadata().family_name.clone(), + stylename: Some(source.name().to_string()), + name: Some(source.name().to_string()), + filename: filename.to_string(), + layer, + location: Self::location(source.location(), axes), + } + } + + fn source_layer(source: &Source, font: &Font) -> Option { + if source.layer_id() == font.default_layer_id() { + None + } else { + font.layers() + .get(&source.layer_id()) + .map(|layer| layer.name().to_string()) + } + } + + fn write_axisless_source( + writer: &mut Writer>, + font: &Font, + filename: &str, + source: Option<&Source>, + ) -> DesignspaceResult<()> { + let mut event = BytesStart::new("source"); + event.push_attribute(("filename", filename)); + + if let Some(familyname) = font.metadata().family_name.as_deref() { + event.push_attribute(("familyname", familyname)); + } + + let stylename = source + .map(|source| source.name().to_string()) + .or_else(|| Some("Regular".to_string())); + if let Some(stylename) = stylename.as_deref() { + event.push_attribute(("stylename", stylename)); + event.push_attribute(("name", stylename)); + } + + let layer = source.and_then(|source| Self::source_layer(source, font)); + if let Some(layer) = layer.as_deref() { + event.push_attribute(("layer", layer)); + } + + writer.write_event(Event::Empty(event)).map_err(|error| { + DesignspaceError::ParseAxislessXml { + details: error.to_string(), + } + }) + } + + fn save_axisless_designspace( + font: &Font, + path: &Path, + ufo_filename: &str, + ) -> DesignspaceResult<()> { + let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2); + writer + .write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None))) + .map_err(|error| DesignspaceError::ParseAxislessXml { + details: error.to_string(), + })?; + + let mut designspace = BytesStart::new("designspace"); + designspace.push_attribute(("format", "5.0")); + writer + .write_event(Event::Start(designspace)) + .map_err(|error| DesignspaceError::ParseAxislessXml { + details: error.to_string(), + })?; + writer + .write_event(Event::Start(BytesStart::new("sources"))) + .map_err(|error| DesignspaceError::ParseAxislessXml { + details: error.to_string(), + })?; + + let sources = font.sources(); + if sources.is_empty() { + Self::write_axisless_source(&mut writer, font, ufo_filename, None)?; + } else { + for source in sources { + Self::write_axisless_source(&mut writer, font, ufo_filename, Some(source))?; + } + } + + writer + .write_event(Event::End(BytesEnd::new("sources"))) + .map_err(|error| DesignspaceError::ParseAxislessXml { + details: error.to_string(), + })?; + writer + .write_event(Event::End(BytesEnd::new("designspace"))) + .map_err(|error| DesignspaceError::ParseAxislessXml { + details: error.to_string(), + })?; + + fs::write(path, writer.into_inner()).map_err(|source| DesignspaceError::WriteFile { + path: path.to_path_buf(), + source, + }) + } + + fn path_to_str(path: &Path) -> DesignspaceResult<&str> { + path.to_str() + .ok_or_else(|| DesignspaceError::InvalidPathUtf8 { + path: path.to_path_buf(), + }) + } + + fn save_designspace(&self, font: &Font, path: &Path) -> DesignspaceResult<()> { + let parent = path + .parent() + .ok_or_else(|| DesignspaceError::MissingParent { + path: path.to_path_buf(), + })?; + fs::create_dir_all(parent).map_err(|source| DesignspaceError::CreateDir { + path: parent.to_path_buf(), + source, + })?; + + let ufo_filename = Self::companion_ufo_filename(path)?; + let ufo_path = parent.join(&ufo_filename); + UfoWriter::new() + .save(font, Self::path_to_str(&ufo_path)?) + .map_err(|source| DesignspaceError::SaveUfo { + path: ufo_path.clone(), + details: source.to_string(), + })?; + + let axes = font.axes(); + if axes.is_empty() { + return Self::save_axisless_designspace(font, path, &ufo_filename); + } + + let mut document = DesignSpaceDocument { + format: 5.0, + axes: axes.iter().map(Self::axis).collect(), + sources: font + .sources() + .iter() + .map(|source| Self::source(source, font, &ufo_filename, axes)) + .collect(), + ..Default::default() + }; + + if document.sources.is_empty() { + document.sources.push(DsSource { + familyname: font.metadata().family_name.clone(), + stylename: Some("Regular".to_string()), + name: Some("Regular".to_string()), + filename: ufo_filename, + location: Self::location(&Location::new(), axes), + ..Default::default() + }); + } + + document + .save(path) + .map_err(|source| DesignspaceError::SaveDesignspace { + path: path.to_path_buf(), + details: source.to_string(), + }) + } +} + +impl Default for DesignspaceWriter { + fn default() -> Self { + Self::new() + } +} + +impl FontWriter for DesignspaceWriter { + fn save(&self, font: &Font, path: &str) -> FormatBackendResult<()> { + self.save_designspace(font, Path::new(path)) + .map_err(FormatBackendError::from) + } +} diff --git a/crates/shift-backends/src/errors.rs b/crates/shift-backends/src/errors.rs index 77bac5eb..d32e8b36 100644 --- a/crates/shift-backends/src/errors.rs +++ b/crates/shift-backends/src/errors.rs @@ -1,28 +1,80 @@ +use std::path::PathBuf; + +use crate::designspace::DesignspaceError; +use crate::format::FontFormat; + #[derive(Debug, thiserror::Error)] pub enum BackendError { - #[error("file has no extension")] - MissingExtension, + #[error("file has no extension: {path}")] + MissingExtension { path: PathBuf }, + + #[error("invalid UTF-8 in path: {path}")] + InvalidPathUtf8 { path: PathBuf }, + + #[error("invalid UTF-8 in extension for path: {path}")] + InvalidExtensionUtf8 { path: PathBuf }, + + #[error("unsupported font format: {extension}")] + UnsupportedFormat { extension: String }, + + #[error("unsupported font format for writing: {extension}")] + UnsupportedWriteFormat { extension: String }, - #[error("invalid UTF-8 in path")] - InvalidPathUtf8, + #[error("font format adaptor is not registered: {}", format.name())] + MissingAdaptor { format: FontFormat }, - #[error("invalid UTF-8 in extension")] - InvalidExtensionUtf8, + #[error("failed to load {} font from '{path}': {source}", format.name())] + Load { + format: FontFormat, + path: PathBuf, + #[source] + source: FormatBackendError, + }, - #[error("unsupported font format: {0}")] - UnsupportedFormat(String), + #[error("failed to save {} font to '{path}': {source}", format.name())] + Save { + format: FontFormat, + path: PathBuf, + #[source] + source: FormatBackendError, + }, +} + +impl BackendError { + pub fn load(format: FontFormat, path: impl Into, source: FormatBackendError) -> Self { + Self::Load { + format, + path: path.into(), + source, + } + } + + pub fn save(format: FontFormat, path: impl Into, source: FormatBackendError) -> Self { + Self::Save { + format, + path: path.into(), + source, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum FormatBackendError { + #[error(transparent)] + Designspace(#[from] DesignspaceError), - #[error("unsupported font format for writing: {0}")] - UnsupportedWriteFormat(String), + #[error("UFO backend error: {0}")] + Ufo(String), - #[error("font format adaptor is not registered: {0}")] - MissingAdaptor(&'static str), + #[error("Glyphs backend error: {0}")] + Glyphs(String), - #[error("failed to load font: {0}")] - Load(String), + #[error("binary font backend error: {0}")] + Binary(String), - #[error("failed to save font: {0}")] - Save(String), + #[error("writing is not supported for this format")] + WriteUnsupported, } pub type BackendResult = Result; +pub type FormatBackendResult = Result; diff --git a/crates/shift-backends/src/export.rs b/crates/shift-backends/src/export.rs new file mode 100644 index 00000000..049ac70c --- /dev/null +++ b/crates/shift-backends/src/export.rs @@ -0,0 +1,216 @@ +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use crate::traits::FontView; +use crate::ufo::UfoWriter; +use fontc::JobTimer; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExportFormat { + Ttf, +} + +impl ExportFormat { + pub fn as_str(self) -> &'static str { + match self { + ExportFormat::Ttf => "ttf", + } + } +} + +impl TryFrom<&str> for ExportFormat { + type Error = ExportError; + + fn try_from(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "ttf" => Ok(Self::Ttf), + format => Err(ExportError::UnsupportedFormat { + format: format.to_string(), + }), + } + } +} + +#[derive(Clone, Debug)] +pub struct FontExportRequest { + pub path: PathBuf, + pub format: ExportFormat, +} + +#[derive(Clone, Debug)] +pub struct FontExportResult { + pub path: PathBuf, + pub format: ExportFormat, +} + +#[derive(Debug, thiserror::Error)] +pub enum ExportError { + #[error("unsupported export format: {format}")] + UnsupportedFormat { format: String }, + + #[error("export path must end in .ttf for TrueType export: {path}")] + OutputExtensionMismatch { path: PathBuf }, + + #[error("invalid UTF-8 in {label} path: {path}")] + InvalidPathUtf8 { label: &'static str, path: PathBuf }, + + #[error("failed to create temporary export directory")] + TempDir { + #[source] + source: std::io::Error, + }, + + #[error("failed to prepare temporary UFO for export: {message}")] + PrepareUfo { message: String }, + + #[error("failed to compile TrueType font: {message}")] + CompileTtf { message: String }, +} + +pub struct FontExporter; + +impl FontExporter { + pub fn new() -> Self { + Self + } + + pub fn export( + &self, + font: &impl FontView, + request: FontExportRequest, + ) -> Result { + match request.format { + ExportFormat::Ttf => self.export_ttf(font, &request.path)?, + } + + Ok(FontExportResult { + path: request.path, + format: request.format, + }) + } + + fn export_ttf(&self, font: &impl FontView, output_path: &Path) -> Result<(), ExportError> { + ensure_ttf_output_path(output_path)?; + + let temp_dir = tempfile::Builder::new() + .prefix("shift-export-") + .tempdir() + .map_err(|source| ExportError::TempDir { source })?; + + let ufo_path = temp_dir.path().join("source.ufo"); + let build_dir = temp_dir.path().join("build"); + let ufo_path_str = path_to_str(&ufo_path, "temporary UFO")?; + + UfoWriter::new() + .save_view(font, ufo_path_str) + .map_err(|message| ExportError::PrepareUfo { message })?; + + compile_ttf(ufo_path_str, &build_dir, output_path) + } +} + +impl Default for FontExporter { + fn default() -> Self { + Self::new() + } +} + +fn compile_ttf(input_path: &str, build_dir: &Path, output_path: &Path) -> Result<(), ExportError> { + let mut args = fontc::Args::new(build_dir, input_path.into()); + args.output_file = Some(output_path.to_path_buf()); + + let timer = JobTimer::new(Instant::now()); + fontc::run(args, timer).map_err(|source| ExportError::CompileTtf { + message: source.to_string(), + }) +} + +fn ensure_ttf_output_path(path: &Path) -> Result<(), ExportError> { + match path.extension().and_then(|ext| ext.to_str()) { + Some(ext) if ext.eq_ignore_ascii_case("ttf") => Ok(()), + _ => Err(ExportError::OutputExtensionMismatch { + path: path.to_path_buf(), + }), + } +} + +fn path_to_str<'a>(path: &'a Path, label: &'static str) -> Result<&'a str, ExportError> { + path.to_str().ok_or_else(|| ExportError::InvalidPathUtf8 { + label, + path: path.to_path_buf(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use shift_ir::{Contour, Font, Glyph, GlyphLayer, PointType}; + use skrifa::{FontRef, MetadataProvider}; + + fn simple_font() -> Font { + let mut font = Font::new(); + let default_layer_id = font.default_layer_id(); + let mut glyph = Glyph::with_unicode("A".to_string(), 0x0041); + let mut layer = GlyphLayer::with_width(600.0); + let mut contour = Contour::new(); + contour.add_point(100.0, 0.0, PointType::OnCurve, false); + contour.add_point(300.0, 700.0, PointType::OnCurve, false); + contour.add_point(500.0, 0.0, PointType::OnCurve, false); + contour.close(); + layer.add_contour(contour); + glyph.set_layer(default_layer_id, layer); + font.insert_glyph(glyph); + font + } + + #[test] + fn exports_ttf_that_can_be_read_back() { + let temp_dir = tempfile::tempdir().unwrap(); + let output_path = temp_dir.path().join("Dogfood.ttf"); + let font = simple_font(); + + let result = FontExporter::new() + .export( + &font, + FontExportRequest { + path: output_path.clone(), + format: ExportFormat::Ttf, + }, + ) + .unwrap(); + + assert_eq!(result.path, output_path); + assert_eq!(result.format, ExportFormat::Ttf); + + let bytes = std::fs::read(&output_path).unwrap(); + assert!(!bytes.is_empty()); + + let exported = FontRef::new(&bytes).unwrap(); + assert!(exported + .charmap() + .mappings() + .any(|(codepoint, _)| codepoint == 0x0041)); + } + + #[test] + fn rejects_ttf_export_without_ttf_extension() { + let temp_dir = tempfile::tempdir().unwrap(); + let output_path = temp_dir.path().join("Dogfood.otf"); + let font = simple_font(); + + let error = FontExporter::new() + .export( + &font, + FontExportRequest { + path: output_path.clone(), + format: ExportFormat::Ttf, + }, + ) + .unwrap_err(); + + assert!(matches!( + error, + ExportError::OutputExtensionMismatch { path } if path == output_path + )); + } +} diff --git a/crates/shift-backends/src/font_loader.rs b/crates/shift-backends/src/font_loader.rs index 0fadaa9e..512af95e 100644 --- a/crates/shift-backends/src/font_loader.rs +++ b/crates/shift-backends/src/font_loader.rs @@ -3,38 +3,18 @@ use std::path::Path; use shift_ir::Font; -use crate::designspace::DesignspaceReader; -use crate::errors::{BackendError, BackendResult}; +use crate::designspace::{DesignspaceReader, DesignspaceWriter}; +use crate::errors::{BackendError, BackendResult, FormatBackendError, FormatBackendResult}; +use crate::format::FontFormat; use crate::glyphs::GlyphsReader; use crate::traits::{FontReader, FontWriter}; use crate::ufo::{UfoReader, UfoWriter}; use crate::binary::BytesFontAdaptor; -#[derive(Hash, Eq, PartialEq)] -pub enum FontFormat { - Ufo, - Glyphs, - Designspace, - Ttf, - Otf, -} - -impl FontFormat { - fn name(&self) -> &'static str { - match self { - FontFormat::Ufo => "ufo", - FontFormat::Glyphs => "glyphs", - FontFormat::Designspace => "designspace", - FontFormat::Ttf => "ttf", - FontFormat::Otf => "otf", - } - } -} - pub trait FontAdaptor { - fn read_font(&self, path: &str) -> Result; - fn write_font(&self, font: &Font, path: &str) -> Result<(), String>; + fn read_font(&self, path: &str) -> FormatBackendResult; + fn write_font(&self, font: &Font, path: &str) -> FormatBackendResult<()>; } struct UfoFontAdaptor; @@ -42,32 +22,32 @@ struct GlyphsFontAdaptor; struct DesignspaceFontAdaptor; impl FontAdaptor for UfoFontAdaptor { - fn read_font(&self, path: &str) -> Result { + fn read_font(&self, path: &str) -> FormatBackendResult { UfoReader::new().load(path) } - fn write_font(&self, font: &Font, path: &str) -> Result<(), String> { + fn write_font(&self, font: &Font, path: &str) -> FormatBackendResult<()> { UfoWriter::new().save(font, path) } } impl FontAdaptor for GlyphsFontAdaptor { - fn read_font(&self, path: &str) -> Result { + fn read_font(&self, path: &str) -> FormatBackendResult { GlyphsReader::new().load(path) } - fn write_font(&self, _font: &Font, _path: &str) -> Result<(), String> { - Err("Glyphs writing is not supported; save as .ufo instead".to_string()) + fn write_font(&self, _font: &Font, _path: &str) -> FormatBackendResult<()> { + Err(FormatBackendError::WriteUnsupported) } } impl FontAdaptor for DesignspaceFontAdaptor { - fn read_font(&self, path: &str) -> Result { + fn read_font(&self, path: &str) -> FormatBackendResult { DesignspaceReader::new().load(path) } - fn write_font(&self, _font: &Font, _path: &str) -> Result<(), String> { - Err("Designspace writing is not supported; save as .ufo instead".to_string()) + fn write_font(&self, font: &Font, path: &str) -> FormatBackendResult<()> { + DesignspaceWriter::new().save(font, path) } } @@ -89,15 +69,21 @@ fn format_from_extension(ext: &str) -> BackendResult { "designspace" => Ok(FontFormat::Designspace), "ttf" => Ok(FontFormat::Ttf), "otf" => Ok(FontFormat::Otf), - _ => Err(BackendError::UnsupportedFormat(ext.to_string())), + _ => Err(BackendError::UnsupportedFormat { + extension: ext.to_string(), + }), } } fn extension_from_path(path: &Path) -> BackendResult<&str> { path.extension() - .ok_or(BackendError::MissingExtension)? + .ok_or_else(|| BackendError::MissingExtension { + path: path.to_path_buf(), + })? .to_str() - .ok_or(BackendError::InvalidExtensionUtf8) + .ok_or_else(|| BackendError::InvalidExtensionUtf8 { + path: path.to_path_buf(), + }) } impl FontLoader { @@ -120,12 +106,17 @@ impl FontLoader { let path = Path::new(path); let ext = extension_from_path(path)?; let format = format_from_extension(ext)?; + let path_buf = path.to_path_buf(); let adaptor = self .adaptors .get(&format) - .ok_or_else(|| BackendError::MissingAdaptor(format.name()))?; - let path = path.to_str().ok_or(BackendError::InvalidPathUtf8)?; - adaptor.read_font(path).map_err(BackendError::Load) + .ok_or(BackendError::MissingAdaptor { format })?; + let path = path.to_str().ok_or_else(|| BackendError::InvalidPathUtf8 { + path: path_buf.clone(), + })?; + adaptor + .read_font(path) + .map_err(|source| BackendError::load(format, path_buf, source)) } pub fn write_font(&self, font: &Font, path: &str) -> BackendResult<()> { @@ -134,22 +125,32 @@ impl FontLoader { let format = format_from_extension(ext)?; match format { - FontFormat::Ufo => {} - _ => return Err(BackendError::UnsupportedWriteFormat(ext.to_string())), + FontFormat::Ufo | FontFormat::Designspace => {} + _ => { + return Err(BackendError::UnsupportedWriteFormat { + extension: ext.to_string(), + }) + } } + let path_buf = path.to_path_buf(); let adaptor = self .adaptors .get(&format) - .ok_or_else(|| BackendError::MissingAdaptor(format.name()))?; - let path = path.to_str().ok_or(BackendError::InvalidPathUtf8)?; - adaptor.write_font(font, path).map_err(BackendError::Save) + .ok_or(BackendError::MissingAdaptor { format })?; + let path = path.to_str().ok_or_else(|| BackendError::InvalidPathUtf8 { + path: path_buf.clone(), + })?; + adaptor + .write_font(font, path) + .map_err(|source| BackendError::save(format, path_buf, source)) } } #[cfg(test)] mod tests { - use super::{format_from_extension, FontFormat}; + use super::format_from_extension; + use crate::format::FontFormat; #[test] fn supports_glyphs_extensions() { diff --git a/crates/shift-backends/src/format.rs b/crates/shift-backends/src/format.rs new file mode 100644 index 00000000..302356ed --- /dev/null +++ b/crates/shift-backends/src/format.rs @@ -0,0 +1,20 @@ +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum FontFormat { + Ufo, + Glyphs, + Designspace, + Ttf, + Otf, +} + +impl FontFormat { + pub fn name(self) -> &'static str { + match self { + FontFormat::Ufo => "ufo", + FontFormat::Glyphs => "glyphs", + FontFormat::Designspace => "designspace", + FontFormat::Ttf => "ttf", + FontFormat::Otf => "otf", + } + } +} diff --git a/crates/shift-backends/src/glyphs/reader.rs b/crates/shift-backends/src/glyphs/reader.rs index 9f4479a7..27a7b363 100644 --- a/crates/shift-backends/src/glyphs/reader.rs +++ b/crates/shift-backends/src/glyphs/reader.rs @@ -6,6 +6,7 @@ use shift_ir::{ use std::collections::HashMap; use std::path::Path; +use crate::errors::{FormatBackendError, FormatBackendResult}; use crate::traits::FontReader; const GLYPHS_SIDE1_PREFIX: &str = "@MMK_L_"; @@ -124,9 +125,9 @@ impl Default for GlyphsReader { } impl FontReader for GlyphsReader { - fn load(&self, path: &str) -> Result { - let glyphs_font = - GlyphsFont::load(Path::new(path)).map_err(|e| format!("Failed to load glyphs: {e}"))?; + fn load(&self, path: &str) -> FormatBackendResult { + let glyphs_font = GlyphsFont::load(Path::new(path)) + .map_err(|e| FormatBackendError::Glyphs(e.to_string()))?; let mut font = Font::empty(); let default_layer_id = font.default_layer_id(); diff --git a/crates/shift-backends/src/lib.rs b/crates/shift-backends/src/lib.rs index f95f7bc9..ba5f7a54 100644 --- a/crates/shift-backends/src/lib.rs +++ b/crates/shift-backends/src/lib.rs @@ -1,10 +1,14 @@ pub mod binary; pub mod designspace; pub mod errors; +pub mod export; pub mod font_loader; +pub mod format; pub mod glyphs; mod traits; pub mod ufo; -pub use errors::{BackendError, BackendResult}; +pub use errors::{BackendError, BackendResult, FormatBackendError, FormatBackendResult}; +pub use export::{ExportError, ExportFormat, FontExportRequest, FontExportResult, FontExporter}; +pub use format::FontFormat; pub use traits::{FontBackend, FontReader, FontView, FontWriter}; diff --git a/crates/shift-backends/src/traits.rs b/crates/shift-backends/src/traits.rs index 72d568ee..7e6d7aea 100644 --- a/crates/shift-backends/src/traits.rs +++ b/crates/shift-backends/src/traits.rs @@ -1,3 +1,4 @@ +use crate::errors::FormatBackendResult; use shift_ir::{ Axis, FeatureData, Font, FontMetadata, FontMetrics, Glyph, GlyphName, Guideline, KerningData, Layer, LayerId, LibData, Source, @@ -72,7 +73,7 @@ impl FontView for Font { } pub trait FontReader: Send + Sync { - fn load(&self, path: &str) -> Result; + fn load(&self, path: &str) -> FormatBackendResult; fn get_glyph(&self, font: &Font, name: &GlyphName) -> Option { font.glyph(name).cloned() @@ -88,7 +89,7 @@ pub trait FontReader: Send + Sync { } pub trait FontWriter: Send + Sync { - fn save(&self, font: &Font, path: &str) -> Result<(), String>; + fn save(&self, font: &Font, path: &str) -> FormatBackendResult<()>; } pub trait FontBackend: FontReader + FontWriter {} diff --git a/crates/shift-backends/src/ufo/mod.rs b/crates/shift-backends/src/ufo/mod.rs index d432b5d4..5b336070 100644 --- a/crates/shift-backends/src/ufo/mod.rs +++ b/crates/shift-backends/src/ufo/mod.rs @@ -5,18 +5,19 @@ pub use reader::UfoReader; pub use writer::UfoWriter; use crate::traits::{FontReader, FontWriter}; +use crate::FormatBackendResult; use shift_ir::Font; pub struct UfoBackend; impl FontReader for UfoBackend { - fn load(&self, path: &str) -> Result { + fn load(&self, path: &str) -> FormatBackendResult { UfoReader::new().load(path) } } impl FontWriter for UfoBackend { - fn save(&self, font: &Font, path: &str) -> Result<(), String> { + fn save(&self, font: &Font, path: &str) -> FormatBackendResult<()> { UfoWriter::new().save(font, path) } } diff --git a/crates/shift-backends/src/ufo/reader.rs b/crates/shift-backends/src/ufo/reader.rs index f07a563a..685775ac 100644 --- a/crates/shift-backends/src/ufo/reader.rs +++ b/crates/shift-backends/src/ufo/reader.rs @@ -1,3 +1,4 @@ +use crate::errors::{FormatBackendError, FormatBackendResult}; use crate::traits::FontReader; use norad::{Font as NoradFont, Line}; use shift_ir::{ @@ -200,8 +201,9 @@ impl Default for UfoReader { } impl FontReader for UfoReader { - fn load(&self, path: &str) -> Result { - let norad_font = NoradFont::load(path).map_err(|e| e.to_string())?; + fn load(&self, path: &str) -> FormatBackendResult { + let norad_font = + NoradFont::load(path).map_err(|e| FormatBackendError::Ufo(e.to_string()))?; let ufo_path = Path::new(path); let mut font = Font::new(); diff --git a/crates/shift-backends/src/ufo/writer.rs b/crates/shift-backends/src/ufo/writer.rs index ada96abc..d832df02 100644 --- a/crates/shift-backends/src/ufo/writer.rs +++ b/crates/shift-backends/src/ufo/writer.rs @@ -1,3 +1,4 @@ +use crate::errors::{FormatBackendError, FormatBackendResult}; use crate::traits::{FontView, FontWriter}; use norad::{Font as NoradFont, Glyph as NoradGlyph, Line, Name}; use shift_ir::{ @@ -316,7 +317,8 @@ impl UfoWriter { } impl FontWriter for UfoWriter { - fn save(&self, font: &Font, path: &str) -> Result<(), String> { + fn save(&self, font: &Font, path: &str) -> FormatBackendResult<()> { self.save_view(font, path) + .map_err(|error| FormatBackendError::Ufo(error.to_string())) } } diff --git a/crates/shift-backends/tests/export.rs b/crates/shift-backends/tests/export.rs new file mode 100644 index 00000000..3afceef3 --- /dev/null +++ b/crates/shift-backends/tests/export.rs @@ -0,0 +1,115 @@ +use std::path::{Path, PathBuf}; + +use shift_backends::font_loader::FontLoader; +use shift_backends::{ExportFormat, FontExportRequest, FontExporter}; +use shift_ir::{Font, Glyph, GlyphLayer}; + +fn fixtures_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("fixtures") +} + +fn mutatorsans_ufo_path() -> PathBuf { + fixtures_path().join("fonts/mutatorsans/MutatorSansLightCondensed.ufo") +} + +fn load_font(path: &Path) -> Font { + assert!(path.exists(), "missing font fixture at {}", path.display()); + FontLoader::new() + .read_font(path.to_str().unwrap()) + .unwrap_or_else(|error| panic!("failed to load {}: {error}", path.display())) +} + +fn main_layer(glyph: &Glyph) -> &GlyphLayer { + glyph + .layers() + .values() + .max_by_key(|layer| layer.contours().len()) + .expect("glyph should have at least one layer") +} + +#[test] +fn exports_mutatorsans_ufo_to_readable_ttf() { + let source = load_font(&mutatorsans_ufo_path()); + let temp_dir = tempfile::tempdir().expect("tempdir should be created"); + let output_path = temp_dir.path().join("MutatorSansLightCondensed.ttf"); + + FontExporter::new() + .export( + &source, + FontExportRequest { + path: output_path.clone(), + format: ExportFormat::Ttf, + }, + ) + .expect("MutatorSans UFO should export as TTF"); + + let exported = load_font(&output_path); + + let exported_family = exported + .metadata() + .family_name + .as_deref() + .expect("exported TTF should include a family name"); + let source_family = source + .metadata() + .family_name + .as_deref() + .expect("source UFO should include a family name"); + let source_style = source + .metadata() + .style_name + .as_deref() + .expect("source UFO should include a style name"); + + assert!( + exported_family.contains(source_family), + "exported family name should include source family: {exported_family}" + ); + assert!( + exported_family.contains(source_style) + || exported.metadata().style_name.as_deref() == Some(source_style), + "exported name data should include source style: family={exported_family}, style={:?}", + exported.metadata().style_name + ); + assert_eq!( + exported.metrics().units_per_em, + source.metrics().units_per_em + ); + + for codepoint in [0x0041, 0x004F, 0x0053] { + let glyph = exported + .glyph_by_unicode(codepoint) + .unwrap_or_else(|| panic!("exported TTF should contain U+{codepoint:04X}")); + let layer = main_layer(glyph); + + assert!( + !layer.contours().is_empty(), + "U+{codepoint:04X} should retain exported outlines" + ); + assert!( + layer.width() > 0.0, + "U+{codepoint:04X} should retain exported advance width" + ); + } + + for codepoint in [0x0041, 0x004F] { + let source_glyph = source + .glyph_by_unicode(codepoint) + .unwrap_or_else(|| panic!("source UFO should contain U+{codepoint:04X}")); + let exported_glyph = exported + .glyph_by_unicode(codepoint) + .unwrap_or_else(|| panic!("exported TTF should contain U+{codepoint:04X}")); + let source_layer = main_layer(source_glyph); + let exported_layer = main_layer(exported_glyph); + + assert!( + (exported_layer.width() - source_layer.width()).abs() < 0.001, + "U+{codepoint:04X} should retain source advance width" + ); + } +} diff --git a/crates/shift-bridge/index.d.ts b/crates/shift-bridge/index.d.ts index cb35ba8b..076bcd65 100644 --- a/crates/shift-bridge/index.d.ts +++ b/crates/shift-bridge/index.d.ts @@ -14,10 +14,12 @@ export declare class Bridge { createFont(): void loadFont(path: string): void saveFont(path: string): Promise + exportFont(request: NapiFontExportRequest): Promise getMetadata(): NapiFontMetadata getMetrics(): NapiFontMetrics getGlyphCount(): number getGlyphs(): Array + updateGlyphIdentity(fromName: GlyphName, name: GlyphName, unicodes: Array): void getGlyphState(glyphHandle: GlyphHandle, sourceId: SourceId): NapiGlyphState | null getGlyphVariationReport(glyphRef: GlyphHandle): NapiGlyphVariationReport | null getVariationReports(): Array @@ -56,6 +58,16 @@ export interface GlyphHandle { unicode?: Unicode } +export interface NapiFontExportRequest { + path: string + format: string +} + +export interface NapiFontExportResult { + path: string + format: string +} + export interface NapiGlyphVariationDiagnostic { glyphName: GlyphName code: string diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index 21788f38..84a8c58f 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -4,11 +4,14 @@ use napi::bindgen_prelude::*; use napi::{Error, Status}; use napi_derive::napi; use serde::{Deserialize, Serialize}; -use shift_backends::{font_loader::FontLoader, ufo::UfoWriter, FontView}; +use shift_backends::{ + font_loader::FontLoader, ExportFormat, FontExportRequest, FontExportResult, FontExporter, + FontView, +}; use shift_edit::{ edit_session::{BulkNodePositionUpdates, EditSession}, interpolation::{build_glyph_variation_data, build_masters, GlyphVariationBuild}, - BooleanOp, ContourId, Font, Glyph, GlyphLayer, LayerId, PointId, SourceId, + BooleanOp, ContourId, Font, Glyph, GlyphLayer, GlyphName, LayerId, PointId, SourceId, }; use shift_wire::{ bridges::napi::{ @@ -32,6 +35,42 @@ pub struct GlyphHandle { pub unicode: Option, } +#[napi(object)] +#[derive(Clone, Debug)] +pub struct NapiFontExportRequest { + pub path: String, + pub format: String, +} + +#[napi(object)] +pub struct NapiFontExportResult { + pub path: String, + pub format: String, +} + +impl TryFrom for FontExportRequest { + type Error = Error; + + fn try_from(request: NapiFontExportRequest) -> Result { + let format = ExportFormat::try_from(request.format.as_str()) + .map_err(|e| Error::new(Status::InvalidArg, e.to_string()))?; + + Ok(Self { + path: request.path.into(), + format, + }) + } +} + +impl From for NapiFontExportResult { + fn from(result: FontExportResult) -> Self { + Self { + path: result.path.to_string_lossy().into_owned(), + format: result.format.as_str().to_string(), + } + } +} + #[napi(object)] pub struct NapiGlyphVariationDiagnosticSource { #[napi(ts_type = "SourceId")] @@ -111,6 +150,14 @@ impl FontSaveSnapshot { fn version(&self) -> DocumentVersion { self.version } + + fn to_font(&self) -> Font { + let mut font = self.font.clone(); + if let Some(active_glyph) = self.active_glyph_override.as_ref() { + font.put_glyph(active_glyph.as_ref().clone()); + } + font + } } impl FontView for FontSaveSnapshot { @@ -204,9 +251,10 @@ impl Task for SaveFontTask { type JsValue = u32; fn compute(&mut self) -> Result { - UfoWriter::new() - .save_view(&self.snapshot, &self.path) - .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to save font: {e}")))?; + let font = self.snapshot.to_font(); + FontLoader::new() + .write_font(&font, &self.path) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?; Ok(self.snapshot.version()) } @@ -217,6 +265,26 @@ impl Task for SaveFontTask { } } +pub struct ExportFontTask { + snapshot: FontSaveSnapshot, + request: FontExportRequest, +} + +impl Task for ExportFontTask { + type Output = FontExportResult; + type JsValue = NapiFontExportResult; + + fn compute(&mut self) -> Result { + FontExporter::new() + .export(&self.snapshot, self.request.clone()) + .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { + Ok(output.into()) + } +} + pub struct ActiveEdit { session: EditSession, glyph: Glyph, @@ -348,6 +416,17 @@ impl Bridge { }) } + #[napi(ts_return_type = "Promise")] + pub fn export_font( + &mut self, + request: NapiFontExportRequest, + ) -> Result> { + Ok(AsyncTask::new(ExportFontTask { + snapshot: self.save_snapshot(), + request: request.try_into()?, + })) + } + #[napi] pub fn get_metadata(&self) -> NapiFontMetadata { FontMetadata::from(self.font.metadata()).into() @@ -377,6 +456,67 @@ impl Bridge { records } + #[napi] + pub fn update_glyph_identity( + &mut self, + #[napi(ts_arg_type = "GlyphName")] from_name: String, + #[napi(ts_arg_type = "GlyphName")] name: String, + #[napi(ts_arg_type = "Array")] unicodes: Vec, + ) -> errors::Result<()> { + let name = name.trim(); + + let existing = self + .font + .glyph(&from_name) + .ok_or_else(|| BridgeError::InvalidInput { + kind: "glyph name", + value: from_name.clone(), + })?; + if from_name == name && existing.unicodes() == unicodes.as_slice() { + return Ok(()); + } + + if self.active_edit.is_some() { + return Err(BridgeError::InvalidInput { + kind: "glyph identity update", + value: "cannot update while an edit session is active".to_string(), + }); + } + + if name.is_empty() { + return Err(BridgeError::InvalidInput { + kind: "glyph name", + value: name.to_string(), + }); + } + + if from_name != name && self.font.glyph(name).is_some() { + return Err(BridgeError::InvalidInput { + kind: "glyph name", + value: format!("{name} already exists"), + }); + } + + let Some(mut glyph) = self.font.take_glyph(&from_name) else { + return Err(BridgeError::InvalidInput { + kind: "glyph name", + value: from_name, + }); + }; + + let glyph_name = GlyphName::new(name.to_string()).map_err(|_| BridgeError::InvalidInput { + kind: "glyph name", + value: name.to_string(), + })?; + + glyph.set_name(glyph_name); + glyph.set_unicodes(unicodes); + self.font.put_glyph(glyph); + self.mark_committed_changed(); + + Ok(()) + } + #[napi] pub fn get_glyph_state( &self, @@ -1112,6 +1252,7 @@ mod tests { assert!(!bridge.has_edit_session()); assert_eq!(bridge.get_glyph_count(), 0); + assert!(bridge.get_axes().is_empty()); assert_eq!(bridge.get_sources().len(), 1); assert_eq!(bridge.get_sources()[0].name, "Regular"); } @@ -1217,6 +1358,48 @@ mod tests { ); } + #[test] + fn save_task_routes_designspace_paths_through_font_loader() { + let mut bridge = Bridge::new(); + bridge + .start_edit_session(glyph_handle("A", Some(65)), default_source_id(&bridge)) + .unwrap(); + bridge.add_contour().unwrap(); + + let dir = std::env::temp_dir().join("shift_bridge_designspace_save_task"); + let designspace_path = dir.join("Smoke.designspace"); + let ufo_path = dir.join("Smoke.ufo"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let mut task = SaveFontTask { + snapshot: bridge.save_snapshot(), + persisted_version: bridge.persisted_version.clone(), + path: designspace_path.to_string_lossy().into_owned(), + }; + + task.compute().unwrap(); + + assert!( + designspace_path.is_file(), + "designspace save should create an XML file, not a UFO directory" + ); + assert!( + ufo_path.is_dir(), + "designspace save should create a companion UFO source" + ); + + let mut reloaded = Bridge::new(); + reloaded + .load_font(designspace_path.to_string_lossy().into_owned()) + .unwrap(); + let glyphs = reloaded.get_glyphs(); + assert_eq!(glyphs.len(), 1); + assert_eq!(glyphs[0].name, "A"); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn persisted_older_snapshot_keeps_document_dirty_after_new_edit() { let mut bridge = Bridge::new(); diff --git a/crates/shift-bridge/src/errors.rs b/crates/shift-bridge/src/errors.rs index e3fa5c8e..a55fd43e 100644 --- a/crates/shift-bridge/src/errors.rs +++ b/crates/shift-bridge/src/errors.rs @@ -23,11 +23,11 @@ pub enum BridgeError { pub fn to_napi_error(error: BridgeError) -> Error { let status = match &error { BridgeError::InvalidInput { .. } - | BridgeError::Backend(BackendError::MissingExtension) - | BridgeError::Backend(BackendError::InvalidPathUtf8) - | BridgeError::Backend(BackendError::InvalidExtensionUtf8) - | BridgeError::Backend(BackendError::UnsupportedFormat(_)) - | BridgeError::Backend(BackendError::UnsupportedWriteFormat(_)) => Status::InvalidArg, + | BridgeError::Backend(BackendError::MissingExtension { .. }) + | BridgeError::Backend(BackendError::InvalidPathUtf8 { .. }) + | BridgeError::Backend(BackendError::InvalidExtensionUtf8 { .. }) + | BridgeError::Backend(BackendError::UnsupportedFormat { .. }) + | BridgeError::Backend(BackendError::UnsupportedWriteFormat { .. }) => Status::InvalidArg, BridgeError::NoActiveEdit | BridgeError::ActiveEditAlreadyExists | BridgeError::Core(_) diff --git a/packages/glyph-info/src/GlyphInfo.ts b/packages/glyph-info/src/GlyphInfo.ts index 25f10d7b..849b3e40 100644 --- a/packages/glyph-info/src/GlyphInfo.ts +++ b/packages/glyph-info/src/GlyphInfo.ts @@ -126,6 +126,7 @@ function toSortedCategorySummaries( */ export class GlyphInfo { #glyphData: Map; + #glyphDataByName: Map; #decomposed: Map; #usedBy: Map; #charsets: CharsetDefinition[]; @@ -133,6 +134,7 @@ export class GlyphInfo { constructor(resources: GlyphInfoResources) { this.#glyphData = new Map(resources.glyphData.map((g) => [g.codepoint, g])); + this.#glyphDataByName = new Map(resources.glyphData.map((g) => [g.name, g])); this.#decomposed = new Map( Object.entries(resources.decomposition.decomposed).map(([k, v]) => [Number(k), v]), @@ -167,6 +169,11 @@ export class GlyphInfo { return data.name; } + /** Look up the full metadata record for a production glyph name. */ + getGlyphByName(name: string): Glyph | null { + return this.#glyphDataByName.get(name) ?? null; + } + getAllGlyph(): Glyph[] { return Array.from(this.#glyphData.values()); } diff --git a/packages/types/src/bridge/generated.ts b/packages/types/src/bridge/generated.ts index 51f1e4d2..a6a5bb78 100644 --- a/packages/types/src/bridge/generated.ts +++ b/packages/types/src/bridge/generated.ts @@ -19,10 +19,12 @@ export interface BridgeApi { createFont(): void loadFont(path: string): void saveFont(path: string): Promise + exportFont(request: FontExportRequest): Promise getMetadata(): FontMetadata getMetrics(): FontMetrics getGlyphCount(): number getGlyphs(): Array + updateGlyphIdentity(fromName: GlyphName, name: GlyphName, unicodes: Array): void getGlyphState(glyphHandle: GlyphHandle, sourceId: SourceId): GlyphState | null getGlyphVariationReport(glyphRef: GlyphHandle): GlyphVariationReport | null getVariationReports(): Array @@ -61,6 +63,16 @@ export interface GlyphHandle { unicode?: Unicode } +export interface FontExportRequest { + path: string + format: string +} + +export interface FontExportResult { + path: string + format: string +} + export interface GlyphVariationDiagnostic { glyphName: GlyphName code: string diff --git a/packages/ui/src/components/button/Button.tsx b/packages/ui/src/components/button/Button.tsx index 4d2edfeb..9ba5726c 100644 --- a/packages/ui/src/components/button/Button.tsx +++ b/packages/ui/src/components/button/Button.tsx @@ -15,7 +15,7 @@ export type ButtonProps = BaseButtonProps & { const variantStyles = { primary: "bg-accent text-white hover:bg-accent/90 rounded-md", default: "bg-surface border border-line-subtle hover:bg-surface-hover", - ghost: "hover:bg-hover/40 data-[active]:bg-hover/50", + ghost: "hover:bg-hover/50 data-[active]:bg-hover/50", }; const sizeStyles = {