diff --git a/docs/superpowers/plans/2026-07-23-monospace-font-embedding-and-naming.md b/docs/superpowers/plans/2026-07-23-monospace-font-embedding-and-naming.md new file mode 100644 index 0000000..7cd880d --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-monospace-font-embedding-and-naming.md @@ -0,0 +1,1581 @@ +# Monospace Font Embedding + Naming Differentiation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port the v4.5.0 monospace font embedding feature from `master` into `react-electron`, then differentiate the dev build's identity (`Markdown Converter React` / `com.concreteinfo.markdownconverter.react`) so it coexists with the installed `markdown-converter` deb. + +**Architecture:** Five small, focused main-process modules (config, pdf header, docx embedder, epub embedder, export css) wired into the existing `exportWithPandoc` pipeline. Settings stored in the same JSON file via the existing `src/main/store.js`. Two new IPC channels exposed through the existing whitelisted preload bridge. Renderer toggles CSS classes on `document.body` to drive `--font-mono-active` tokens. Product rename in `package.json` `build` block plus `src/main/window/index.js` title prefix. + +**Tech Stack:** Node.js 20 + Electron 41 + React 19 + TypeScript 5.5 + Vite 8 + Jest 30 + Vitest 4. New deps: none (we use `archiver`/`unzipper`? — checked, none; we use only Node built-ins + `jszip` which is already a dep via `docx`). + +--- + +## File Structure + +### New files +- `src/main/MonospaceFontConfig.js` — TTF path resolver (`getMonoFontTtfPath`, `ligaturesEnabled`, `getActiveFamily`) +- `src/main/PdfFontHeader.js` — writes `.tex` fontspec header for xelatex/lualatex +- `src/main/DocxFontEmbedder.js` — post-process pandoc DOCX output to embed TTF +- `src/main/EpubFontEmbedder.js` — `withEpubEmbedFontArgs` + `embedEpubFont` +- `src/main/ExportCss.js` — `buildExportCss` returning CSS string with base64 woff2 +- `src/main/settings/monospaceSettings.js` — schema, family map, `safeMonospaceSettings` +- `src/main/ipc/monospace-handlers.js` — registers `get-monospace-settings`, `set-monospace-settings` +- `src/renderer/hooks/use-monospace-classes.ts` — applies body classes from settings +- `tests/unit/main/monospace/MonospaceFontConfig.test.js` +- `tests/unit/main/monospace/monospaceSettings.test.js` +- `tests/unit/main/monospace/PdfFontHeader.test.js` +- `tests/unit/main/monospace/DocxFontEmbedder.test.js` +- `tests/unit/main/monospace/EpubFontEmbedder.test.js` +- `tests/unit/main/monospace/ExportCss.test.js` +- `tests/unit/main/monospace/monospace-handlers.test.js` +- `tests/smoke-e2e-monospace.js` — E2E: ASCII art markdown → PDF/DOCX/EPUB/HTML, grep for font refs + +### Modified files +- `src/main/index.js` — wire monospace modules into `exportWithPandoc` PDF/DOCX/EPUB/HTML branches; register IPC handlers at startup +- `src/main/window/index.js` — title suffix `— React Dev` in dev +- `src/preload.js` — add monospace channels to allowlist + expose on `window.electronAPI.monospace` +- `src/renderer/types/electron.d.ts` — extend `ElectronAPI` with `monospace` namespace +- `src/renderer/lib/ipc.ts` — type-safe wrapper for monospace +- `src/renderer/styles/globals.css` — add `--font-mono-active` / `--font-mono-feature` declarations +- `src/main/updater/migration-transform.js` — add `monospaceFont`, `monospaceLigatures`, `appVariant` defaults +- `package.json` — `name`, `productName`, `appId`, `linux.executableName`; `build.asarUnpack` for `assets/fonts/**`; bundle Fira Code downloader reference in `download-tools` +- `scripts/download-tools.js` — add `downloadFiraCode()` parallel to `downloadPandoc()` +- `App.tsx` (or wherever the root layout mounts hooks) — call `useMonospaceClasses()` once at startup +- `CHANGELOG.md` — 5.1.0 entry + +### Untracked but not new +- `assets/fonts/JetBrainsMono-Regular.ttf` (~274 KB) +- `assets/fonts/JetBrainsMono-Bold.ttf` (~278 KB) +- `assets/fonts/JetBrainsMono-LICENSE.txt` +- `assets/fonts/FiraCode-Regular.ttf` (~300 KB) +- `assets/fonts/FiraCode-Bold.ttf` (~300 KB) +- `assets/fonts/FiraCode-LICENSE.txt` + +--- + +## Task 1: Add monospace settings schema + tests + +**Files:** +- Create: `src/main/settings/monospaceSettings.js` +- Create: `tests/unit/main/monospace/monospaceSettings.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/unit/main/monospace/monospaceSettings.test.js +const { + FAMILY_BY_KEY, + getActiveMonoFont, + isLigaturesEnabled, + safeMonospaceSettings, + DEFAULT_SETTINGS, +} = require('../../../../src/main/settings/monospaceSettings'); + +describe('monospaceSettings', () => { + test('FAMILY_BY_KEY maps jetbrains-mono → "JetBrains Mono"', () => { + expect(FAMILY_BY_KEY['jetbrains-mono']).toBe('JetBrains Mono'); + }); + test('getActiveMonoFont returns family for valid key', () => { + expect(getActiveMonoFont({ monospaceFont: 'fira-code' })).toBe('Fira Code'); + }); + test('getActiveMonoFont falls back to default for unknown key', () => { + expect(getActiveMonoFont({ monospaceFont: 'unknown' })).toBe('JetBrains Mono'); + }); + test('getActiveMonoFont returns default when key missing', () => { + expect(getActiveMonoFont({})).toBe('JetBrains Mono'); + }); + test('isLigaturesEnabled is true only when explicitly true', () => { + expect(isLigaturesEnabled({ monospaceLigatures: true })).toBe(true); + expect(isLigaturesEnabled({ monospaceLigatures: false })).toBe(false); + expect(isLigaturesEnabled({ monospaceLigatures: 'yes' })).toBe(false); + expect(isLigaturesEnabled({})).toBe(false); + }); + test('safeMonospaceSettings rejects unknown fonts', () => { + expect(safeMonospaceSettings({ monospaceFont: 'comic-sans' })).toEqual(DEFAULT_SETTINGS); + }); + test('safeMonospaceSettings coerces ligatures to boolean', () => { + expect(safeMonospaceSettings({ monospaceLigatures: 1 })).toEqual({ + monospaceFont: 'jetbrains-mono', + monospaceLigatures: true, + }); + }); + test('safeMonospaceSettings returns DEFAULT_SETTINGS for empty input', () => { + expect(safeMonospaceSettings({})).toEqual(DEFAULT_SETTINGS); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/unit/main/monospace/monospaceSettings.test.js` +Expected: FAIL — `Cannot find module '../../../../src/main/settings/monospaceSettings'` + +- [ ] **Step 3: Implement the module** + +```js +// src/main/settings/monospaceSettings.js +'use strict'; + +const FAMILY_BY_KEY = { + 'jetbrains-mono': 'JetBrains Mono', + 'fira-code': 'Fira Code', +}; + +const ALLOWED_FONTS = Object.keys(FAMILY_BY_KEY); + +const DEFAULT_SETTINGS = Object.freeze({ + monospaceFont: 'jetbrains-mono', + monospaceLigatures: false, +}); + +function getActiveMonoFont(settings) { + const key = settings && settings.monospaceFont; + if (typeof key === 'string' && Object.prototype.hasOwnProperty.call(FAMILY_BY_KEY, key)) { + return FAMILY_BY_KEY[key]; + } + return FAMILY_BY_KEY[DEFAULT_SETTINGS.monospaceFont]; +} + +function isLigaturesEnabled(settings) { + return !!(settings && settings.monospaceLigatures === true); +} + +function safeMonospaceSettings(input) { + const safe = { ...DEFAULT_SETTINGS }; + if (input && typeof input === 'object') { + if (ALLOWED_FONTS.includes(input.monospaceFont)) { + safe.monospaceFont = input.monospaceFont; + } + if (typeof input.monospaceLigatures === 'boolean') { + safe.monospaceLigatures = input.monospaceLigatures; + } else if (input.monospaceLigatures === 1 || input.monospaceLigatures === 'true') { + safe.monospaceLigatures = true; + } + } + return safe; +} + +module.exports = { + FAMILY_BY_KEY, + ALLOWED_FONTS, + DEFAULT_SETTINGS, + getActiveMonoFont, + isLigaturesEnabled, + safeMonospaceSettings, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/unit/main/monospace/monospaceSettings.test.js` +Expected: PASS — 8 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/settings/monospaceSettings.js tests/unit/main/monospace/monospaceSettings.test.js +git commit -m "feat(monospace): add settings schema with safe defaults" +``` + +--- + +## Task 2: MonospaceFontConfig path resolver + +**Files:** +- Create: `src/main/MonospaceFontConfig.js` +- Create: `tests/unit/main/monospace/MonospaceFontConfig.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/unit/main/monospace/MonospaceFontConfig.test.js +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +describe('MonospaceFontConfig', () => { + let tmpDir; + let originalResourcesPath; + let originalCwd; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-font-cfg-')); + originalResourcesPath = process.resourcesPath; + originalCwd = process.cwd(); + process.resourcesPath = undefined; + }); + + afterEach(() => { + process.resourcesPath = originalResourcesPath; + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function loadFresh() { + delete require.cache[require.resolve('../../../../src/main/MonospaceFontConfig')]; + return require('../../../../src/main/MonospaceFontConfig'); + } + + test('resolves JetBrains Mono Regular TTF from repo assets', () => { + const repoRoot = path.resolve(__dirname, '../../..'); + fs.mkdirSync(path.join(repoRoot, 'assets', 'fonts'), { recursive: true }); + const target = path.join(repoRoot, 'assets', 'fonts', 'JetBrainsMono-Regular.ttf'); + fs.writeFileSync(target, 'fake-ttf'); + try { + const cfg = loadFresh(); + const result = cfg.getMonoFontTtfPath('jetbrains-mono', 400); + expect(result).toBe(target); + } finally { + fs.unlinkSync(target); + } + }); + + test('resolves Fira Code Bold TTF when family is fira-code and weight 700', () => { + const repoRoot = path.resolve(__dirname, '../../..'); + const target = path.join(repoRoot, 'assets', 'fonts', 'FiraCode-Bold.ttf'); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, 'fake-ttf'); + try { + const cfg = loadFresh(); + const result = cfg.getMonoFontTtfPath('fira-code', 700); + expect(result).toBe(target); + } finally { + fs.unlinkSync(target); + } + }); + + test('returns null when TTF is missing', () => { + const cfg = loadFresh(); + const result = cfg.getMonoFontTtfPath('jetbrains-mono', 400); + expect(result).toBeNull(); + }); + + test('ligaturesEnabled delegates to settings', () => { + const { ligaturesEnabled } = require('../../../../src/main/settings/monospaceSettings'); + const cfg = loadFresh(); + expect(cfg.ligaturesEnabled({ monospaceLigatures: true })).toBe(true); + expect(cfg.ligaturesEnabled({ monospaceLigatures: false })).toBe(false); + expect(cfg.ligaturesEnabled({})).toBe(false); + }); + + test('getActiveFamily returns the human-readable family name', () => { + const cfg = loadFresh(); + expect(cfg.getActiveFamily({ monospaceFont: 'fira-code' })).toBe('Fira Code'); + expect(cfg.getActiveFamily({})).toBe('JetBrains Mono'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/unit/main/monospace/MonospaceFontConfig.test.js` +Expected: FAIL — `Cannot find module` + +- [ ] **Step 3: Implement the module** + +```js +// src/main/MonospaceFontConfig.js +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { + getActiveMonoFont, + isLigaturesEnabled, + FAMILY_BY_KEY, +} = require('./settings/monospaceSettings'); + +const WEIGHT_BY_KEY = { 300: 'Light', 400: 'Regular', 500: 'Medium', 600: 'SemiBold', 700: 'Bold' }; + +function getAppRoot() { + if ( + process.resourcesPath && + fs.existsSync(path.join(process.resourcesPath, 'app.asar.unpacked')) + ) { + return process.resourcesPath; + } + return path.resolve(__dirname, '..', '..'); +} + +function getCandidatePaths(family, weight) { + const familyDir = family === 'Fira Code' ? 'FiraCode' : 'JetBrainsMono'; + const weightName = WEIGHT_BY_KEY[weight] || 'Regular'; + const filename = `${familyDir}-${weightName}.ttf`; + const candidates = []; + candidates.push(path.resolve(getAppRoot(), 'assets', 'fonts', filename)); + const packagedRoot = process.resourcesPath || getAppRoot(); + candidates.push(path.join(packagedRoot, 'app.asar.unpacked', 'assets', 'fonts', filename)); + return candidates; +} + +function getMonoFontTtfPath(familyKey, weight = 400) { + const family = FAMILY_BY_KEY[familyKey] || 'JetBrains Mono'; + const candidates = getCandidatePaths(family, weight); + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + const filename = path.basename(candidates[candidates.length - 1]); + console.warn( + `[MonospaceFontConfig] bundled font missing: ${filename}; falling back to system monospace` + ); + return null; +} + +function ligaturesEnabled(settings) { + return isLigaturesEnabled(settings); +} + +function getActiveFamily(settings) { + return getActiveMonoFont(settings); +} + +module.exports = { getMonoFontTtfPath, ligaturesEnabled, getActiveFamily }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/unit/main/monospace/MonospaceFontConfig.test.js` +Expected: PASS — 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/MonospaceFontConfig.js tests/unit/main/monospace/MonospaceFontConfig.test.js +git commit -m "feat(monospace): add path resolver for bundled TTF assets" +``` + +--- + +## Task 3: PdfFontHeader builder + +**Files:** +- Create: `src/main/PdfFontHeader.js` +- Create: `tests/unit/main/monospace/PdfFontHeader.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +// tests/unit/main/monospace/PdfFontHeader.test.js +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { buildPdfFontHeader } = require('../../../../src/main/PdfFontHeader'); + +describe('PdfFontHeader', () => { + let tmpDir; + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-pdfhdr-')); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + + test('writes a fontspec header for JetBrains Mono Regular', () => { + const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf'); + fs.writeFileSync(ttf, 'fake'); + const out = buildPdfFontHeader({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }, ttf, 'JetBrains Mono'); + expect(typeof out.headerPath).toBe('string'); + expect(fs.existsSync(out.headerPath)).toBe(true); + const content = fs.readFileSync(out.headerPath, 'utf-8'); + expect(content).toContain('\\setmonofont'); + expect(content).toContain('JetBrainsMono-Regular.ttf'); + expect(out.familyName).toBe('JetBrains Mono'); + }); + + test('writes header with ligatures when enabled', () => { + const ttf = path.join(tmpDir, 'FiraCode-Regular.ttf'); + fs.writeFileSync(ttf, 'fake'); + const out = buildPdfFontHeader({ monospaceFont: 'fira-code', monospaceLigatures: true }, ttf, 'Fira Code'); + const content = fs.readFileSync(out.headerPath, 'utf-8'); + expect(content).toContain('Ligatures=TeX'); + }); + + test('header is written under os.tmpdir', () => { + const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf'); + fs.writeFileSync(ttf, 'fake'); + const out = buildPdfFontHeader({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }, ttf, 'JetBrains Mono'); + expect(out.headerPath.startsWith(os.tmpdir())).toBe(true); + }); + + test('throws if ttfPath does not exist', () => { + expect(() => + buildPdfFontHeader({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }, '/nonexistent.ttf', 'JetBrains Mono') + ).toThrow(/TTF/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/unit/main/monospace/PdfFontHeader.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```js +// src/main/PdfFontHeader.js +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +function buildPdfFontHeader(settings, ttfPath, fontFamily) { + if (!ttfPath || !fs.existsSync(ttfPath)) { + throw new Error(`PdfFontHeader: TTF not found at ${ttfPath}`); + } + const ligatures = !!(settings && settings.monospaceLigatures === true); + const lines = [ + `\\setmonofont[Path = ${path.dirname(ttfPath)}/,`, + ` Extension = .ttf,`, + ` UprightFont = ${path.basename(ttfPath)},`, + ` BoldFont = ${path.basename(ttfPath).replace('Regular', 'Bold')},`, + ligatures ? ' Ligatures = TeX,' : '', + ` Scale = 0.9]`, + `{${fontFamily}}`, + ].filter(Boolean); + const headerPath = path.join(os.tmpdir(), `monospace-pdf-${Date.now()}-${process.pid}.tex`); + fs.writeFileSync(headerPath, lines.join('\n'), 'utf-8'); + return { headerPath, familyName: fontFamily }; +} + +module.exports = { buildPdfFontHeader }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/unit/main/monospace/PdfFontHeader.test.js` +Expected: PASS — 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/PdfFontHeader.js tests/unit/main/monospace/PdfFontHeader.test.js +git commit -m "feat(monospace): add xelatex fontspec header builder" +``` + +--- + +## Task 4: DocxFontEmbedder + +**Files:** +- Create: `src/main/DocxFontEmbedder.js` +- Create: `tests/unit/main/monospace/DocxFontEmbedder.test.js` + +- [ ] **Step 1: Write failing test** + +```js +// tests/unit/main/monospace/DocxFontEmbedder.test.js +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const JSZip = require('jszip'); + +const { embedDocxFont } = require('../../../../src/main/DocxFontEmbedder'); + +async function makeMinimalDocx(outPath) { + const zip = new JSZip(); + zip.file('[Content_Types].xml', ''); + zip.file('_rels/.rels', ''); + zip.file('word/document.xml', ''); + const buf = await zip.generateAsync({ type: 'nodebuffer' }); + fs.writeFileSync(outPath, buf); +} + +describe('DocxFontEmbedder', () => { + let tmpDir; + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-docx-')); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + + test('embeds TTF into word/fonts and updates rels', async () => { + const input = path.join(tmpDir, 'in.docx'); + const output = path.join(tmpDir, 'out.docx'); + const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf'); + await makeMinimalDocx(input); + fs.writeFileSync(ttf, Buffer.from([0x01, 0x00, 0x00, 0x00, 0x00])); + + await embedDocxFont(input, output, ttf, 'JetBrainsMono'); + + const result = await JSZip.loadAsync(fs.readFileSync(output)); + const fontFiles = Object.keys(result.files).filter((n) => n.startsWith('word/fonts/')); + expect(fontFiles.length).toBeGreaterThan(0); + const rels = result.file('word/_rels/fontTable.xml.rels'); + expect(rels).toBeTruthy(); + const relsContent = await rels.async('string'); + expect(relsContent).toContain('.ttf'); + }); + + test('returns rejected promise for invalid docx input', async () => { + const input = path.join(tmpDir, 'invalid.docx'); + const output = path.join(tmpDir, 'out.docx'); + const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf'); + fs.writeFileSync(input, Buffer.from('not a docx')); + fs.writeFileSync(ttf, Buffer.from([0x01])); + await expect(embedDocxFont(input, output, ttf, 'JetBrainsMono')).rejects.toThrow(); + }); + + test('throws if ttf missing', async () => { + const input = path.join(tmpDir, 'in.docx'); + const output = path.join(tmpDir, 'out.docx'); + await makeMinimalDocx(input); + await expect(embedDocxFont(input, output, '/nope.ttf', 'JetBrainsMono')).rejects.toThrow(/TTF/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/unit/main/monospace/DocxFontEmbedder.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```js +// src/main/DocxFontEmbedder.js +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const JSZip = require('jszip'); + +const FONT_TABLE_PATH = 'word/fontTable.xml'; +const FONT_TABLE_RELS_PATH = 'word/_rels/fontTable.xml.rels'; + +function fontTableXml(family) { + return ` + + + + + + + + +`; +} + +function fontTableRels(fontFilename) { + return ` + + +`; +} + +async function embedDocxFont(inputDocxPath, outputDocxPath, ttfPath, fontFamily) { + if (!ttfPath || !fs.existsSync(ttfPath)) { + throw new Error(`DocxFontEmbedder: TTF not found at ${ttfPath}`); + } + const inputBytes = fs.readFileSync(inputDocxPath); + const zip = await JSZip.loadAsync(inputBytes); + const safeFamily = String(fontFamily).replace(/[^A-Za-z0-9]/g, ''); + const fontFilename = `${safeFamily}.ttf`; + const fontBytes = fs.readFileSync(ttfPath); + zip.file(`word/fonts/${fontFilename}`, fontBytes); + zip.file(FONT_TABLE_PATH, fontTableXml(safeFamily)); + zip.file(FONT_TABLE_RELS_PATH, fontTableRels(fontFilename)); + const out = await zip.generateAsync({ type: 'nodebuffer' }); + fs.writeFileSync(outputDocxPath, out); +} + +module.exports = { embedDocxFont }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/unit/main/monospace/DocxFontEmbedder.test.js` +Expected: PASS — 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/DocxFontEmbedder.js tests/unit/main/monospace/DocxFontEmbedder.test.js +git commit -m "feat(monospace): embed TTF into pandoc-generated DOCX" +``` + +--- + +## Task 5: EpubFontEmbedder + +**Files:** +- Create: `src/main/EpubFontEmbedder.js` +- Create: `tests/unit/main/monospace/EpubFontEmbedder.test.js` + +- [ ] **Step 1: Write failing test** + +```js +// tests/unit/main/monospace/EpubFontEmbedder.test.js +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const JSZip = require('jszip'); + +const { withEpubEmbedFontArgs, embedEpubFont } = require('../../../../src/main/EpubFontEmbedder'); + +async function makeMinimalEpub(outPath) { + const zip = new JSZip(); + zip.file('META-INF/container.xml', ''); + zip.file('OEBPS/content.opf', ''); + fs.writeFileSync(outPath, await zip.generateAsync({ type: 'nodebuffer' })); +} + +describe('EpubFontEmbedder', () => { + let tmpDir; + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-epub-')); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + + test('withEpubEmbedFontArgs prepends --epub-embed-font', () => { + const args = withEpubEmbedFontArgs(['-o', 'out.epub', 'in.md'], '/tmp/font.ttf', 'JetBrains Mono'); + expect(args).toContain('--epub-embed-font=/tmp/font.ttf'); + expect(args.indexOf('--epub-embed-font=/tmp/font.ttf')).toBeLessThan(args.indexOf('-o')); + }); + + test('embedEpubFont adds font to OEBPS and updates manifest', async () => { + const epub = path.join(tmpDir, 'book.epub'); + const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf'); + await makeMinimalEpub(epub); + fs.writeFileSync(ttf, Buffer.from([0x01, 0x00])); + + await embedEpubFont(epub, ttf, 'JetBrains Mono'); + + const zip = await JSZip.loadAsync(fs.readFileSync(epub)); + const fontFile = zip.file('OEBPS/JetBrainsMono-Regular.ttf'); + expect(fontFile).toBeTruthy(); + const opf = await zip.file('OEBPS/content.opf').async('string'); + expect(opf).toContain('JetBrainsMono-Regular.ttf'); + }); + + test('embedEpubFont rejects missing TTF', async () => { + const epub = path.join(tmpDir, 'book.epub'); + await makeMinimalEpub(epub); + await expect(embedEpubFont(epub, '/nope.ttf', 'JetBrains Mono')).rejects.toThrow(/TTF/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/unit/main/monospace/EpubFontEmbedder.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```js +// src/main/EpubFontEmbedder.js +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const JSZip = require('jszip'); + +function withEpubEmbedFontArgs(pandocArgs, ttfPath, _fontFamily) { + return [`--epub-embed-font=${ttfPath}`, ...pandocArgs]; +} + +async function embedEpubFont(epubPath, ttfPath, fontFamily) { + if (!ttfPath || !fs.existsSync(ttfPath)) { + throw new Error(`EpubFontEmbedder: TTF not found at ${ttfPath}`); + } + const bytes = fs.readFileSync(epubPath); + const zip = await JSZip.loadAsync(bytes); + const ttfBytes = fs.readFileSync(ttfPath); + const fontName = path.basename(ttfPath); + zip.file(`OEBPS/${fontName}`, ttfBytes); + const opfPath = 'OEBPS/content.opf'; + const opfFile = zip.file(opfPath); + if (!opfFile) { + fs.writeFileSync(epubPath, await zip.generateAsync({ type: 'nodebuffer' })); + return; + } + let opf = await opfFile.async('string'); + const manifestEntry = ``; + if (!opf.includes('manifest')) { + opf = opf.replace( + '', + `${manifestEntry}` + ); + } else if (!opf.includes(fontName)) { + opf = opf.replace('', `${manifestEntry}`); + } + zip.file(opfPath, opf); + fs.writeFileSync(epubPath, await zip.generateAsync({ type: 'nodebuffer' })); +} + +module.exports = { withEpubEmbedFontArgs, embedEpubFont }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/unit/main/monospace/EpubFontEmbedder.test.js` +Expected: PASS — 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/EpubFontEmbedder.js tests/unit/main/monospace/EpubFontEmbedder.test.js +git commit -m "feat(monospace): EPUB embed-font helper + manifest patcher" +``` + +--- + +## Task 6: ExportCss (woff2 → base64) + +**Files:** +- Create: `src/main/ExportCss.js` +- Create: `tests/unit/main/monospace/ExportCss.test.js` + +- [ ] **Step 1: Write failing test** + +```js +// tests/unit/main/monospace/ExportCss.test.js +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const { buildExportCss, buildFontFaceBlock } = require('../../../../src/main/ExportCss'); + +describe('ExportCss', () => { + let tmpDir; + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-css-')); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + + test('buildFontFaceBlock emits @font-face with base64 data URI', () => { + const woff2 = Buffer.from([0x77, 0x4f, 0x46, 0x32]); + const css = buildFontFaceBlock('JetBrains Mono', woff2); + expect(css).toContain('@font-face'); + expect(css).toContain('font-family: \'JetBrains Mono\''); + expect(css).toContain('format(\'woff2\')'); + expect(css).toContain('base64,'); + const m = css.match(/base64,([A-Za-z0-9+/=]+)/); + expect(m).toBeTruthy(); + expect(Buffer.from(m[1], 'base64')).toEqual(woff2); + }); + + test('buildExportCss writes @font-face + body rule', () => { + const woff2 = Buffer.from([0x77, 0x4f, 0x46, 0x32]); + const css = buildExportCss({ monospaceFont: 'jetbrains-mono', monospaceLigatures: true }, { woff2 }); + expect(css).toContain('@font-face'); + expect(css).toContain('font-feature-settings'); + expect(css).toContain('\'liga\' 1'); + }); + + test('buildExportCss with ligatures off omits font-feature-settings', () => { + const woff2 = Buffer.from([0x77, 0x4f, 0x46, 0x32]); + const css = buildExportCss({ monospaceFont: 'fira-code', monospaceLigatures: false }, { woff2 }); + expect(css).not.toContain('font-feature-settings'); + expect(css).toContain('Fira Code'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/unit/main/monospace/ExportCss.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```js +// src/main/ExportCss.js +'use strict'; + +const { FAMILY_BY_KEY } = require('./settings/monospaceSettings'); + +function buildFontFaceBlock(family, woff2Bytes) { + const safeFamily = family.replace(/'/g, "\\'"); + const b64 = Buffer.from(woff2Bytes).toString('base64'); + return `@font-face { + font-family: '${safeFamily}'; + src: url(data:font/woff2;base64,${b64}) format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +}`; +} + +function buildExportCss(settings, { woff2 } = {}) { + const family = (settings && FAMILY_BY_KEY[settings.monospaceFont]) || 'JetBrains Mono'; + const ligatures = !!(settings && settings.monospaceLigatures === true); + const face = buildFontFaceBlock(family, woff2 || Buffer.alloc(0)); + const feature = ligatures + ? `code, pre, kbd, samp { font-feature-settings: 'liga' 1, 'calt' 1; }` + : ''; + return [face, feature].filter(Boolean).join('\n\n'); +} + +module.exports = { buildExportCss, buildFontFaceBlock }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/unit/main/monospace/ExportCss.test.js` +Expected: PASS — 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/ExportCss.js tests/unit/main/monospace/ExportCss.test.js +git commit -m "feat(monospace): build CSS with embedded woff2 base64" +``` + +--- + +## Task 7: IPC handlers for monospace settings + +**Files:** +- Create: `src/main/ipc/monospace-handlers.js` +- Create: `tests/unit/main/monospace/monospace-handlers.test.js` + +- [ ] **Step 1: Write failing test** + +```js +// tests/unit/main/monospace/monospace-handlers.test.js +const { EventEmitter } = require('events'); + +class FakeIpcMain extends EventEmitter { + constructor() { super(); this.handlers = new Map(); } + handle(channel, handler) { this.handlers.set(channel, handler); } + emit(channel, ...args) { this.handle(channel, ...args); } +} + +describe('monospace-handlers', () => { + let ipc; + let register; + let store; + + beforeEach(() => { + jest.resetModules(); + ipc = new FakeIpcMain(); + store = new Map(); + // Monkey-patch electron and store before requiring handler + jest.doMock('electron', () => ({ ipcMain: ipc })); + jest.doMock('../../../../src/main/store', () => ({ + get: (k, d) => (store.has(k) ? store.get(k) : d), + set: (k, v) => store.set(k, v), + })); + ({ register } = require('../../../../src/main/ipc/monospace-handlers')); + }); + + test('get-monospace-settings returns defaults when nothing stored', () => { + register(); + const handler = ipc.handlers.get('get-monospace-settings'); + return handler({}).then((result) => { + expect(result).toEqual({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }); + }); + }); + + test('set-monospace-settings persists and returns sanitized values', () => { + register(); + const handler = ipc.handlers.get('set-monospace-settings'); + return handler({}, { monospaceFont: 'fira-code', monospaceLigatures: 1 }).then((result) => { + expect(result).toEqual({ monospaceFont: 'fira-code', monospaceLigatures: true }); + expect(store.get('monospaceFont')).toBe('fira-code'); + expect(store.get('monospaceLigatures')).toBe(true); + }); + }); + + test('set-monospace-settings rejects invalid font key', () => { + register(); + const handler = ipc.handlers.get('set-monospace-settings'); + return handler({}, { monospaceFont: 'comic-sans' }).then((result) => { + expect(result.monospaceFont).toBe('jetbrains-mono'); + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- tests/unit/main/monospace/monospace-handlers.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```js +// src/main/ipc/monospace-handlers.js +'use strict'; + +const { ipcMain } = require('electron'); +const store = require('../store'); +const { safeMonospaceSettings, DEFAULT_SETTINGS } = require('../settings/monospaceSettings'); + +function readCurrent() { + return { + monospaceFont: store.get('monospaceFont', DEFAULT_SETTINGS.monospaceFont), + monospaceLigatures: store.get('monospaceLigatures', DEFAULT_SETTINGS.monospaceLigatures), + }; +} + +function register() { + ipcMain.handle('get-monospace-settings', () => readCurrent()); + + ipcMain.handle('set-monospace-settings', (_event, partial) => { + const safe = safeMonospaceSettings(partial || {}); + if (Object.prototype.hasOwnProperty.call(partial || {}, 'monospaceFont')) { + store.set('monospaceFont', safe.monospaceFont); + } + if (Object.prototype.hasOwnProperty.call(partial || {}, 'monospaceLigatures')) { + store.set('monospaceLigatures', safe.monospaceLigatures); + } + return readCurrent(); + }); +} + +module.exports = { register, readCurrent }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- tests/unit/main/monospace/monospace-handlers.test.js` +Expected: PASS — 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/ipc/monospace-handlers.js tests/unit/main/monospace/monospace-handlers.test.js +git commit -m "feat(monospace): add get/set IPC handlers with safe validation" +``` + +--- + +## Task 8: Bundle TTF assets + asarUnpack + +**Files:** +- Create: `assets/fonts/JetBrainsMono-Regular.ttf` +- Create: `assets/fonts/JetBrainsMono-Bold.ttf` +- Create: `assets/fonts/FiraCode-Regular.ttf` +- Create: `assets/fonts/FiraCode-Bold.ttf` +- Create: `assets/fonts/JetBrainsMono-LICENSE.txt` +- Create: `assets/fonts/FiraCode-LICENSE.txt` +- Modify: `package.json` (asarUnpack) + +- [ ] **Step 1: Copy TTFs from master snapshot** + +```bash +mkdir -p assets/fonts +cp /tmp/master-snapshot/assets/fonts/JetBrainsMono-Regular.ttf assets/fonts/ +cp /tmp/master-snapshot/assets/fonts/JetBrainsMono-Bold.ttf assets/fonts/ +cp /tmp/master-snapshot/assets/fonts/FiraCode-Regular.ttf assets/fonts/ +cp /tmp/master-snapshot/assets/fonts/FiraCode-Bold.ttf assets/fonts/ +cp /tmp/master-snapshot/assets/fonts/JetBrainsMono-LICENSE.txt assets/fonts/ +cp /tmp/master-snapshot/assets/fonts/FiraCode-LICENSE.txt assets/fonts/ +ls -la assets/fonts/ +``` + +Expected: 6 files present (4 TTFs + 2 LICENSE.txt). + +- [ ] **Step 2: Add asarUnpack to package.json** + +Modify `package.json` `build` block, add after `files:`: + +```json +"asarUnpack": [ + "assets/fonts/**" +] +``` + +- [ ] **Step 3: Extend download-tools.js with downloadFiraCode()** + +Append to `scripts/download-tools.js` (just before `Promise.all([...])`): + +```js +async function downloadFiraCode() { + const fs = require('fs'); + const path = require('path'); + const targetDir = path.resolve(__dirname, '..', 'assets', 'fonts'); + fs.mkdirSync(targetDir, { recursive: true }); + const files = [ + { url: 'https://github.com/tonsky/FiraCode/raw/master/distr/ttf/FiraCode-Regular.ttf', out: 'FiraCode-Regular.ttf' }, + { url: 'https://github.com/tonsky/FiraCode/raw/master/distr/ttf/FiraCode-Bold.ttf', out: 'FiraCode-Bold.ttf' }, + { url: 'https://raw.githubusercontent.com/tonsky/FiraCode/master/LICENSE', out: 'FiraCode-LICENSE.txt' }, + ]; + for (const f of files) { + await downloadToFile(f.url, path.join(targetDir, f.out)); + } +} +``` + +And update the final line: + +```js +Promise.all([downloadPandoc(), downloadFiraCode()]).catch((err) => { +``` + +- [ ] **Step 4: Commit** + +```bash +git add assets/fonts/ package.json scripts/download-tools.js +git commit -m "build(monospace): bundle JetBrains Mono + Fira Code TTFs, asarUnpack" +``` + +--- + +## Task 9: Wire monospace modules into exportWithPandoc + +**Files:** +- Modify: `src/main/index.js` (export pipeline branches) + +- [ ] **Step 1: Locate the four branches in exportWithPandoc** + +Run: `grep -n "exportToPDFElectron\|exportWordWithTemplate\|exportToHTML\|epub" src/main/index.js | head -20` + +- [ ] **Step 2: Add monospace wiring at the top of exportWithPandoc** + +Find the function `exportWithPandoc` and add right after the args parse: + +```js +const MonospaceFontConfig = require('./MonospaceFontConfig'); +const { buildPdfFontHeader } = require('./PdfFontHeader'); +const { embedDocxFont } = require('./DocxFontEmbedder'); +const { withEpubEmbedFontArgs, embedEpubFont } = require('./EpubFontEmbedder'); +const { buildExportCss } = require('./ExportCss'); +const { safeMonospaceSettings, DEFAULT_SETTINGS: MONO_DEFAULTS } = require('./settings/monospaceSettings'); + +const monoSettings = safeMonospaceSettings({ + monospaceFont: store.get('monospaceFont', MONO_DEFAULTS.monospaceFont), + monospaceLigatures: store.get('monospaceLigatures', MONO_DEFAULTS.monospaceLigatures), +}); +const monoTtf = MonospaceFontConfig.getMonoFontTtfPath(monoSettings.monospaceFont, 400); +``` + +- [ ] **Step 3: Wire PDF branch with --include-in-header** + +In the PDF export branch (where pandoc args are built), if format is `pdf` and `monoTtf` exists: + +```js +if (format === 'pdf' && monoTtf) { + const { headerPath } = buildPdfFontHeader(monoSettings, monoTtf, MonospaceFontConfig.getActiveFamily(monoSettings)); + pandocArgs.push(`--include-in-header=${headerPath}`); +} +``` + +- [ ] **Step 4: Wire DOCX branch with embedDocxFont (post-pandoc)** + +In the DOCX branch, after the pandoc call, if `monoTtf` exists: + +```js +if (monoTtf) { + await embedDocxFont(outputPath, outputPath, monoTtf, MonospaceFontConfig.getActiveFamily(monoSettings).replace(/\s+/g, '')); +} +``` + +- [ ] **Step 5: Wire EPUB branch with --epub-embed-font + manifest patch** + +```js +if (format === 'epub' && monoTtf) { + pandocArgs = withEpubEmbedFontArgs(pandocArgs, monoTtf, MonospaceFontConfig.getActiveFamily(monoSettings)); +} +// after pandoc call: +if (format === 'epub' && monoTtf) { + await embedEpubFont(outputPath, monoTtf, MonospaceFontConfig.getActiveFamily(monoSettings)); +} +``` + +- [ ] **Step 6: Wire HTML branch with embedded CSS** + +```js +if ((format === 'html' || format === 'html5') && monoTtf) { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const woff2Path = path.join(path.dirname(monoTtf), MonospaceFontConfig.getActiveFamily(monoSettings) === 'Fira Code' ? 'FiraCode-Regular.woff2' : 'JetBrainsMono-Regular.woff2'); + if (fs.existsSync(woff2Path)) { + const css = buildExportCss(monoSettings, { woff2: fs.readFileSync(woff2Path) }); + const cssPath = path.join(os.tmpdir(), `mono-html-${Date.now()}-${process.pid}.css`); + fs.writeFileSync(cssPath, css); + pandocArgs.push(`--css=${cssPath}`); + } +} +``` + +- [ ] **Step 7: Run full test suite to verify no regressions** + +Run: `npm test` +Expected: PASS — all 208 jest tests pass (existing + ~24 new). + +- [ ] **Step 8: Commit** + +```bash +git add src/main/index.js +git commit -m "feat(monospace): wire embedders into PDF/DOCX/EPUB/HTML exports" +``` + +--- + +## Task 10: Renderer hook + CSS tokens + +**Files:** +- Create: `src/renderer/hooks/use-monospace-classes.ts` +- Modify: `src/renderer/styles/globals.css` +- Modify: `src/renderer/App.tsx` (mount hook once) + +- [ ] **Step 1: Write failing vitest** + +```ts +// src/renderer/hooks/use-monospace-classes.test.ts +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { applyMonospaceClasses } from './use-monospace-classes'; + +describe('applyMonospaceClasses', () => { + beforeEach(() => { document.body.className = ''; }); + afterEach(() => { document.body.className = ''; }); + + it('applies jetbrains-mono + ligatures-off for default settings', () => { + applyMonospaceClasses({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }); + expect(document.body.classList.contains('mono-jetbrains-mono')).toBe(true); + expect(document.body.classList.contains('mono-ligatures-off')).toBe(true); + }); + + it('applies fira-code + ligatures-on when enabled', () => { + applyMonospaceClasses({ monospaceFont: 'fira-code', monospaceLigatures: true }); + expect(document.body.classList.contains('mono-fira-code')).toBe(true); + expect(document.body.classList.contains('mono-ligatures-on')).toBe(true); + }); + + it('strips prior mono-* classes before applying new ones', () => { + document.body.classList.add('mono-jetbrains-mono', 'mono-ligatures-off'); + applyMonospaceClasses({ monospaceFont: 'fira-code', monospaceLigatures: true }); + expect(document.body.classList.contains('mono-jetbrains-mono')).toBe(false); + expect(document.body.classList.contains('mono-ligatures-off')).toBe(false); + expect(document.body.classList.contains('mono-fira-code')).toBe(true); + }); + + it('falls back to defaults when given null', () => { + applyMonospaceClasses(null); + expect(document.body.classList.contains('mono-jetbrains-mono')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run vitest to verify it fails** + +Run: `npm run test:renderer -- use-monospace-classes` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +```ts +// src/renderer/hooks/use-monospace-classes.ts +export interface MonospaceSettings { + monospaceFont: 'jetbrains-mono' | 'fira-code'; + monospaceLigatures: boolean; +} + +const DEFAULTS: MonospaceSettings = { + monospaceFont: 'jetbrains-mono', + monospaceLigatures: false, +}; + +const FAMILY_CLASSES = ['mono-jetbrains-mono', 'mono-fira-code']; +const LIG_CLASSES = ['mono-ligatures-on', 'mono-ligatures-off']; + +function stripMonospaceClasses(): void { + for (const c of [...FAMILY_CLASSES, ...LIG_CLASSES]) { + document.body.classList.remove(c); + } +} + +export function applyMonospaceClasses(input: Partial | null | undefined): MonospaceSettings { + const safe: MonospaceSettings = { + monospaceFont: (input && (input.monospaceFont === 'fira-code' ? 'fira-code' : 'jetbrains-mono')) || DEFAULTS.monospaceFont, + monospaceLigatures: !!(input && input.monospaceLigatures === true), + }; + stripMonospaceClasses(); + document.body.classList.add(`mono-${safe.monospaceFont}`); + document.body.classList.add(`mono-ligatures-${safe.monospaceLigatures ? 'on' : 'off'}`); + return safe; +} + +export function useMonospaceClasses(): void { + if (typeof window === 'undefined') return; + const api = (window as any).electronAPI; + if (!api || !api.monospace) return; + api.monospace.getSettings().then(applyMonospaceClasses).catch(() => applyMonospaceClasses(null)); +} +``` + +- [ ] **Step 4: Run vitest to verify it passes** + +Run: `npm run test:renderer -- use-monospace-classes` +Expected: PASS — 4 tests pass. + +- [ ] **Step 5: Add CSS tokens** + +Append to `src/renderer/styles/globals.css`: + +```css +:root { + --font-mono-active: 'JetBrains Mono', 'SF Mono', Monaco, 'Courier New', monospace; + --font-mono-feature: 'liga' 0, 'calt' 0; +} +body.mono-fira-code { + --font-mono-active: 'Fira Code', 'JetBrains Mono', 'SF Mono', monospace; +} +body.mono-ligatures-on { + --font-mono-feature: 'liga' 1, 'calt' 1; +} +code, pre, kbd, samp { + font-family: var(--font-mono-active); + font-feature-settings: var(--font-mono-feature); +} +``` + +- [ ] **Step 6: Mount the hook in App.tsx** + +Find `src/renderer/App.tsx` and add at the top of the default exported component: + +```tsx +import { useMonospaceClasses } from './hooks/use-monospace-classes'; + +export default function App() { + useMonospaceClasses(); + return ...; +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/renderer/hooks/use-monospace-classes.ts src/renderer/hooks/use-monospace-classes.test.ts src/renderer/styles/globals.css src/renderer/App.tsx +git commit -m "feat(renderer): body-class monospace toggle + CSS tokens" +``` + +--- + +## Task 11: Preload + TypeScript declarations + +**Files:** +- Modify: `src/preload.js` +- Modify: `src/renderer/types/electron.d.ts` +- Modify: `src/renderer/lib/ipc.ts` + +- [ ] **Step 1: Add channels to preload allowlist** + +In `src/preload.js`, add to `ALLOWED_SEND_CHANNELS`: + +```js +'get-monospace-settings', +'set-monospace-settings', +``` + +Add to the `electronAPI` object inside `contextBridge.exposeInMainWorld`: + +```js +monospace: { + getSettings: () => ipcRenderer.invoke('get-monospace-settings'), + saveSettings: (partial) => ipcRenderer.invoke('set-monospace-settings', partial), +}, +``` + +- [ ] **Step 2: Add TypeScript declaration** + +In `src/renderer/types/electron.d.ts`, inside the `ElectronAPI` interface, add: + +```ts +monospace: { + getSettings: () => Promise<{ monospaceFont: 'jetbrains-mono' | 'fira-code'; monospaceLigatures: boolean }>; + saveSettings: (partial: { monospaceFont?: 'jetbrains-mono' | 'fira-code'; monospaceLigatures?: boolean }) => Promise<{ monospaceFont: 'jetbrains-mono' | 'fira-code'; monospaceLigatures: boolean }>; +}; +``` + +- [ ] **Step 3: Update ipc.ts wrapper** + +In `src/renderer/lib/ipc.ts`, add typed wrappers if they exist; if `ipc.ts` is purely a `wrap()` helper, no change is needed (the d.ts change is sufficient). + +- [ ] **Step 4: Run both test suites** + +```bash +npm test && npm run test:renderer +``` +Expected: PASS — all 208 + 430 existing + new tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/preload.js src/renderer/types/electron.d.ts src/renderer/lib/ipc.ts +git commit -m "feat(ipc): expose monospace settings through preload bridge" +``` + +--- + +## Task 12: Window title + naming differentiation + +**Files:** +- Modify: `package.json` +- Modify: `src/main/window/index.js` +- Modify: `src/main/updater/migration-transform.js` + +- [ ] **Step 1: Update package.json identity** + +```diff +- "name": "markdown-converter", ++ "name": "markdown-converter-react", +- "productName": "MarkdownConverter", ++ "productName": "Markdown Converter React", +- "appId": "com.concreteinfo.markdownconverter", ++ "appId": "com.concreteinfo.markdownconverter.react", +``` + +Add inside `build` block: + +```json +"linux": { + "executableName": "markdown-converter-react", + "synopsis": "Markdown editor (React build)", + "description": "Markdown editor and universal file converter — React build" +} +``` + +- [ ] **Step 2: Window title suffix in dev** + +In `src/main/window/index.js`, find the `BrowserWindow` constructor and add `title` option: + +```js +const isDev = !!process.env.VITE_DEV_SERVER_URL; +mainWindow = new BrowserWindow({ + title: `Markdown Converter${isDev ? ' — React Dev' : ''}`, + ... +}); +``` + +- [ ] **Step 3: Add defaults to migration-transform.js** + +In `src/main/updater/migration-transform.js`, find the v5 schema object and add: + +```js +monospaceFont: z.enum(['jetbrains-mono', 'fira-code']).default('jetbrains-mono'), +monospaceLigatures: z.boolean().default(false), +appVariant: z.enum(['classic', 'react']).default('react'), +``` + +- [ ] **Step 4: Mirror the same defaults in the renderer migration** + +In `src/renderer/lib/migrations/v4-to-v5.ts`, add the same three fields to the schema. + +- [ ] **Step 5: Run tests + lint** + +```bash +npm test && npm run test:renderer && npm run lint +``` +Expected: PASS, PASS, no errors. + +- [ ] **Step 6: Commit** + +```bash +git add package.json src/main/window/index.js src/main/updater/migration-transform.js src/renderer/lib/migrations/v4-to-v5.ts +git commit -m "build(identity): rename to Markdown Converter React + defaults" +``` + +--- + +## Task 13: E2E smoke test + +**Files:** +- Create: `tests/smoke-e2e-monospace.js` + +- [ ] **Step 1: Write the smoke test** + +```js +// tests/smoke-e2e-monospace.js +/** + * E2E: verify monospace TTF is embedded into actual exports. + * Skips silently if pandoc is unavailable. + */ +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +function pandocAvailable() { + try { execFileSync('pandoc', ['--version'], { stdio: 'ignore' }); return true; } + catch { return false; } +} + +function ttfAvailable() { + const ttf = path.resolve(__dirname, '..', 'assets', 'fonts', 'JetBrainsMono-Regular.ttf'); + return fs.existsSync(ttf); +} + +describe('monospace E2E', () => { + if (!pandocAvailable() || !ttfAvailable()) { + it.skip('pandoc or font asset unavailable', () => {}); + return; + } + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-e2e-')); + const mdPath = path.join(tmp, 'ascii.md'); + fs.writeFileSync( + mdPath, + '```\n+---+---+\n| A | B |\n+---+---+\n```\n' + ); + + afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); + + it('PDF export references the monospace font (xelatex header)', () => { + const headerPath = path.join(tmp, 'header.tex'); + const { buildPdfFontHeader } = require('../src/main/PdfFontHeader'); + const { getMonoFontTtfPath } = require('../src/main/MonospaceFontConfig'); + const ttf = getMonoFontTtfPath('jetbrains-mono', 400); + const { headerPath: out } = buildPdfFontHeader( + { monospaceFont: 'jetbrains-mono', monospaceLigatures: false }, + ttf, + 'JetBrains Mono' + ); + const content = fs.readFileSync(out, 'utf-8'); + expect(content).toContain('\\setmonofont'); + expect(content).toContain('JetBrainsMono-Regular.ttf'); + }); + + it('DOCX export embeds the TTF', async () => { + const { embedDocxFont } = require('../src/main/DocxFontEmbedder'); + const { getMonoFontTtfPath } = require('../src/main/MonospaceFontConfig'); + const JSZip = require('jszip'); + const zip = new JSZip(); + zip.file('[Content_Types].xml', ''); + fs.writeFileSync(path.join(tmp, 'in.docx'), await zip.generateAsync({ type: 'nodebuffer' })); + await embedDocxFont( + path.join(tmp, 'in.docx'), + path.join(tmp, 'out.docx'), + getMonoFontTtfPath('jetbrains-mono', 400), + 'JetBrains Mono' + ); + const out = await JSZip.loadAsync(fs.readFileSync(path.join(tmp, 'out.docx'))); + const fonts = Object.keys(out.files).filter((n) => n.startsWith('word/fonts/')); + expect(fonts.length).toBeGreaterThan(0); + }); + + it('EPUB export args include --epub-embed-font', () => { + const { withEpubEmbedFontArgs } = require('../src/main/EpubFontEmbedder'); + const args = withEpubEmbedFontArgs(['-o', 'out.epub', 'in.md'], '/tmp/JetBrainsMono-Regular.ttf', 'JetBrains Mono'); + expect(args).toContain('--epub-embed-font=/tmp/JetBrainsMono-Regular.ttf'); + }); + + it('HTML CSS contains base64 font face', () => { + const { buildExportCss } = require('../src/main/ExportCss'); + const woff2 = Buffer.from('woff2-test'); + const css = buildExportCss({ monospaceFont: 'jetbrains-mono', monospaceLigatures: true }, { woff2 }); + const m = css.match(/base64,([A-Za-z0-9+/=]+)/); + expect(Buffer.from(m[1], 'base64').toString()).toBe('woff2-test'); + }); +}); +``` + +- [ ] **Step 2: Run the smoke test** + +Run: `npm test -- tests/smoke-e2e-monospace.js` +Expected: PASS if assets/fonts present and pandoc present; otherwise 1 test skipped. + +- [ ] **Step 3: Commit** + +```bash +git add tests/smoke-e2e-monospace.js +git commit -m "test(e2e): verify monospace font embeds into PDF/DOCX/EPUB/HTML" +``` + +--- + +## Task 14: Register monospace IPC at startup + final verification + +**Files:** +- Modify: `src/main/index.js` + +- [ ] **Step 1: Import + register IPC handler** + +At the top of `src/main/index.js`: + +```js +const monospaceHandlers = require('./ipc/monospace-handlers'); +``` + +In the app initialization function (after `app.whenReady()` or wherever other IPC handlers register), add: + +```js +monospaceHandlers.register(); +``` + +- [ ] **Step 2: Run all tests** + +```bash +npm test && npm run test:renderer +``` +Expected: PASS — ~230 jest + 434 vitest tests. + +- [ ] **Step 3: Lint** + +```bash +npm run lint +``` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/index.js +git commit -m "feat(main): register monospace IPC handlers at startup" +``` + +--- + +## Task 15: CHANGELOG + README touch-up + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Add 5.1.0 entry** + +Append to `CHANGELOG.md`: + +```markdown +## [5.1.0] — react-electron parity + +### Added +- Monospace font embedding: JetBrains Mono + Fira Code TTFs bundled and + injected into PDF (xelatex fontspec), DOCX (post-pandoc zip patch), + EPUB (--epub-embed-font + manifest), and HTML (base64 woff2 CSS) exports +- `monospaceFont` and `monospaceLigatures` settings exposed via + `get-monospace-settings` / `set-monospace-settings` IPC +- Renderer body classes (`mono-jetbrains-mono`, `mono-fira-code`, + `mono-ligatures-on/off`) drive `--font-mono-active` and + `--font-mono-feature` CSS tokens + +### Changed +- App identity renamed to **Markdown Converter React** + (`com.concreteinfo.markdownconverter.react`) so the dev build coexists + with the installed `markdown-converter` deb without single-instance + lock conflicts +``` + +- [ ] **Step 2: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs: 5.1.0 changelog entry" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Monospace modules (5): Tasks 2-6 ✓ +- Settings schema: Task 1 ✓ +- IPC handlers + preload + d.ts: Tasks 7, 11 ✓ +- Renderer hook + tokens: Task 10 ✓ +- TTF assets + asarUnpack + download-tools: Task 8 ✓ +- Wire into export pipeline: Task 9 ✓ +- Naming differentiation (package.json + window title + migration defaults): Task 12 ✓ +- E2E smoke test: Task 13 ✓ +- Final verification + CHANGELOG: Tasks 14, 15 ✓ + +**Placeholder scan:** No TBD/TODO/FIXME in any step. All code blocks complete. All commands explicit with expected output. + +**Type consistency:** `MonospaceSettings` shape (`{ monospaceFont: 'jetbrains-mono' | 'fira-code'; monospaceLigatures: boolean }`) used identically in Tasks 1, 7, 10, 11, 12, 13. `getMonoFontTtfPath(familyKey, weight=400)` signature consistent in Tasks 2, 9, 13. `buildPdfFontHeader(settings, ttfPath, fontFamily) → { headerPath, familyName }` consistent in Tasks 3, 9, 13. + +No issues found. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-07-23-monospace-font-embedding-and-naming.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — fresh subagent per task, two-stage review, fast iteration +2. **Inline Execution** — execute tasks in this session, batch with checkpoints + +I'll go with **Inline Execution** since you've already said "go ahead" and the work is sequential (each task's tests depend on prior commits being intact).