diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@codemirror/view": "^6.39.14", "@fontsource-variable/ibm-plex-sans": "^5.2.8", "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", "@fontsource/ibm-plex-serif": "^5.2.7", "@fontsource/monaspace-argon": "^5.2.5", "@fontsource/monaspace-krypton": "^5.2.5", @@ -35,9 +36,9 @@ "@fontsource/monaspace-xenon": "^5.2.5", "@lezer/highlight": "^1.2.3", "@lezer/markdown": "^1.6.3", + "@react-pdf/renderer": "^4.3.0", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/api": "^2", - "@react-pdf/renderer": "^4.3.0", "@tauri-apps/plugin-dialog": "^2.2.1", "@tauri-apps/plugin-fs": "^2.2.1", "@tauri-apps/plugin-log": "^2.8.0", @@ -52,6 +53,7 @@ "devDependencies": { "@egoist/tailwindcss-icons": "^1.9.2", "@iconify-json/ri": "^1.2.10", + "@tailwindcss/forms": "^0.5.11", "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ '@fontsource/ibm-plex-mono': specifier: ^5.2.7 version: 5.2.7 + '@fontsource/ibm-plex-sans': + specifier: ^5.2.8 + version: 5.2.8 '@fontsource/ibm-plex-serif': specifier: ^5.2.7 version: 5.2.7 @@ -108,6 +111,9 @@ '@iconify-json/ri': specifier: ^1.2.10 version: 1.2.10 + '@tailwindcss/forms': + specifier: ^0.5.11 + version: 0.5.11(tailwindcss@4.1.18) '@tauri-apps/cli': specifier: ^2 version: 2.10.0 @@ -561,6 +567,9 @@ '@fontsource/ibm-plex-mono@5.2.7': resolution: {integrity: sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==} + '@fontsource/ibm-plex-sans@5.2.8': + resolution: {integrity: sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==} + '@fontsource/ibm-plex-serif@5.2.7': resolution: {integrity: sha512-04owmc2OQQ/DAMjXjEMJc+7V4dBVmR01aIFOg4cuqS8pQ4HbvtlYs/u6+O0vCt21EMx5M/azIbgx43iKyisEOA==} @@ -918,6 +927,11 @@ '@swc/helpers@0.5.18': resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + + '@tailwindcss/forms@0.5.11': + resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1' '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -1627,6 +1641,10 @@ min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + + mini-svg-data-uri@1.4.4: + resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} + hasBin: true mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -2409,6 +2427,8 @@ '@fontsource/ibm-plex-mono@5.2.7': {} + '@fontsource/ibm-plex-sans@5.2.8': {} + '@fontsource/ibm-plex-serif@5.2.7': {} '@fontsource/monaspace-argon@5.2.5': {} @@ -2727,6 +2747,11 @@ '@swc/helpers@0.5.18': dependencies: tslib: 2.8.1 + + '@tailwindcss/forms@0.5.11(tailwindcss@4.1.18)': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 4.1.18 '@tailwindcss/node@4.1.18': dependencies: @@ -3398,6 +3423,8 @@ media-engine@1.0.3: {} min-indent@1.0.1: {} + + mini-svg-data-uri@1.4.4: {} mlly@1.8.0: dependencies: diff --git a/tsconfig.json b/tsconfig.json --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2021", + "target": "ES2022", "useDefineForClassFields": true, - "lib": ["ES2021", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", diff --git a/vite.config.ts b/vite.config.ts --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,6 @@ import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; +import { fileURLToPath, URL } from "node:url"; import { defineConfig } from "vite"; import type { ViteUserConfig } from "vitest/config"; @@ -17,6 +18,7 @@ }; const host = process.env.TAURI_DEV_HOST; +const resolveFromRoot = (path: string) => fileURLToPath(new URL(path, import.meta.url)); // TODO: aliases here and in tsconfig.json // - $hooks @@ -41,12 +43,12 @@ test, resolve: { alias: { - "$ports": "./src/ports.ts", - "$types": "./src/types.ts", - "$logger": "./src/logger.ts", - "$icons": "./src/components/icons.tsx", - "$pdf": "./src/pdf", - "$components": "./src/components", + "$ports": resolveFromRoot("./src/ports.ts"), + "$types": resolveFromRoot("./src/types.ts"), + "$logger": resolveFromRoot("./src/logger.ts"), + "$icons": resolveFromRoot("./src/components/icons.tsx"), + "$pdf": resolveFromRoot("./src/pdf"), + "$components": resolveFromRoot("./src/components"), }, }, }); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,7 +13,6 @@ "bundle": { "active": true, "targets": "all", - "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], - "resources": { "resources/fonts/**/*": "fonts/" } + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"] } } diff --git a/src/App.css b/src/App.css --- a/src/App.css +++ b/src/App.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @plugin "@egoist/tailwindcss-icons"; +@plugin "@tailwindcss/forms"; @theme { --font-sans: diff --git a/src/App.tsx b/src/App.tsx --- a/src/App.tsx +++ b/src/App.tsx @@ -309,6 +309,7 @@ initialText: editorModel.text, theme: layoutState.theme, showLineNumbers: layoutState.lineNumbersVisible, + textWrappingEnabled: layoutState.textWrappingEnabled, syntaxHighlightingEnabled: layoutState.syntaxHighlightingEnabled, fontSize: layoutState.editorFontSize, fontFamily: layoutState.editorFontFamily, @@ -321,6 +322,7 @@ editorModel.text, layoutState.theme, layoutState.lineNumbersVisible, + layoutState.textWrappingEnabled, layoutState.syntaxHighlightingEnabled, layoutState.editorFontSize, layoutState.editorFontFamily, @@ -394,6 +396,7 @@ topBarsCollapsed: layoutState.topBarsCollapsed, statusBarCollapsed: layoutState.statusBarCollapsed, lineNumbersVisible: layoutState.lineNumbersVisible, + textWrappingEnabled: layoutState.textWrappingEnabled, syntaxHighlightingEnabled: layoutState.syntaxHighlightingEnabled, editorFontSize: layoutState.editorFontSize, editorFontFamily: layoutState.editorFontFamily, @@ -401,6 +404,7 @@ onSetTopBarsCollapsed: layoutActions.setTopBarsCollapsed, onSetStatusBarCollapsed: layoutActions.setStatusBarCollapsed, onSetLineNumbersVisible: layoutActions.setLineNumbersVisible, + onSetTextWrappingEnabled: layoutActions.setTextWrappingEnabled, onSetSyntaxHighlightingEnabled: layoutActions.setSyntaxHighlightingEnabled, onSetEditorFontSize: layoutActions.setEditorFontSize, onSetEditorFontFamily: layoutActions.setEditorFontFamily, @@ -412,6 +416,7 @@ layoutState.topBarsCollapsed, layoutState.statusBarCollapsed, layoutState.lineNumbersVisible, + layoutState.textWrappingEnabled, layoutState.syntaxHighlightingEnabled, layoutState.editorFontSize, layoutState.editorFontFamily, @@ -419,6 +424,7 @@ layoutActions.setTopBarsCollapsed, layoutActions.setStatusBarCollapsed, layoutActions.setLineNumbersVisible, + layoutActions.setTextWrappingEnabled, layoutActions.setSyntaxHighlightingEnabled, layoutActions.setEditorFontSize, layoutActions.setEditorFontFamily, @@ -437,6 +443,7 @@ charCount: charCount, selectionCount: selectionCount, lineNumbersVisible: layoutState.lineNumbersVisible, + textWrappingEnabled: layoutState.textWrappingEnabled, syntaxHighlightingEnabled: layoutState.syntaxHighlightingEnabled, editorFontSize: layoutState.editorFontSize, editorFontFamily: layoutState.editorFontFamily, @@ -457,6 +464,7 @@ charCount, selectionCount, layoutState.lineNumbersVisible, + layoutState.textWrappingEnabled, layoutState.syntaxHighlightingEnabled, layoutState.editorFontSize, layoutState.editorFontFamily, @@ -503,6 +511,7 @@ layoutActions.setTopBarsCollapsed(settings.top_bars_collapsed); layoutActions.setStatusBarCollapsed(settings.status_bar_collapsed); layoutActions.setLineNumbersVisible(settings.line_numbers_visible); + layoutActions.setTextWrappingEnabled(settings.text_wrapping_enabled); layoutActions.setSyntaxHighlightingEnabled(settings.syntax_highlighting_enabled); layoutActions.setEditorFontSize(settings.editor_font_size); layoutActions.setEditorFontFamily(settings.editor_font_family); @@ -530,6 +539,7 @@ top_bars_collapsed: layoutState.topBarsCollapsed, status_bar_collapsed: layoutState.statusBarCollapsed, line_numbers_visible: layoutState.lineNumbersVisible, + text_wrapping_enabled: layoutState.textWrappingEnabled, syntax_highlighting_enabled: layoutState.syntaxHighlightingEnabled, editor_font_size: layoutState.editorFontSize, editor_font_family: layoutState.editorFontFamily, @@ -544,6 +554,7 @@ layoutState.topBarsCollapsed, layoutState.statusBarCollapsed, layoutState.lineNumbersVisible, + layoutState.textWrappingEnabled, layoutState.syntaxHighlightingEnabled, layoutState.editorFontSize, layoutState.editorFontFamily, diff --git a/src/ports.ts b/src/ports.ts --- a/src/ports.ts +++ b/src/ports.ts @@ -44,6 +44,7 @@ top_bars_collapsed: boolean; status_bar_collapsed: boolean; line_numbers_visible: boolean; + text_wrapping_enabled: boolean; syntax_highlighting_enabled: boolean; editor_font_size: number; editor_font_family: EditorFontFamily; diff --git a/public/fonts/ibm-plex-mono-400-italic.ttf b/public/fonts/ibm-plex-mono-400-italic.ttf new file mode 100644 --- /dev/null +++ b/public/fonts/ibm-plex-mono-400-italic.ttf diff --git a/public/fonts/ibm-plex-mono-400-normal.ttf b/public/fonts/ibm-plex-mono-400-normal.ttf new file mode 100644 --- /dev/null +++ b/public/fonts/ibm-plex-mono-400-normal.ttf diff --git a/public/fonts/ibm-plex-mono-700-italic.ttf b/public/fonts/ibm-plex-mono-700-italic.ttf new file mode 100644 --- /dev/null +++ b/public/fonts/ibm-plex-mono-700-italic.ttf diff --git a/public/fonts/ibm-plex-mono-700-normal.ttf b/public/fonts/ibm-plex-mono-700-normal.ttf new file mode 100644 --- /dev/null +++ b/public/fonts/ibm-plex-mono-700-normal.ttf diff --git a/public/fonts/ibm-plex-sans-400-italic.ttf b/public/fonts/ibm-plex-sans-400-italic.ttf new file mode 100644 --- /dev/null +++ b/public/fonts/ibm-plex-sans-400-italic.ttf diff --git a/public/fonts/ibm-plex-sans-400-normal.ttf b/public/fonts/ibm-plex-sans-400-normal.ttf new file mode 100644 --- /dev/null +++ b/public/fonts/ibm-plex-sans-400-normal.ttf diff --git a/public/fonts/ibm-plex-sans-700-italic.ttf b/public/fonts/ibm-plex-sans-700-italic.ttf new file mode 100644 --- /dev/null +++ b/public/fonts/ibm-plex-sans-700-italic.ttf diff --git a/public/fonts/ibm-plex-sans-700-normal.ttf b/public/fonts/ibm-plex-sans-700-normal.ttf new file mode 100644 --- /dev/null +++ b/public/fonts/ibm-plex-sans-700-normal.ttf diff --git a/public/fonts/ibm-plex-serif-400-italic.ttf b/public/fonts/ibm-plex-serif-400-italic.ttf new file mode 100644 --- /dev/null +++ b/public/fonts/ibm-plex-serif-400-italic.ttf diff --git a/public/fonts/ibm-plex-serif-400-normal.ttf b/public/fonts/ibm-plex-serif-400-normal.ttf new file mode 100644 --- /dev/null +++ b/public/fonts/ibm-plex-serif-400-normal.ttf diff --git a/public/fonts/ibm-plex-serif-700-italic.ttf b/public/fonts/ibm-plex-serif-700-italic.ttf new file mode 100644 --- /dev/null +++ b/public/fonts/ibm-plex-serif-700-italic.ttf diff --git a/public/fonts/ibm-plex-serif-700-normal.ttf b/public/fonts/ibm-plex-serif-700-normal.ttf new file mode 100644 --- /dev/null +++ b/public/fonts/ibm-plex-serif-700-normal.ttf diff --git a/public/fonts/monaspace-argon-400-italic.otf b/public/fonts/monaspace-argon-400-italic.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-argon-400-italic.otf diff --git a/public/fonts/monaspace-argon-400-normal.otf b/public/fonts/monaspace-argon-400-normal.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-argon-400-normal.otf diff --git a/public/fonts/monaspace-argon-700-italic.otf b/public/fonts/monaspace-argon-700-italic.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-argon-700-italic.otf diff --git a/public/fonts/monaspace-argon-700-normal.otf b/public/fonts/monaspace-argon-700-normal.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-argon-700-normal.otf diff --git a/public/fonts/monaspace-krypton-400-italic.otf b/public/fonts/monaspace-krypton-400-italic.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-krypton-400-italic.otf diff --git a/public/fonts/monaspace-krypton-400-normal.otf b/public/fonts/monaspace-krypton-400-normal.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-krypton-400-normal.otf diff --git a/public/fonts/monaspace-krypton-700-italic.otf b/public/fonts/monaspace-krypton-700-italic.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-krypton-700-italic.otf diff --git a/public/fonts/monaspace-krypton-700-normal.otf b/public/fonts/monaspace-krypton-700-normal.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-krypton-700-normal.otf diff --git a/public/fonts/monaspace-neon-400-italic.otf b/public/fonts/monaspace-neon-400-italic.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-neon-400-italic.otf diff --git a/public/fonts/monaspace-neon-400-normal.otf b/public/fonts/monaspace-neon-400-normal.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-neon-400-normal.otf diff --git a/public/fonts/monaspace-neon-700-italic.otf b/public/fonts/monaspace-neon-700-italic.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-neon-700-italic.otf diff --git a/public/fonts/monaspace-neon-700-normal.otf b/public/fonts/monaspace-neon-700-normal.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-neon-700-normal.otf diff --git a/public/fonts/monaspace-radon-400-italic.otf b/public/fonts/monaspace-radon-400-italic.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-radon-400-italic.otf diff --git a/public/fonts/monaspace-radon-400-normal.otf b/public/fonts/monaspace-radon-400-normal.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-radon-400-normal.otf diff --git a/public/fonts/monaspace-radon-700-italic.otf b/public/fonts/monaspace-radon-700-italic.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-radon-700-italic.otf diff --git a/public/fonts/monaspace-radon-700-normal.otf b/public/fonts/monaspace-radon-700-normal.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-radon-700-normal.otf diff --git a/public/fonts/monaspace-xenon-400-italic.otf b/public/fonts/monaspace-xenon-400-italic.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-xenon-400-italic.otf diff --git a/public/fonts/monaspace-xenon-400-normal.otf b/public/fonts/monaspace-xenon-400-normal.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-xenon-400-normal.otf diff --git a/public/fonts/monaspace-xenon-700-italic.otf b/public/fonts/monaspace-xenon-700-italic.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-xenon-700-italic.otf diff --git a/public/fonts/monaspace-xenon-700-normal.otf b/public/fonts/monaspace-xenon-700-normal.otf new file mode 100644 --- /dev/null +++ b/public/fonts/monaspace-xenon-700-normal.otf diff --git a/src/__tests__/Editor.test.tsx b/src/__tests__/Editor.test.tsx --- a/src/__tests__/Editor.test.tsx +++ b/src/__tests__/Editor.test.tsx @@ -44,6 +44,16 @@ expect(container.querySelector(".cm-lineNumbers")).not.toBeInTheDocument(); }); + it("should enable text wrapping by default", () => { + const { container } = render(); + expect(container.querySelector(".cm-lineWrapping")).toBeInTheDocument(); + }); + + it("should disable text wrapping when disabled via prop", () => { + const { container } = render(); + expect(container.querySelector(".cm-lineWrapping")).not.toBeInTheDocument(); + }); + it("should apply custom font family and size variables", () => { render(); const container = screen.getByTestId("editor-container"); @@ -181,6 +191,18 @@ expect(secondEditorRoot).toBeInTheDocument(); expect(secondEditorRoot).not.toBe(firstEditorRoot); expect(container.querySelector(".cm-lineNumbers")).not.toBeInTheDocument(); + expect(container.querySelector(".cm-content")).toHaveTextContent("Persistent"); + }); + + it("recreates the editor view when text wrapping changes", () => { + const { container, rerender } = render(); + const firstEditorRoot = container.querySelector(".cm-editor"); + rerender(); + + const secondEditorRoot = container.querySelector(".cm-editor"); + expect(secondEditorRoot).toBeInTheDocument(); + expect(secondEditorRoot).not.toBe(firstEditorRoot); + expect(container.querySelector(".cm-lineWrapping")).not.toBeInTheDocument(); expect(container.querySelector(".cm-content")).toHaveTextContent("Persistent"); }); diff --git a/src/__tests__/appStore.test.ts b/src/__tests__/appStore.test.ts --- a/src/__tests__/appStore.test.ts +++ b/src/__tests__/appStore.test.ts @@ -151,6 +151,7 @@ expect(layoutState.current.topBarsCollapsed).toBeFalsy(); expect(layoutState.current.statusBarCollapsed).toBeFalsy(); expect(layoutState.current.lineNumbersVisible).toBeTruthy(); + expect(layoutState.current.textWrappingEnabled).toBeTruthy(); expect(layoutState.current.syntaxHighlightingEnabled).toBeTruthy(); expect(layoutState.current.editorFontSize).toBe(16); expect(layoutState.current.editorFontFamily).toBe("IBM Plex Mono"); @@ -162,6 +163,7 @@ layoutActions.current.toggleTabBarCollapsed(); layoutActions.current.toggleStatusBarCollapsed(); layoutActions.current.toggleLineNumbersVisible(); + layoutActions.current.toggleTextWrappingEnabled(); layoutActions.current.toggleSyntaxHighlightingEnabled(); layoutActions.current.setEditorFontSize(20); layoutActions.current.setEditorFontFamily("Monaspace Neon"); @@ -175,6 +177,7 @@ expect(layoutState.current.topBarsCollapsed).toBeTruthy(); expect(layoutState.current.statusBarCollapsed).toBeTruthy(); expect(layoutState.current.lineNumbersVisible).toBeFalsy(); + expect(layoutState.current.textWrappingEnabled).toBeFalsy(); expect(layoutState.current.syntaxHighlightingEnabled).toBeFalsy(); expect(layoutState.current.editorFontSize).toBe(20); expect(layoutState.current.editorFontFamily).toBe("Monaspace Neon"); diff --git a/src/__tests__/pdfFonts.test.ts b/src/__tests__/pdfFonts.test.ts new file mode 100644 --- /dev/null +++ b/src/__tests__/pdfFonts.test.ts @@ -0,0 +1,74 @@ +import { describePdfFont, ensurePdfFontRegistered } from "$pdf/fonts"; +import { Font } from "@react-pdf/renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-pdf/renderer", () => ({ + Font: { + register: vi.fn(), + load: vi.fn(async () => {}), + }, +})); + +describe("pdf fonts", () => { + const fetchMock = vi.fn(async () => + new Response(new Uint8Array([0x00, 0x01, 0x00, 0x00, 0x00, 0x00]), { + status: 200, + headers: { "content-type": "font/ttf" }, + })); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("registers static IBM Plex Sans with italic support", async () => { + await ensurePdfFontRegistered("IBM Plex Sans Variable", "custom"); + + expect(Font.register).toHaveBeenCalledTimes(1); + expect(Font.register).toHaveBeenCalledWith({ + family: "IBMPlexSans", + fonts: expect.arrayContaining([ + expect.objectContaining({ + fontWeight: "normal", + fontStyle: "italic", + src: expect.stringMatching(/^data:font\/ttf;base64,/), + }), + expect.objectContaining({ + fontWeight: "bold", + fontStyle: "italic", + src: expect.stringMatching(/^data:font\/ttf;base64,/), + }), + ]), + }); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("/fonts/ibm-plex-sans-400-normal.ttf"), { + method: "GET", + }); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("/fonts/ibm-plex-sans-700-italic.ttf"), { + method: "GET", + }); + }); + + it("describes custom font sources for export diagnostics", () => { + const description = describePdfFont("Monaspace Xenon", "custom"); + + expect(description.family).toBe("MonaspaceXenon"); + expect(description.sources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "monaspace-xenon-400-italic.otf", + fontStyle: "italic", + fontWeight: "normal", + }), + expect.objectContaining({ + file: "monaspace-xenon-700-italic.otf", + fontStyle: "italic", + fontWeight: "bold", + }), + ]), + ); + }); +}); diff --git a/src/__tests__/ports.test.ts b/src/__tests__/ports.test.ts --- a/src/__tests__/ports.test.ts +++ b/src/__tests__/ports.test.ts @@ -664,6 +664,7 @@ top_bars_collapsed: false, status_bar_collapsed: true, line_numbers_visible: false, + text_wrapping_enabled: true, syntax_highlighting_enabled: false, editor_font_size: 18, editor_font_family: "Monaspace Neon", @@ -680,6 +681,7 @@ top_bars_collapsed: false, status_bar_collapsed: true, line_numbers_visible: false, + text_wrapping_enabled: true, syntax_highlighting_enabled: false, editor_font_size: 18, editor_font_family: "Monaspace Neon", diff --git a/src/__tests__/usePdfExport.test.tsx b/src/__tests__/usePdfExport.test.tsx new file mode 100644 --- /dev/null +++ b/src/__tests__/usePdfExport.test.tsx @@ -0,0 +1,129 @@ +import { logger } from "$logger"; +import { DEFAULT_OPTIONS } from "$pdf/constants"; +import { describePdfFont, ensurePdfFontRegistered } from "$pdf/fonts"; +import type { PdfRenderResult } from "$pdf/types"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeFile } from "@tauri-apps/plugin-fs"; +import { pdf } from "@react-pdf/renderer"; +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { usePdfExport } from "../hooks/usePdfExport"; + +vi.mock("$components/pdf/MarkdownPdfDocument", () => ({ + MarkdownPdfDocument: () => null, +})); + +vi.mock("$logger", () => ({ + logger: { + init: vi.fn(), + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("$pdf/fonts", () => ({ + ensurePdfFontRegistered: vi.fn(), + describePdfFont: vi.fn((fontName: string, strategy: string) => ({ + fontName, + strategy, + family: `${fontName}-${strategy}`, + sources: strategy === "custom" ? [{ file: "font.woff", src: "/fonts/font.woff", fontWeight: "normal" }] : [], + })), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + save: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-fs", () => ({ + writeFile: vi.fn(), +})); + +const toBlobMock = vi.fn(); + +vi.mock("@react-pdf/renderer", () => ({ + Font: { register: vi.fn() }, + Document: "Document", + Page: "Page", + Text: "Text", + View: "View", + StyleSheet: { create: (styles: unknown) => styles }, + pdf: vi.fn(() => ({ toBlob: toBlobMock })), +})); + +const renderResult: PdfRenderResult = { + title: "Doc", + word_count: 2, + nodes: [{ type: "paragraph", content: "Hello world" }], +}; + +describe(usePdfExport, () => { + beforeEach(() => { + vi.clearAllMocks(); + toBlobMock.mockReset(); + }); + + it("retries with built-in fonts on any custom render failure", async () => { + toBlobMock + .mockRejectedValueOnce(new Error("Custom font failure without known substrings")) + .mockResolvedValueOnce(new Blob(["pdf-bytes"], { type: "application/pdf" })); + vi.mocked(save).mockResolvedValue("/tmp/output.pdf"); + + const { result } = renderHook(() => usePdfExport()); + + let didExport = false; + await act(async () => { + didExport = await result.current.exportPdf(renderResult, DEFAULT_OPTIONS, "IBM Plex Sans Variable"); + }); + + expect(didExport).toBeTruthy(); + expect(pdf).toHaveBeenCalledTimes(2); + expect(vi.mocked(ensurePdfFontRegistered)).toHaveBeenNthCalledWith(1, "IBM Plex Sans Variable", "custom"); + expect(vi.mocked(ensurePdfFontRegistered)).toHaveBeenNthCalledWith(2, "IBM Plex Mono", "custom"); + expect(vi.mocked(ensurePdfFontRegistered)).toHaveBeenNthCalledWith(3, "IBM Plex Sans Variable", "builtin"); + expect(vi.mocked(ensurePdfFontRegistered)).toHaveBeenNthCalledWith(4, "IBM Plex Mono", "builtin"); + expect(vi.mocked(writeFile)).toHaveBeenCalledOnce(); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "PDF export custom font render failed; retrying with built-in fonts", + expect.objectContaining({ + editorFontFamily: "IBM Plex Sans Variable", + error: expect.objectContaining({ message: "Custom font failure without known substrings" }), + }), + ); + expect(vi.mocked(logger.warn)).toHaveBeenCalledWith( + "PDF export completed with built-in fonts after custom font failure", + expect.objectContaining({ + customError: expect.objectContaining({ message: "Custom font failure without known substrings" }), + }), + ); + expect(vi.mocked(describePdfFont)).toHaveBeenCalled(); + }); + + it("fails with combined diagnostics when both custom and built-in rendering fail", async () => { + toBlobMock.mockRejectedValueOnce(new Error("custom failed")).mockRejectedValueOnce(new Error("builtin failed")); + + const { result } = renderHook(() => usePdfExport()); + + await act(async () => { + await expect(result.current.exportPdf(renderResult, DEFAULT_OPTIONS, "IBM Plex Sans Variable")).rejects.toThrow( + "Failed to render PDF using both custom and built-in fonts. Check logs for details.", + ); + }); + + expect(vi.mocked(save)).not.toHaveBeenCalled(); + expect(result.current.state.isExporting).toBeFalsy(); + expect(result.current.state.error).toBe( + "Failed to render PDF using both custom and built-in fonts. Check logs for details.", + ); + expect(vi.mocked(logger.error)).toHaveBeenCalledWith( + "PDF export failed with both custom and built-in fonts", + expect.objectContaining({ + customError: expect.objectContaining({ message: "custom failed" }), + builtinError: expect.objectContaining({ message: "builtin failed" }), + }), + ); + }); +}); diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -17,6 +17,7 @@ theme?: EditorTheme; disabled?: boolean; showLineNumbers?: boolean; + textWrappingEnabled?: boolean; syntaxHighlightingEnabled?: boolean; fontSize?: number; fontFamily?: EditorFontFamily; @@ -36,6 +37,7 @@ theme: EditorTheme; disabled: boolean; showLineNumbers: boolean; + textWrappingEnabled: boolean; syntaxHighlightingEnabled: boolean; placeholder?: string; updateListener: ReturnType; @@ -55,8 +57,17 @@ }; function createEditorState( - { doc, theme, disabled, showLineNumbers, syntaxHighlightingEnabled, placeholder, updateListener, onSave }: - CreateEditorStateOptions, + { + doc, + theme, + disabled, + showLineNumbers, + textWrappingEnabled, + syntaxHighlightingEnabled, + placeholder, + updateListener, + onSave, + }: CreateEditorStateOptions, ): CMEditorState { const themeExtension = theme === "dark" ? oxocarbonDark : oxocarbonLight; @@ -73,6 +84,7 @@ doc, extensions: [ ...(showLineNumbers ? [lineNumbers()] : []), + ...(textWrappingEnabled ? [EditorView.lineWrapping] : []), history(), ...(syntaxHighlightingEnabled ? [markdown({ base: markdownLanguage, codeLanguages: [], addKeymap: true })] : []), themeExtension, @@ -91,6 +103,7 @@ theme = "dark", disabled = false, showLineNumbers = true, + textWrappingEnabled = true, syntaxHighlightingEnabled = true, fontSize = 16, fontFamily = "IBM Plex Mono", @@ -109,7 +122,14 @@ const debounceMsRef = useRef(debounceMs); const onChangeTimeoutRef = useRef | null>(null); const initialTextRef = useRef(initialText); - const presentationRef = useRef({ theme, disabled, showLineNumbers, syntaxHighlightingEnabled, placeholder }); + const presentationRef = useRef({ + theme, + disabled, + showLineNumbers, + textWrappingEnabled, + syntaxHighlightingEnabled, + placeholder, + }); const [isReady, setIsReady] = useState(false); useEffect(() => { @@ -168,6 +188,7 @@ theme: currentPresentation.theme, disabled: currentPresentation.disabled, showLineNumbers: currentPresentation.showLineNumbers, + textWrappingEnabled: currentPresentation.textWrappingEnabled, syntaxHighlightingEnabled: currentPresentation.syntaxHighlightingEnabled, placeholder: currentPresentation.placeholder, updateListener: createUpdateListener(), @@ -214,13 +235,21 @@ previousPresentation.theme === theme && previousPresentation.disabled === disabled && previousPresentation.showLineNumbers === showLineNumbers + && previousPresentation.textWrappingEnabled === textWrappingEnabled && previousPresentation.syntaxHighlightingEnabled === syntaxHighlightingEnabled && previousPresentation.placeholder === placeholder ) { return; } - presentationRef.current = { theme, disabled, showLineNumbers, syntaxHighlightingEnabled, placeholder }; + presentationRef.current = { + theme, + disabled, + showLineNumbers, + textWrappingEnabled, + syntaxHighlightingEnabled, + placeholder, + }; const view = viewRef.current; if (!view) { @@ -239,7 +268,7 @@ } viewRef.current = nextView; - }, [createView, disabled, placeholder, showLineNumbers, syntaxHighlightingEnabled, theme]); + }, [createView, disabled, placeholder, showLineNumbers, textWrappingEnabled, syntaxHighlightingEnabled, theme]); useEffect(() => { const view = viewRef.current; diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -185,7 +185,7 @@ shortcut="Ctrl+F" /> -
+
{onExportPdf && ( { + if (depth > 4) { + return { name: "TruncatedError", message: "Error cause chain exceeded depth limit" }; + } + + if (error instanceof Error) { + const withFields = error as Error & { code?: string; details?: unknown; cause?: unknown }; + const serialized: SerializedError = { name: error.name, message: error.message, stack: error.stack }; + + if (withFields.code) { + serialized.code = withFields.code; + } + if (withFields.details) { + serialized.details = withFields.details; + } + if (withFields.cause) { + serialized.cause = serializeError(withFields.cause, depth + 1); + } + return serialized; + } + + return { name: "UnknownError", message: String(error) }; +}; + +const runtimeContext = () => ({ + href: globalThis.location?.href ?? null, + origin: globalThis.location?.origin ?? null, + userAgent: globalThis.navigator?.userAgent ?? null, +}); + export function usePdfExport(): UsePdfExportReturn { const [state, setState] = useState(initialState); + + const renderPdfBlob = useCallback( + async ( + result: PdfRenderResult, + options: PdfExportOptions, + editorFontFamily: EditorFontFamily, + strategy: FontStrategy, + ) => { + const bodyFont = describePdfFont(editorFontFamily, strategy); + const codeFont = describePdfFont("IBM Plex Mono", strategy); + logger.debug("PDF export render attempt started", { strategy, bodyFont, codeFont, runtime: runtimeContext() }); + + await ensurePdfFontRegistered(editorFontFamily, strategy); + await ensurePdfFontRegistered("IBM Plex Mono", strategy); + + const blob = await pdf( + , + ).toBlob(); + + logger.debug("PDF export render attempt completed", { strategy, outputSizeBytes: blob.size }); + + return blob; + }, + [], + ); const exportPdf = useCallback( async (result: PdfRenderResult, options: PdfExportOptions, editorFontFamily: EditorFontFamily) => { setState({ isExporting: true, error: null }); try { - const blob = await pdf( - , - ).toBlob(); + let blob: Blob; + let customRenderError: unknown = null; + try { + blob = await renderPdfBlob(result, options, editorFontFamily, "custom"); + } catch (initialError) { + customRenderError = initialError; + logger.warn("PDF export custom font render failed; retrying with built-in fonts", { + editorFontFamily, + error: serializeError(initialError), + }); + + try { + blob = await renderPdfBlob(result, options, editorFontFamily, "builtin"); + logger.warn("PDF export completed with built-in fonts after custom font failure", { + editorFontFamily, + customError: serializeError(customRenderError), + }); + } catch (builtinError) { + logger.error("PDF export failed with both custom and built-in fonts", { + editorFontFamily, + customError: serializeError(customRenderError), + builtinError: serializeError(builtinError), + }); + throw new Error("Failed to render PDF using both custom and built-in fonts. Check logs for details.", { + cause: builtinError, + }); + } + } const arrayBuffer = await blob.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); @@ -40,21 +132,27 @@ const filePath = await save({ filters: [{ name: "PDF", extensions: ["pdf"] }], defaultPath: defaultFileName }); if (!filePath) { + logger.info("PDF export canceled before writing file", { + editorFontFamily, + fallbackUsed: customRenderError !== null, + }); setState({ isExporting: false, error: null }); return false; } await writeFile(filePath, uint8Array); + logger.info("PDF export completed", { filePath, editorFontFamily, fallbackUsed: customRenderError !== null }); setState({ isExporting: false, error: null }); return true; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Failed to export PDF"; + logger.error("PDF export failed", { editorFontFamily, error: serializeError(err) }); setState({ isExporting: false, error: errorMessage }); throw err; } }, - [], + [renderPdfBlob], ); const reset = useCallback(() => { diff --git a/src/pdf/errors.ts b/src/pdf/errors.ts new file mode 100644 --- /dev/null +++ b/src/pdf/errors.ts @@ -0,0 +1,32 @@ +// oxlint-disable max-classes-per-file + +import type { PdfFontResolution, PdfFontSourceDescriptor } from "./types"; + +export class PdfFontSourceFetchError extends Error { + readonly code = "PDF_FONT_SOURCE_FETCH_FAILED"; + readonly details: { source: PdfFontSourceDescriptor; status?: number; statusText?: string; contentType?: string }; + + constructor( + source: PdfFontSourceDescriptor, + message: string, + extras?: { status?: number; statusText?: string; contentType?: string; cause?: unknown }, + ) { + super(message, { cause: extras?.cause instanceof Error ? extras.cause : undefined }); + this.name = "PdfFontSourceFetchError"; + this.details = { source, status: extras?.status, statusText: extras?.statusText, contentType: extras?.contentType }; + } +} + +export class PdfFontRegistrationError extends Error { + readonly code = "PDF_FONT_REGISTRATION_FAILED"; + readonly details: PdfFontResolution; + + constructor(details: PdfFontResolution, cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + super(`Failed to register PDF font "${details.fontName}" (${details.family}): ${message}`, { + cause: cause instanceof Error ? cause : undefined, + }); + this.name = "PdfFontRegistrationError"; + this.details = details; + } +} diff --git a/src/pdf/fonts.ts b/src/pdf/fonts.ts --- a/src/pdf/fonts.ts +++ b/src/pdf/fonts.ts @@ -1,90 +1,273 @@ +// oxlint-disable no-await-in-loop + import { Font } from "@react-pdf/renderer"; -import { FontName } from "./types"; +import { PdfFontRegistrationError, PdfFontSourceFetchError } from "./errors"; +import type { + FontConfig, + FontName, + FontPayloadFormat, + FontStrategy, + PdfFontResolution, + PdfFontSourceDescriptor, +} from "./types"; -type FontWeight = - | number - | "thin" - | "ultralight" - | "light" - | "normal" - | "medium" - | "semibold" - | "bold" - | "ultrabold" - | "heavy"; +const staticFont = (filename: string): string => { + const relativePath = `/fonts/${filename}`; + const origin = globalThis.location?.origin; + if (!origin || origin === "null") { + return relativePath; + } + return `${origin}${relativePath}`; +}; -type FontSource = { src: string; fontWeight?: FontWeight }; - -const FONT_PATHS: Record = { +const FONT_PATHS: Record = { "IBM Plex Mono": { family: "IBMPlexMono", - fonts: [{ src: "asset:///fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2", fontWeight: "normal" }, { - src: "asset:///fonts/ibm-plex-mono/ibm-plex-mono-latin-700-normal.woff2", - fontWeight: "bold", - }], + files: [ + { file: "ibm-plex-mono-400-normal.ttf", fontWeight: "normal", fontStyle: "normal" }, + { file: "ibm-plex-mono-400-italic.ttf", fontWeight: "normal", fontStyle: "italic" }, + { file: "ibm-plex-mono-700-normal.ttf", fontWeight: "bold", fontStyle: "normal" }, + { file: "ibm-plex-mono-700-italic.ttf", fontWeight: "bold", fontStyle: "italic" }, + ], }, "IBM Plex Sans Variable": { family: "IBMPlexSans", - fonts: [{ src: "asset:///fonts/ibm-plex-sans/ibm-plex-sans-latin-wght-normal.woff2", fontWeight: "normal" }], + files: [ + { file: "ibm-plex-sans-400-normal.ttf", fontWeight: "normal", fontStyle: "normal" }, + { file: "ibm-plex-sans-400-italic.ttf", fontWeight: "normal", fontStyle: "italic" }, + { file: "ibm-plex-sans-700-normal.ttf", fontWeight: "bold", fontStyle: "normal" }, + { file: "ibm-plex-sans-700-italic.ttf", fontWeight: "bold", fontStyle: "italic" }, + ], }, "IBM Plex Serif": { family: "IBMPlexSerif", - fonts: [{ src: "asset:///fonts/ibm-plex-serif/ibm-plex-serif-latin-400-normal.woff2", fontWeight: "normal" }, { - src: "asset:///fonts/ibm-plex-serif/ibm-plex-serif-latin-700-normal.woff2", - fontWeight: "bold", - }], + files: [ + { file: "ibm-plex-serif-400-normal.ttf", fontWeight: "normal", fontStyle: "normal" }, + { file: "ibm-plex-serif-400-italic.ttf", fontWeight: "normal", fontStyle: "italic" }, + { file: "ibm-plex-serif-700-normal.ttf", fontWeight: "bold", fontStyle: "normal" }, + { file: "ibm-plex-serif-700-italic.ttf", fontWeight: "bold", fontStyle: "italic" }, + ], }, "Monaspace Argon": { family: "MonaspaceArgon", - fonts: [{ src: "asset:///fonts/monaspace-argon/monaspace-argon-latin-400-normal.woff2", fontWeight: "normal" }], + files: [ + { file: "monaspace-argon-400-normal.otf", fontWeight: "normal", fontStyle: "normal" }, + { file: "monaspace-argon-400-italic.otf", fontWeight: "normal", fontStyle: "italic" }, + { file: "monaspace-argon-700-normal.otf", fontWeight: "bold", fontStyle: "normal" }, + { file: "monaspace-argon-700-italic.otf", fontWeight: "bold", fontStyle: "italic" }, + ], }, "Monaspace Krypton": { family: "MonaspaceKrypton", - fonts: [{ src: "asset:///fonts/monaspace-krypton/monaspace-krypton-latin-400-normal.woff2", fontWeight: "normal" }], + files: [ + { file: "monaspace-krypton-400-normal.otf", fontWeight: "normal", fontStyle: "normal" }, + { file: "monaspace-krypton-400-italic.otf", fontWeight: "normal", fontStyle: "italic" }, + { file: "monaspace-krypton-700-normal.otf", fontWeight: "bold", fontStyle: "normal" }, + { file: "monaspace-krypton-700-italic.otf", fontWeight: "bold", fontStyle: "italic" }, + ], }, "Monaspace Neon": { family: "MonaspaceNeon", - fonts: [{ src: "asset:///fonts/monaspace-neon/monaspace-neon-latin-400-normal.woff2", fontWeight: "normal" }], + files: [ + { file: "monaspace-neon-400-normal.otf", fontWeight: "normal", fontStyle: "normal" }, + { file: "monaspace-neon-400-italic.otf", fontWeight: "normal", fontStyle: "italic" }, + { file: "monaspace-neon-700-normal.otf", fontWeight: "bold", fontStyle: "normal" }, + { file: "monaspace-neon-700-italic.otf", fontWeight: "bold", fontStyle: "italic" }, + ], }, "Monaspace Radon": { family: "MonaspaceRadon", - fonts: [{ src: "asset:///fonts/monaspace-radon/monaspace-radon-latin-400-normal.woff2", fontWeight: "normal" }], + files: [ + { file: "monaspace-radon-400-normal.otf", fontWeight: "normal", fontStyle: "normal" }, + { file: "monaspace-radon-400-italic.otf", fontWeight: "normal", fontStyle: "italic" }, + { file: "monaspace-radon-700-normal.otf", fontWeight: "bold", fontStyle: "normal" }, + { file: "monaspace-radon-700-italic.otf", fontWeight: "bold", fontStyle: "italic" }, + ], }, "Monaspace Xenon": { family: "MonaspaceXenon", - fonts: [{ src: "asset:///fonts/monaspace-xenon/monaspace-xenon-latin-400-normal.woff2", fontWeight: "normal" }], + files: [ + { file: "monaspace-xenon-400-normal.otf", fontWeight: "normal", fontStyle: "normal" }, + { file: "monaspace-xenon-400-italic.otf", fontWeight: "normal", fontStyle: "italic" }, + { file: "monaspace-xenon-700-normal.otf", fontWeight: "bold", fontStyle: "normal" }, + { file: "monaspace-xenon-700-italic.otf", fontWeight: "bold", fontStyle: "italic" }, + ], }, }; const registeredFonts = new Set(); -export function registerPdfFont(fontName: FontName): string { - const config = FONT_PATHS[fontName]; +const preloadedFontSources = new Map(); - if (!config) { - throw new Error(`Unknown font: ${fontName}. Available fonts: ${Object.keys(FONT_PATHS).join(", ")}`); - } +const BUILTIN_FONT_FAMILY_MAP: Record = { + "IBM Plex Mono": "Courier", + "IBM Plex Sans Variable": "Helvetica", + "IBM Plex Serif": "Times-Roman", + "Monaspace Argon": "Courier", + "Monaspace Krypton": "Courier", + "Monaspace Neon": "Courier", + "Monaspace Radon": "Courier", + "Monaspace Xenon": "Courier", +}; - if (!registeredFonts.has(config.family)) { - Font.register({ family: config.family, fonts: config.fonts }); - registeredFonts.add(config.family); - } +const getBuiltinFamily = (fontName: FontName): string => BUILTIN_FONT_FAMILY_MAP[fontName] ?? "Helvetica"; - return config.family; -} - -export function getPdfFontFamily(fontName: FontName): string { +const getCustomConfig = (fontName: FontName): FontConfig => { if (!(fontName in FONT_PATHS)) { - return registerPdfFont("IBM Plex Mono"); + return FONT_PATHS["IBM Plex Mono"]; + } + return FONT_PATHS[fontName]; +}; + +const toHex = (value: number): string => value.toString(16).padStart(2, "0"); + +const getFormatFromFilename = (filename: string): FontPayloadFormat | null => { + const lower = filename.toLowerCase(); + if (lower.endsWith(".woff")) { + return "woff"; + } + if (lower.endsWith(".ttf")) { + return "ttf"; + } + if (lower.endsWith(".otf")) { + return "otf"; + } + return null; +}; + +const detectFormatFromHeader = (bytes: Uint8Array): FontPayloadFormat | null => { + if (bytes.length < 4) { + return null; + } + if (bytes[0] === 0x77 && bytes[1] === 0x4f && bytes[2] === 0x46 && bytes[3] === 0x46) { + return "woff"; + } + if (bytes[0] === 0x00 && bytes[1] === 0x01 && bytes[2] === 0x00 && bytes[3] === 0x00) { + return "ttf"; + } + if (bytes[0] === 0x4f && bytes[1] === 0x54 && bytes[2] === 0x54 && bytes[3] === 0x4f) { + return "otf"; + } + return null; +}; + +const toBase64 = (bytes: Uint8Array): string => { + let binary = ""; + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + const chunk = bytes.subarray(index, index + chunkSize); + binary += String.fromCodePoint(...chunk); + } + return btoa(binary); +}; + +const preloadFontSource = async (source: PdfFontSourceDescriptor): Promise => { + const cached = preloadedFontSources.get(source.src); + if (cached) { + return cached; } - return registerPdfFont(fontName); + let response: Response; + try { + response = await fetch(source.src, { method: "GET" }); + } catch (error) { + throw new PdfFontSourceFetchError(source, `Failed to fetch font source: ${source.src}`, { cause: error }); + } + + const contentType = response.headers.get("content-type") ?? undefined; + if (!response.ok) { + throw new PdfFontSourceFetchError( + source, + `Font source request failed (${response.status} ${response.statusText}) for ${source.src}`, + { status: response.status, statusText: response.statusText, contentType }, + ); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + const detectedFormat = detectFormatFromHeader(bytes); + const expectedFormat = getFormatFromFilename(source.file); + if (!detectedFormat || (expectedFormat && detectedFormat !== expectedFormat)) { + const signature = bytes.length >= 4 + ? `${toHex(bytes[0])} ${toHex(bytes[1])} ${toHex(bytes[2])} ${toHex(bytes[3])}` + : "too-short"; + throw new PdfFontSourceFetchError( + source, + `Font payload format mismatch (${source.src}, expected: ${expectedFormat ?? "unknown"}, detected: ${ + detectedFormat ?? "unknown" + }, signature: ${signature})`, + { status: response.status, statusText: response.statusText, contentType }, + ); + } + + const mimeType = detectedFormat === "woff" ? "font/woff" : detectedFormat === "ttf" ? "font/ttf" : "font/otf"; + const dataUrl = `data:${mimeType};base64,${toBase64(bytes)}`; + preloadedFontSources.set(source.src, dataUrl); + return dataUrl; +}; + +export function describePdfFont(fontName: FontName, strategy: FontStrategy = "custom"): PdfFontResolution { + if (strategy === "builtin") { + return { fontName, strategy, family: getBuiltinFamily(fontName), sources: [] }; + } + + const config = getCustomConfig(fontName); + return { + fontName, + strategy, + family: config.family, + sources: config.files.map((file) => ({ + file: file.file, + src: staticFont(file.file), + fontWeight: file.fontWeight, + fontStyle: file.fontStyle, + })), + }; } -export function registerAllPdfFonts(): void { - for (const [fontName] of Object.entries(FONT_PATHS)) { - registerPdfFont(fontName as FontName); +export async function ensurePdfFontRegistered(fontName: FontName, strategy: FontStrategy = "custom"): Promise { + const resolution = describePdfFont(fontName, strategy); + if (strategy === "builtin") { + return resolution.family; + } + + if (!registeredFonts.has(resolution.family)) { + try { + const resolvedSources = await Promise.all( + resolution.sources.map(async (source) => ({ ...source, resolvedSrc: await preloadFontSource(source) })), + ); + + Font.register({ + family: resolution.family, + fonts: resolvedSources.map((source) => ({ + src: source.resolvedSrc, + fontWeight: source.fontWeight, + fontStyle: source.fontStyle, + })), + }); + + for (const source of resolvedSources) { + await Font.load({ + fontFamily: resolution.family, + fontWeight: source.fontWeight ?? "normal", + fontStyle: source.fontStyle ?? "normal", + }); + } + + registeredFonts.add(resolution.family); + } catch (error) { + throw new PdfFontRegistrationError(resolution, error); + } + } + + return resolution.family; +} + +export const getPdfFontFamily = (f: FontName, s: FontStrategy = "custom"): string => describePdfFont(f, s).family; + +export async function registerAllPdfFonts(): Promise { + for (const fontName in FONT_PATHS) { + await ensurePdfFontRegistered(fontName as FontName); } } -export const getCodeFontFamily = (): string => getPdfFontFamily("IBM Plex Mono"); +export const getCodeFontFamily = (s: FontStrategy = "custom"): string => getPdfFontFamily("IBM Plex Mono", s); diff --git a/src/pdf/types.ts b/src/pdf/types.ts --- a/src/pdf/types.ts +++ b/src/pdf/types.ts @@ -39,3 +39,34 @@ | "Monaspace Neon" | "Monaspace Radon" | "Monaspace Xenon"; + +export type FontPayloadFormat = "woff" | "ttf" | "otf"; + +export type PdfFontSourceDescriptor = { file: string; src: string; fontWeight?: FontWeight; fontStyle?: FontStyle }; + +export type PdfFontResolution = { + fontName: FontName; + strategy: FontStrategy; + family: string; + sources: PdfFontSourceDescriptor[]; +}; + +export type FontWeight = + | number + | "thin" + | "ultralight" + | "light" + | "normal" + | "medium" + | "semibold" + | "bold" + | "ultrabold" + | "heavy"; + +export type FontStyle = "normal" | "italic"; + +export type FontStrategy = "custom" | "builtin"; + +export type FontFile = { file: string; fontWeight?: FontWeight; fontStyle?: FontStyle }; + +export type FontConfig = { family: string; files: FontFile[] }; diff --git a/src/state/appStore.ts b/src/state/appStore.ts --- a/src/state/appStore.ts +++ b/src/state/appStore.ts @@ -15,6 +15,7 @@ topBarsCollapsed: boolean; statusBarCollapsed: boolean; lineNumbersVisible: boolean; + textWrappingEnabled: boolean; syntaxHighlightingEnabled: boolean; editorFontSize: number; editorFontFamily: EditorFontFamily; @@ -34,6 +35,8 @@ toggleStatusBarCollapsed: () => void; setLineNumbersVisible: (value: boolean) => void; toggleLineNumbersVisible: () => void; + setTextWrappingEnabled: (value: boolean) => void; + toggleTextWrappingEnabled: () => void; setSyntaxHighlightingEnabled: (value: boolean) => void; toggleSyntaxHighlightingEnabled: () => void; setEditorFontSize: (value: number) => void; @@ -87,6 +90,7 @@ topBarsCollapsed: false, statusBarCollapsed: false, lineNumbersVisible: true, + textWrappingEnabled: true, syntaxHighlightingEnabled: true, editorFontSize: 16, editorFontFamily: "IBM Plex Mono", @@ -123,6 +127,9 @@ setLineNumbersVisible: (value) => set({ lineNumbersVisible: value }), toggleLineNumbersVisible: () => set((state) => ({ lineNumbersVisible: !state.lineNumbersVisible })), + + setTextWrappingEnabled: (value) => set({ textWrappingEnabled: value }), + toggleTextWrappingEnabled: () => set((state) => ({ textWrappingEnabled: !state.textWrappingEnabled })), setSyntaxHighlightingEnabled: (value) => set({ syntaxHighlightingEnabled: value }), toggleSyntaxHighlightingEnabled: () => @@ -289,6 +296,7 @@ topBarsCollapsed: state.topBarsCollapsed, statusBarCollapsed: state.statusBarCollapsed, lineNumbersVisible: state.lineNumbersVisible, + textWrappingEnabled: state.textWrappingEnabled, syntaxHighlightingEnabled: state.syntaxHighlightingEnabled, editorFontSize: state.editorFontSize, editorFontFamily: state.editorFontFamily, @@ -311,6 +319,8 @@ toggleStatusBarCollapsed: state.toggleStatusBarCollapsed, setLineNumbersVisible: state.setLineNumbersVisible, toggleLineNumbersVisible: state.toggleLineNumbersVisible, + setTextWrappingEnabled: state.setTextWrappingEnabled, + toggleTextWrappingEnabled: state.toggleTextWrappingEnabled, setSyntaxHighlightingEnabled: state.setSyntaxHighlightingEnabled, toggleSyntaxHighlightingEnabled: state.toggleSyntaxHighlightingEnabled, setEditorFontSize: state.setEditorFontSize, diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -38,6 +38,8 @@ #[serde(default = "default_true")] pub line_numbers_visible: bool, #[serde(default = "default_true")] + pub text_wrapping_enabled: bool, + #[serde(default = "default_true")] pub syntax_highlighting_enabled: bool, #[serde(default = "default_editor_font_size")] pub editor_font_size: u16, @@ -52,6 +54,7 @@ top_bars_collapsed: false, status_bar_collapsed: false, line_numbers_visible: true, + text_wrapping_enabled: true, syntax_highlighting_enabled: true, editor_font_size: default_editor_font_size(), editor_font_family: default_editor_font_family(), @@ -1598,6 +1601,7 @@ top_bars_collapsed: false, status_bar_collapsed: true, line_numbers_visible: false, + text_wrapping_enabled: false, syntax_highlighting_enabled: false, editor_font_size: 18, editor_font_family: "Monaspace Neon".to_string(), @@ -1630,6 +1634,7 @@ let loaded = store.ui_layout_get().unwrap(); assert!(loaded.line_numbers_visible); + assert!(loaded.text_wrapping_enabled); assert!(loaded.syntax_highlighting_enabled); assert_eq!(loaded.editor_font_size, 16); assert_eq!(loaded.editor_font_family, "IBM Plex Mono"); diff --git a/src/components/layout/FocusModePanel.tsx b/src/components/layout/FocusModePanel.tsx --- a/src/components/layout/FocusModePanel.tsx +++ b/src/components/layout/FocusModePanel.tsx @@ -13,6 +13,7 @@ charCount: number; selectionCount?: number; lineNumbersVisible: boolean; + textWrappingEnabled: boolean; syntaxHighlightingEnabled: boolean; editorFontSize: number; editorFontFamily: EditorFontFamily; @@ -49,6 +50,7 @@ charCount, selectionCount, lineNumbersVisible, + textWrappingEnabled, syntaxHighlightingEnabled, editorFontSize, editorFontFamily, @@ -68,6 +70,7 @@ initialText={text} theme={theme} showLineNumbers={lineNumbersVisible} + textWrappingEnabled={textWrappingEnabled} syntaxHighlightingEnabled={syntaxHighlightingEnabled} fontSize={editorFontSize} fontFamily={editorFontFamily} diff --git a/src/components/layout/LayoutSettingsPanel.tsx b/src/components/layout/LayoutSettingsPanel.tsx --- a/src/components/layout/LayoutSettingsPanel.tsx +++ b/src/components/layout/LayoutSettingsPanel.tsx @@ -8,6 +8,7 @@ topBarsCollapsed: boolean; statusBarCollapsed: boolean; lineNumbersVisible: boolean; + textWrappingEnabled: boolean; syntaxHighlightingEnabled: boolean; editorFontSize: number; editorFontFamily: EditorFontFamily; @@ -15,6 +16,7 @@ onSetTopBarsCollapsed: (value: boolean) => void; onSetStatusBarCollapsed: (value: boolean) => void; onSetLineNumbersVisible: (value: boolean) => void; + onSetTextWrappingEnabled: (value: boolean) => void; onSetSyntaxHighlightingEnabled: (value: boolean) => void; onSetEditorFontSize: (value: number) => void; onSetEditorFontFamily: (value: EditorFontFamily) => void; @@ -130,6 +132,7 @@ topBarsCollapsed, statusBarCollapsed, lineNumbersVisible, + textWrappingEnabled, syntaxHighlightingEnabled, editorFontSize, editorFontFamily, @@ -137,6 +140,7 @@ onSetTopBarsCollapsed, onSetStatusBarCollapsed, onSetLineNumbersVisible, + onSetTextWrappingEnabled, onSetSyntaxHighlightingEnabled, onSetEditorFontSize, onSetEditorFontFamily, @@ -158,6 +162,10 @@ const toggleLineNumbers = useCallback(() => { onSetLineNumbersVisible(!lineNumbersVisible); }, [lineNumbersVisible, onSetLineNumbersVisible]); + + const toggleTextWrapping = useCallback(() => { + onSetTextWrappingEnabled(!textWrappingEnabled); + }, [onSetTextWrappingEnabled, textWrappingEnabled]); const toggleSyntaxHighlighting = useCallback(() => { onSetSyntaxHighlightingEnabled(!syntaxHighlightingEnabled); @@ -202,6 +210,11 @@ description="Show or hide line numbers in the editor gutter." isVisible={lineNumbersVisible} onToggle={toggleLineNumbers} /> + ; +const PDF_TEXT_COLOR = "#161616"; +const PDF_MUTED_TEXT_COLOR = "#525252"; +const PDF_PAGE_BACKGROUND = "#ffffff"; const toPdfPageSize = (size: PageSize): PageSize => { if (typeof size === "string") { @@ -39,27 +42,37 @@ paddingBottom: options.margins.bottom, paddingLeft: options.margins.left, fontFamily: bodyFont, + color: PDF_TEXT_COLOR, + backgroundColor: PDF_PAGE_BACKGROUND, }, header: { marginBottom: 12, borderBottomWidth: 1, borderBottomColor: "#e0e0e0", paddingBottom: 8 }, - footer: { fontSize: baseFontSize - 3, color: "#666", textAlign: "center" }, + footer: { fontSize: baseFontSize - 3, color: PDF_MUTED_TEXT_COLOR, textAlign: "center" }, content: { flexGrow: 1 }, - title: { fontSize: 24, marginBottom: 20, fontWeight: "bold" }, - heading1: { fontSize: 20, marginTop: 20, marginBottom: 10, fontWeight: "bold" }, - heading2: { fontSize: 16, marginTop: 15, marginBottom: 8, fontWeight: "bold" }, - heading3: { fontSize: 14, marginTop: 12, marginBottom: 6, fontWeight: "bold" }, - paragraph: { fontSize: baseFontSize, lineHeight, marginBottom: 10 }, + title: { fontSize: 24, marginBottom: 20, fontWeight: "bold", color: PDF_TEXT_COLOR }, + heading1: { fontSize: 20, marginTop: 20, marginBottom: 10, fontWeight: "bold", color: PDF_TEXT_COLOR }, + heading2: { fontSize: 16, marginTop: 15, marginBottom: 8, fontWeight: "bold", color: PDF_TEXT_COLOR }, + heading3: { fontSize: 14, marginTop: 12, marginBottom: 6, fontWeight: "bold", color: PDF_TEXT_COLOR }, + paragraph: { fontSize: baseFontSize, lineHeight, marginBottom: 10, color: PDF_TEXT_COLOR }, code: { fontFamily: codeFont, fontSize: baseFontSize - 2, backgroundColor: "#f4f4f4", + color: PDF_TEXT_COLOR, padding: 8, marginBottom: 10, }, list: { marginLeft: 20, marginBottom: 10 }, - listItem: { fontSize: baseFontSize, lineHeight, marginBottom: 4 }, + listItem: { fontSize: baseFontSize, lineHeight, marginBottom: 4, color: PDF_TEXT_COLOR }, blockquote: { marginLeft: 20, paddingLeft: 10, borderLeftWidth: 2, borderLeftColor: "#ddd", marginBottom: 10 }, - blockquoteText: { fontSize: baseFontSize, lineHeight, fontStyle: "italic", color: "#666" }, - footnote: { fontSize: baseFontSize - 2, marginTop: 20, paddingTop: 10, borderTopWidth: 1, borderTopColor: "#ddd" }, + blockquoteText: { fontSize: baseFontSize, lineHeight, fontStyle: "italic", color: PDF_MUTED_TEXT_COLOR }, + footnote: { + fontSize: baseFontSize - 2, + marginTop: 20, + paddingTop: 10, + borderTopWidth: 1, + borderTopColor: "#ddd", + color: PDF_MUTED_TEXT_COLOR, + }, }); type DocumentNodeProps = { node: MarkdownNode; styles: TStyleSheet }; @@ -110,6 +123,7 @@ title?: string; options: PdfExportOptions; editorFontFamily: FontName; + useBuiltinFonts?: boolean; }; const DocumentTitle = ( @@ -144,9 +158,12 @@ }, ) => ; -export const MarkdownPdfDocument = ({ nodes, title, options, editorFontFamily }: MarkdownPdfDocumentProps) => { - const bodyFont = getPdfFontFamily(editorFontFamily); - const codeFont = getCodeFontFamily(); +export const MarkdownPdfDocument = ( + { nodes, title, options, editorFontFamily, useBuiltinFonts = false }: MarkdownPdfDocumentProps, +) => { + const fontStrategy = useBuiltinFonts ? "builtin" : "custom"; + const bodyFont = getPdfFontFamily(editorFontFamily, fontStrategy); + const codeFont = getCodeFontFamily(fontStrategy); const styles = createStyles(bodyFont, codeFont, options, options.fontSize, options.lineHeight); const renderPageNumber = useCallback( ({ pageNumber, totalPages }: { pageNumber: number; totalPages: number }) => `${pageNumber} / ${totalPages}`, diff --git a/src-tauri/resources/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 b/src-tauri/resources/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 deleted file mode 100644 --- a/src-tauri/resources/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +++ /dev/null diff --git a/src-tauri/resources/fonts/ibm-plex-mono/ibm-plex-mono-latin-700-normal.woff2 b/src-tauri/resources/fonts/ibm-plex-mono/ibm-plex-mono-latin-700-normal.woff2 deleted file mode 100644 --- a/src-tauri/resources/fonts/ibm-plex-mono/ibm-plex-mono-latin-700-normal.woff2 +++ /dev/null diff --git a/src-tauri/resources/fonts/ibm-plex-sans/ibm-plex-sans-latin-wght-normal.woff2 b/src-tauri/resources/fonts/ibm-plex-sans/ibm-plex-sans-latin-wght-normal.woff2 deleted file mode 100644 --- a/src-tauri/resources/fonts/ibm-plex-sans/ibm-plex-sans-latin-wght-normal.woff2 +++ /dev/null diff --git a/src-tauri/resources/fonts/ibm-plex-serif/ibm-plex-serif-latin-400-normal.woff2 b/src-tauri/resources/fonts/ibm-plex-serif/ibm-plex-serif-latin-400-normal.woff2 deleted file mode 100644 --- a/src-tauri/resources/fonts/ibm-plex-serif/ibm-plex-serif-latin-400-normal.woff2 +++ /dev/null diff --git a/src-tauri/resources/fonts/ibm-plex-serif/ibm-plex-serif-latin-700-normal.woff2 b/src-tauri/resources/fonts/ibm-plex-serif/ibm-plex-serif-latin-700-normal.woff2 deleted file mode 100644 --- a/src-tauri/resources/fonts/ibm-plex-serif/ibm-plex-serif-latin-700-normal.woff2 +++ /dev/null diff --git a/src-tauri/resources/fonts/monaspace-argon/monaspace-argon-latin-400-normal.woff2 b/src-tauri/resources/fonts/monaspace-argon/monaspace-argon-latin-400-normal.woff2 deleted file mode 100644 --- a/src-tauri/resources/fonts/monaspace-argon/monaspace-argon-latin-400-normal.woff2 +++ /dev/null diff --git a/src-tauri/resources/fonts/monaspace-krypton/monaspace-krypton-latin-400-normal.woff2 b/src-tauri/resources/fonts/monaspace-krypton/monaspace-krypton-latin-400-normal.woff2 deleted file mode 100644 --- a/src-tauri/resources/fonts/monaspace-krypton/monaspace-krypton-latin-400-normal.woff2 +++ /dev/null diff --git a/src-tauri/resources/fonts/monaspace-neon/monaspace-neon-latin-400-normal.woff2 b/src-tauri/resources/fonts/monaspace-neon/monaspace-neon-latin-400-normal.woff2 deleted file mode 100644 --- a/src-tauri/resources/fonts/monaspace-neon/monaspace-neon-latin-400-normal.woff2 +++ /dev/null diff --git a/src-tauri/resources/fonts/monaspace-radon/monaspace-radon-latin-400-normal.woff2 b/src-tauri/resources/fonts/monaspace-radon/monaspace-radon-latin-400-normal.woff2 deleted file mode 100644 --- a/src-tauri/resources/fonts/monaspace-radon/monaspace-radon-latin-400-normal.woff2 +++ /dev/null diff --git a/src-tauri/resources/fonts/monaspace-xenon/monaspace-xenon-latin-400-normal.woff2 b/src-tauri/resources/fonts/monaspace-xenon/monaspace-xenon-latin-400-normal.woff2 deleted file mode 100644 --- a/src-tauri/resources/fonts/monaspace-xenon/monaspace-xenon-latin-400-normal.woff2 +++ /dev/null