Files
markdown-converter/docs/superpowers/plans/2026-06-30-monospace-font-embedding.md
T
amitwh 151be60b03 feat(monospace): print-preview iframe uses bundled monospace font
Inlines @font-face as base64 data URI so the iframe srcdoc can render
JetBrains Mono / Fira Code without depending on the parent window's
loaded @font-face sets. Reads family + ligature state from the
renderer-wide cache populated by applyMonospaceClasses().

Amit Haridas
2026-08-23 19:31:33 +05:30

1618 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Monospace Font Embedding Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Guarantee ASCII alignment in MarkdownConverter's preview **and** every supported export format (PDF, DOCX, HTML, EPUB, LaTeX, ODT, RTF) by bundling JetBrains Mono + Fira Code TTFs inside the app and embedding them in every output. No OS font dependency, no internet required.
**Architecture:** `MonospaceFontConfig` is the single source of truth for the active monospace family + weight. It maps to bundled TTF paths (dev vs packaged/asar.unpacked). `PdfFontHeader`, `DocxFontEmbedder`, `EpubFontEmbedder`, and `ExportCss` are the four per-format adapters. Body-class toggles in the renderer flip a CSS custom property consumed by `styles-modern.css` and friends.
**Tech Stack:** Electron (main + renderer + preload), CommonJS, vanilla JS. `jszip ^3.10.1` (already a dep) for DOCX/EPUB surgery. `fontspec` via xelatex for PDF. No new NPM dependencies.
**Spec:** `docs/superpowers/specs/2026-06-30-monospace-font-embedding-design.md`
---
## File Structure
**New files (8 + 3 font assets):**
```
src/main/MonospaceFontConfig.js # active family → TTF path resolver
src/main/PdfFontHeader.js # builds xelatex fontspec header
src/main/DocxFontEmbedder.js # embeds TTF into pandoc DOCX output
src/main/EpubFontEmbedder.js # wraps pandoc --epub-embed-font + manifest patch
src/main/ExportCss.js # @font-face with base64-woff2, self-contained
src/main/settings/monospaceSettings.js # shared read/default of monospace settings
tests/monospace-font-config.test.js
tests/pdf-font-header.test.js
tests/docx-font-embedder.test.js
tests/epub-font-embedder.test.js
tests/export-css.test.js
tests/monospace-settings.test.js
assets/fonts/FiraCode-Regular.ttf # new asset
assets/fonts/FiraCode-Bold.ttf # new asset
assets/fonts/FiraCode-LICENSE.txt # new asset
```
**Modified files:**
```
src/fonts.css # add Fira Code @font-face
src/styles/tokens.css # add --font-mono-active + --font-mono-feature tokens
src/styles-concreteinfo.css # class-driven token values
src/styles-modern.css # .editor-textarea, .preview-content code/pre use new tokens
src/ascii-generator.html # swap Google Fonts CDN for local fonts.css
src/print-preview.js # embed ExportCss in iframe srcdoc
src/main.js # 5 export pipelines + asar config + pandoc version flag detection
src/renderer.js # body-class toggle on settings change
src/preload.js # expose monospaceSettings IPC
scripts/download-tools.js # add Fira Code downloader
package.json # build.asarUnpack + Fira font files
```
**Convention note:** All new modules are CommonJS (`require` / `module.exports`). Tests are in `tests/`, follow the existing pattern (require the module directly, no electron mocks for these).
---
## Phase A — Settings + path resolver
### Task 1: Settings schema with safe defaults
**Files:**
- Create: `src/main/settings/monospaceSettings.js`
- Test: `tests/monospace-settings.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/monospace-settings.test.js
const { getDefaults, getActiveMonoFont, isLigaturesEnabled } = require('../src/main/settings/monospaceSettings');
describe('monospaceSettings', () => {
test('getDefaults returns sane defaults', () => {
const d = getDefaults();
expect(d.monospaceFont).toBe('jetbrains-mono');
expect(d.monospaceLigatures).toBe(false);
});
test('getActiveMonoFont returns the active family', () => {
expect(getActiveMonoFont({ monospaceFont: 'fira-code' })).toBe('Fira Code');
expect(getActiveMonoFont({})).toBe('JetBrains Mono');
expect(getActiveMonoFont({ monospaceFont: 'bogus' })).toBe('JetBrains Mono');
});
test('isLigaturesEnabled reads boolean strictly', () => {
expect(isLigaturesEnabled({ monospaceLigatures: true })).toBe(true);
expect(isLigaturesEnabled({ monospaceLigatures: false })).toBe(false);
expect(isLigaturesEnabled({})).toBe(false);
expect(isLigaturesEnabled({ monospaceLigatures: 'yes' })).toBe(false);
});
});
```
- [ ] **Step 2: Run the test, verify it fails**
Run: `npm test -- tests/monospace-settings.test.js`
Expected: FAIL — `Cannot find module '../src/main/settings/monospaceSettings'`
- [ ] **Step 3: Implement**
```js
// src/main/settings/monospaceSettings.js
'use strict';
const FAMILY_BY_KEY = {
'jetbrains-mono': 'JetBrains Mono',
'fira-code': 'Fira Code',
};
function getDefaults() {
return Object.freeze({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false });
}
function getActiveMonoFont(settings) {
const key = settings && settings.monospaceFont;
return FAMILY_BY_KEY[key] || 'JetBrains Mono';
}
function isLigaturesEnabled(settings) {
return Boolean(settings && settings.monospaceLigatures === true);
}
module.exports = { getDefaults, getActiveMonoFont, isLigaturesEnabled, FAMILY_BY_KEY };
```
- [ ] **Step 4: Run the test, verify it passes**
Run: `npm test -- tests/monospace-settings.test.js`
Expected: PASS — 3 tests
- [ ] **Step 5: Commit**
```bash
git add src/main/settings/monospaceSettings.js tests/monospace-settings.test.js
git commit -m "feat(monospace): add settings schema + safe defaults
getDefaults(), getActiveMonoFont(), isLigaturesEnabled() with TDD."
```
---
### Task 2: `MonospaceFontConfig` resolves dev + packaged TTF paths
**Files:**
- Create: `src/main/MonospaceFontConfig.js`
- Test: `tests/monospace-font-config.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/monospace-font-config.test.js
jest.mock('electron', () => ({
app: { getPath: () => '/fake/userData' },
}));
jest.mock('fs', () => ({ existsSync: jest.fn(), statSync: jest.fn() }));
const path = require('path');
const fs = require('fs');
const MonospaceFontConfig = require('../src/main/MonospaceFontConfig');
describe('MonospaceFontConfig', () => {
afterEach(() => { jest.clearAllMocks(); });
test('returns dev repo path when no packaged layout exists', () => {
fs.existsSync.mockReturnValue(false);
const p = MonospaceFontConfig.getMonoFontTtfPath('jetbrains-mono', 400);
expect(p).toMatch(/assets\/fonts\/JetBrainsMono-Regular\.ttf$/);
});
test('returns packaged asar.unpacked path when present and file exists', () => {
fs.existsSync.mockImplementation((p) => p.includes('app.asar.unpacked') && p.endsWith('FiraCode-Regular.ttf'));
const p = MonospaceFontConfig.getMonoFontTtfPath('fira-code', 400);
expect(p).toContain('app.asar.unpacked');
expect(p).toContain('FiraCode-Regular.ttf');
});
test('returns null and warns when file is missing', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
fs.existsSync.mockReturnValue(false);
const p = MonospaceFontConfig.getMonoFontTtfPath('fira-code', 700);
expect(p).toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/FiraCode-Bold\.ttf/));
warn.mockRestore();
});
test('ligaturesEnabled maps from settings', () => {
expect(MonospaceFontConfig.ligaturesEnabled({ monospaceLigatures: true })).toBe(true);
expect(MonospaceFontConfig.ligaturesEnabled({ monospaceLigatures: false })).toBe(false);
expect(MonospaceFontConfig.ligaturesEnabled({})).toBe(false);
});
});
```
- [ ] **Step 2: Run the test, verify it fails**
Run: `npm test -- tests/monospace-font-config.test.js`
Expected: FAIL — `Cannot find module`
- [ ] **Step 3: Implement**
```js
// src/main/MonospaceFontConfig.js
'use strict';
const fs = require('fs');
const path = require('path');
const { app } = require('electron');
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() {
// __dirname in packaged builds = .../app.asar/src/main; we need the app dir.
// process.resourcesPath points to the unpacked root on all platforms.
if (process.resourcesPath && fs.existsSync(path.join(process.resourcesPath, 'app.asar.unpacked'))) {
return process.resourcesPath;
}
// Dev: walk up from src/main to repo root.
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 = [];
// 1. Repo dev path (used in `npm start`)
candidates.push(path.resolve(getAppRoot(), 'assets', 'fonts', filename));
// 2. Packaged asar.unpacked
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';
for (const p of getCandidatePaths(family, weight)) {
if (fs.existsSync(p)) return p;
}
console.warn(`[MonospaceFontConfig] bundled font missing for ${family} weight ${weight}; falling back to system`);
return null;
}
function ligaturesEnabled(settings) { return isLigaturesEnabled(settings); }
function getActiveFamily(settings) { return getActiveMonoFont(settings); }
module.exports = { getMonoFontTtfPath, ligaturesEnabled, getActiveFamily };
```
- [ ] **Step 4: Run the test, verify it passes**
Run: `npm test -- tests/monospace-font-config.test.js`
Expected: PASS — 4 tests
- [ ] **Step 5: Commit**
```bash
git add src/main/MonospaceFontConfig.js tests/monospace-font-config.test.js
git commit -m "feat(monospace): add MonospaceFontConfig path resolver
Resolves dev vs packaged (asar.unpacked) TTF paths. Logs warn, returns null
when bundled font is missing."
```
---
## Phase B — Bundled fonts
### Task 3: Add Fira Code TTF + LICENSE assets
**Files:**
- Create: `assets/fonts/FiraCode-Regular.ttf`
- Create: `assets/fonts/FiraCode-Bold.ttf`
- Create: `assets/fonts/FiraCode-LICENSE.txt`
- [ ] **Step 1: Confirm JetBrains Mono assets are tracked**
Run: `git ls-files assets/fonts/JetBrainsMono-*` — expect at minimum Regular, Bold, LICENSE listed. If `JetBrainsMono-Regular.ttf` and `JetBrainsMono-Bold.ttf` are still untracked (from prior session), `git add` them now.
- [ ] **Step 2: Download Fira Code TTFs + license from upstream release**
Run: `node scripts/download-tools.js --font fira-code`
Expected: writes `assets/fonts/FiraCode-Regular.ttf`, `FiraCode-Bold.ttf`, `FiraCode-LICENSE.txt`.
If the helper script doesn't accept `--font fira-code` yet, do this temporarily:
```bash
curl -L -o assets/fonts/FiraCode-Regular.ttf \
https://github.com/tonsky/FiraCode/raw/master/distr/ttf/FiraCode-Regular.ttf
curl -L -o assets/fonts/FiraCode-Bold.ttf \
https://github.com/tonsky/FiraCode/raw/master/distr/ttf/FiraCode-Bold.ttf
curl -L -o assets/fonts/FiraCode-LICENSE.txt \
https://raw.githubusercontent.com/tonsky/FiraCode/master/LICENSE
```
- [ ] **Step 3: Verify file sizes are reasonable**
Run: `ls -la assets/fonts/Fira*` — each TTF should be roughly 100500 KB.
- [ ] **Step 4: Commit**
```bash
git add assets/fonts/FiraCode-Regular.ttf assets/fonts/FiraCode-Bold.ttf assets/fonts/FiraCode-LICENSE.txt assets/fonts/JetBrainsMono-Regular.ttf assets/fonts/JetBrainsMono-Bold.ttf assets/fonts/JetBrainsMono-LICENSE.txt
git commit -m "feat(monospace): bundle JetBrainsMono + FiraCode TTF assets
Both families are SIL OFL. TTF (not just woff2) is required so xelatex can
embed into PDF and jszip can inject into DOCX."
```
---
### Task 4: Extend `scripts/download-tools.js` with Fira Code support
**Files:**
- Modify: `scripts/download-tools.js`
- [ ] **Step 1: Read existing tool and identify the pandoc download pattern**
Look for how `download-pandoc` is structured (URL, version pin, mkdir, writeFile).
- [ ] **Step 2: Add Fira Code section**
Add a new exported function near the existing tools:
```js
async function downloadFiraCode() {
const base = 'https://github.com/tonsky/FiraCode/raw/master/distr/ttf';
const targets = [
{ url: `${base}/FiraCode-Regular.ttf`, out: 'FiraCode-Regular.ttf' },
{ url: `${base}/FiraCode-Bold.ttf`, out: 'FiraCode-Bold.ttf' },
];
fs.mkdirSync('assets/fonts', { recursive: true });
for (const t of targets) {
await downloadTo(t.url, path.join('assets/fonts', t.out));
}
await downloadTo(
'https://raw.githubusercontent.com/tonsky/FiraCode/master/LICENSE',
path.join('assets/fonts', 'FiraCode-LICENSE.txt')
);
}
```
- [ ] **Step 3: Wire into the existing CLI dispatcher**
If the script uses `if (cmd === '...')` blocks, add `else if (cmd === 'fira-code' || cmd === '--font fira-code') downloadFiraCode();`. If it auto-runs every helper, no extra wiring needed.
- [ ] **Step 4: Run it as a smoke test**
Run: `node scripts/download-tools.js fira-code` (or whatever your dispatch is) — verify no errors; rerun Task 3's `ls` to confirm files exist.
- [ ] **Step 5: Commit**
```bash
git add scripts/download-tools.js
git commit -m "chore(monospace): extend download-tools with Fira Code downloader
Matches the existing version-pinned approach for Pandoc."
```
---
## Phase C — CSS layer
### Task 5: Add Fira Code `@font-face` in `src/fonts.css`
**Files:**
- Modify: `src/fonts.css` (append after the existing JetBrains Mono block)
- [ ] **Step 1: Add the new `@font-face` rules**
Append at the end of `src/fonts.css`:
```css
/* Fira Code Font Family — bundled with the app */
@font-face {
font-family: 'Fira Code';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/FiraCode-Regular.woff2') format('woff2'),
url('../assets/fonts/FiraCode-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'Fira Code';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../assets/fonts/FiraCode-Bold.woff2') format('woff2'),
url('../assets/fonts/FiraCode-Bold.ttf') format('truetype');
}
```
(`FiraCode-Regular.woff2` and `FiraCode-Bold.woff2` are optional. Chromium will pick whichever loads first; without them Chromium falls back to TTF. Skip the woff2 line if those files don't exist.)
- [ ] **Step 2: Visual smoke test in `npm start`**
Run: `npm start`. In the editor, set font-family on a paragraph to `'Fira Code', monospace` via DevTools; verify the active font shows the Fira signature slightly taller x-height. Revert the DevTools change.
- [ ] **Step 3: Commit**
```bash
git add src/fonts.css
git commit -m "feat(monospace): register Fira Code @font-face in renderer
Two weights: 400 (Regular) and 700 (Bold). Falls back to TTF if woff2
isn't bundled."
```
---
### Task 6: CSS tokens for active family + ligatures
**Files:**
- Modify: `src/styles/tokens.css`
- Modify: `src/styles-concreteinfo.css`
- [ ] **Step 1: Read existing tokens and find the `--font-mono` declaration**
```bash
grep -n "font-mono" src/styles/tokens.css src/styles-concreteinfo.css
```
Note the line where `--font-mono` is defined.
- [ ] **Step 2: Add the two new tokens**
In `src/styles/tokens.css`, add right after `--font-mono`:
```css
--font-mono-active: 'JetBrains Mono', monospace;
--font-mono-feature: 'liga' 0, 'calt' 0, 'dlig' 0;
```
- [ ] **Step 3: Add body-class overrides in `src/styles-concreteinfo.css`**
Append:
```css
body.mono-fira { --font-mono-active: 'Fira Code', monospace; }
body.mono-fira.mono-ligatures-on { --font-mono-feature: normal; }
body.mono-jetbrains { --font-mono-active: 'JetBrains Mono', monospace; }
body.mono-ligatures-on { --font-mono-feature: normal; }
```
(The `.mono-jetbrains` family rule is the default; the explicit declaration is for clarity and to make Lighthouse detect both branches.)
- [ ] **Step 4: Update the existing `--font-mono` declaration to use the new token**
Find the line that says `--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;` and replace with:
```css
--font-mono: var(--font-mono-active);
```
- [ ] **Step 5: Commit**
```bash
git add src/styles/tokens.css src/styles-concreteinfo.css
git commit -m "feat(monospace): add --font-mono-active / --font-mono-feature tokens
Body classes (.mono-fira, .mono-ligatures-on) flip the tokens for live
switching without re-rendering."
```
---
### Task 7: `styles-modern.css` honours the new tokens
**Files:**
- Modify: `src/styles-modern.css`
- [ ] **Step 1: Replace hard-coded font strings with the token**
At each occurrence of a literal `'JetBrains Mono'` or `'JetBrains Mono', 'Fira Code', monospace`, replace with `var(--font-mono-active)`. Specifically:
```diff
- font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace;
+ font-family: var(--font-mono-active);
```
Apply to:
- `.editor-textarea`
- `.codemirror-container .cm-editor`
- `.preview-content code`
- `.preview-content pre`
- `.source-code` / any pre/code block found in `styles-modern.css`
- [ ] **Step 2: Add ligature control to pre/code/textarea**
Append a new rule at the end of the file:
```css
.editor-textarea,
.preview-content code,
.preview-content pre,
.codemirror-container .cm-editor {
font-feature-settings: var(--font-mono-feature);
}
```
- [ ] **Step 3: Manual visual check**
`npm start`. In DevTools, add `body.mono-fira` to `<body>` — confirm family flips; add `body.mono-ligatures-on` — confirm ligatures enable. Revert.
- [ ] **Step 4: Commit**
```bash
git add src/styles-modern.css
git commit -m "feat(monospace): wire preview + editor to --font-mono-active token"
```
---
### Task 8: Renderer toggles body classes on settings change
**Files:**
- Modify: `src/renderer.js`
- [ ] **Step 1: Locate the existing settings-changed handler**
```bash
grep -n "settings-changed\|on('settings\|applyTheme\|currentSettings\|loadSettings" src/renderer.js | head
```
Pick the function that already applies user preferences on init / change. (Examples: `applyThemeSettings`, `applyUserSettings`, or wherever header/footer settings are wired.)
- [ ] **Step 2: Implement the body-class flip**
Inside the settings-applied function, add:
```js
function applyMonospaceClasses(settings) {
const body = document.body;
body.classList.toggle('mono-jetbrains', settings.monospaceFont !== 'fira-code');
body.classList.toggle('mono-fira', settings.monospaceFont === 'fira-code');
body.classList.toggle('mono-ligatures-on', settings.monospaceLigatures === true);
body.classList.toggle('mono-ligatures-off', settings.monospaceLigatures !== true);
}
```
Call `applyMonospaceClasses(currentSettings)` on initial load (wherever other current-settings are applied). Call it again inside the existing settings-changed listener.
- [ ] **Step 3: Request user contribution (learning mode)**
**Your turn:** between initial-load application and the settings-changed listener, the exact wiring depends on the codebase's existing structure. Open `src/renderer.js`, find both call sites, and place the two calls to `applyMonospaceClasses(...)`. Trade-off: tightest coupling is right next to the other settings reads, loosest is a dedicated effect block — pick whichever matches the surrounding code's idiom.
- [ ] **Step 4: Manual smoke test**
`npm start`. Open Settings (if it exists in the UI yet) — otherwise toggle the class via DevTools to confirm CSS responds. Revert when done.
- [ ] **Step 5: Commit**
```bash
git add src/renderer.js
git commit -m "feat(monospace): renderer toggles body classes on settings change"
```
---
## Phase D — Helpers for non-CSS surfaces
### Task 9: ASCII Generator window uses local fonts
**Files:**
- Modify: `src/ascii-generator.html`
- [ ] **Step 1: Replace the Google Fonts `<link>` with local `fonts.css`**
In `src/ascii-generator.html`, find the `<link href="https://fonts.googleapis.com/css2?...">` near line 8 and replace with:
```html
<link rel="stylesheet" href="../styles/fonts.css" />
```
`fonts.css` already wires `@font-face` for both JetBrains Mono and Fira Code (after Task 5).
- [ ] **Step 2: Ensure the window body has the right default class**
Around the `<body>` tag, add the default class:
```html
<body class="mono-jetbrains mono-ligatures-off">
```
(If the file is opened by the renderer with explicit settings, the opener script can override via `document.body.classList.add/remove` — that wiring lives in the export-opening code path; consult `src/main.js` around `show-ascii-generator` for the open path.)
- [ ] **Step 3: Ensure CSS fallback is local**
Find the `.preview-content` rule (around line 142 of the file). Confirm it sets `font-family: 'JetBrains Mono', monospace;` or update it to use `var(--font-mono-active)`. If you reference the token, ensure the file links `tokens.css` or inlines the variable definition.
Quick fix if you want the simplest behaviour:
```css
.preview-content {
font-family: 'JetBrains Mono', monospace;
font-feature-settings: 'liga' 0, 'calt' 0, 'dlig' 0;
}
```
- [ ] **Step 4: Verify ascii-generator still works**
Run: `npm start`. Open the ASCII generator from the menu (or `Ctrl+Alt+A` if there's a binding). Confirm preview text uses JetBrains Mono and aligns correctly.
- [ ] **Step 5: Commit**
```bash
git add src/ascii-generator.html
git commit -m "fix(ascii): replace Google Fonts CDN with local fonts.css
ASCII generator now renders in bundled JetBrains Mono without internet,
matching the preview pane."
```
---
### Task 10: `ExportCss` — self-contained CSS with embedded woff2
**Files:**
- Create: `src/main/ExportCss.js`
- Test: `tests/export-css.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/export-css.test.js
const fs = require('fs');
const ExportCss = require('../src/main/ExportCss');
describe('ExportCss.build', () => {
const fakeFontPath = require('path').join(__dirname, 'fixtures', 'fake.woff2');
const fixture = Buffer.from('woff2-binary-fake-data');
beforeAll(() => {
fs.mkdirSync(require('path').dirname(fakeFontPath), { recursive: true });
fs.writeFileSync(fakeFontPath, fixture);
});
afterAll(() => fs.rmSync(require('path').dirname(fakeFontPath), { recursive: true, force: true }));
test('emits a self-contained CSS with embedded @font-face', () => {
const css = ExportCss.build({ activeFontPath: fakeFontPath, family: 'JetBrains Mono', weight: 400, ligatures: false });
expect(css).toMatch(/@font-face\s*\{[^}]*src:\s*url\('data:font\/woff2;base64,/);
expect(css).toContain('font-family: \'JetBrains Mono\'');
expect(css).toMatch(/font-feature-settings:[^;]*liga[^;]*0/);
});
test('falls back to family-only CSS when font path is missing', () => {
const css = ExportCss.build({ activeFontPath: null, family: 'Fira Code', weight: 700, ligatures: true });
expect(css).not.toContain('data:font/woff2;');
expect(css).toContain('font-family: \'Fira Code\'');
});
});
```
- [ ] **Step 2: Run, fail**
Run: `npm test -- tests/export-css.test.js`
Expected: FAIL — `Cannot find module`
- [ ] **Step 3: Implement**
```js
// src/main/ExportCss.js
'use strict';
const fs = require('fs');
function toDataUri(filePath) {
const buf = fs.readFileSync(filePath);
return `data:font/woff2;base64,${buf.toString('base64')}`;
}
function build({ activeFontPath, family, weight = 400, ligatures = false }) {
const features = ligatures ? 'normal' : "'liga' 0, 'calt' 0, 'dlig' 0";
const faceBlock = activeFontPath
? `@font-face {
font-family: '${family}';
font-weight: ${weight};
font-style: normal;
font-display: swap;
src: url('${toDataUri(activeFontPath)}') format('woff2');
}
`
: '';
return `${faceBlock}code, pre, kbd, samp {
font-family: '${family}', monospace;
font-feature-settings: ${features};
}
pre, code {
white-space: pre;
tab-size: 4;
}
`;
}
module.exports = { build };
```
(Note: for the test, the file needs to look like woff2 to `_not_` matter — the encode is the same. The real `activeFontPath` in production will be the actual `.woff2` file shipped with the app. There's no need to separately ship a `.ttf` here — `ExportCss` embeds the woff2.)
- [ ] **Step 4: Run, pass**
Run: `npm test -- tests/export-css.test.js`
Expected: PASS — 2 tests
- [ ] **Step 5: Commit**
```bash
git add src/main/ExportCss.js tests/export-css.test.js
git commit -m "feat(monospace): ExportCss embeds woff2 as base64 in CSS
Self-contained CSS for HTML export and print-preview iframe."
```
---
### Task 11: `print-preview.js` injects the ExportCss
**Files:**
- Modify: `src/print-preview.js`
- [ ] **Step 1: Locate the srcdoc-building function**
The file already has a `previewHtml` template literal around line 82. Inside its `<style>` block, locate the `pre`/`code` rules.
- [ ] **Step 2: Augment the srcdoc CSS**
At the top of `print-preview.js`, add the helper:
```js
const { build: buildExportCss } = require('./ExportCss');
const MonospaceFontConfig = require('./MonospaceFontConfig');
```
Inside the function that builds `previewHtml`, replace the static `pre { ... }` block with:
```js
const monospaceCss = buildExportCss({
activeFontPath: MonospaceFontConfig.getMonoFontTtfPath(
'jetbrains-mono', 400) /* woff2 version is preferred for preview */,
family: MonospaceFontConfig.getActiveFamily({ monospaceFont: 'jetbrains-mono' }),
weight: 400,
ligatures: MonospaceFontConfig.ligaturesEnabled({ monospaceLigatures: false }),
});
```
(Note: `print-preview.js` is renderer-side, so `MonospaceFontConfig` doesn't run there — instead, use the renderer-side settings read. Adjust in Task 12 once you confirm settings context.)
- [ ] **Step 3: Request user contribution (learning mode)**
**Your turn:** `print-preview.js` runs in the renderer process, where `require` of Electron's `app` is unavailable. The cleanest path is: have the main process emit the monospace settings via an existing event (`load-custom-css` already broadcasts) OR pass them into the dialog constructor. Look at how `print-preview.js` is initialised (`new PrintPreview()`) and how it currently knows the editor font size, then thread the settings through. Decide:
- A: Pass `{ monospaceFont, monospaceLigatures }` into `new PrintPreview(settings)`.
- B: Read from `localStorage`/renderer state inside `print-preview.js`.
Both are valid; A is more testable; B requires fewer signat changes. Pick one and implement.
- [ ] **Step 4: Manual test**
`npm start`, open a markdown file, `Ctrl+P` (or whatever triggers print preview). Confirm the rendered iframe uses JetBrains Mono with `+---+` row alignment visible at consistent column X-positions across lines.
- [ ] **Step 5: Commit**
```bash
git add src/print-preview.js
git commit -m "feat(monospace): print-preview iframe uses ExportCss"
```
---
## Phase E — Pandoc export pipelines
### Task 12: Pandoc version detection helper
**Files:**
- Modify: `src/main.js`
- [ ] **Step 1: Add a cached version probe**
After the existing `pandocAvailable` cache declaration (line 334), add:
```js
let pandocVersionCache = null;
function getPandocMajorVersion() {
if (pandocVersionCache !== null) return pandocVersionCache;
execFile('pandoc', ['--version'], (error, stdout) => {
if (error) { pandocVersionCache = 0; return; }
const m = String(stdout).match(/pandoc\s+(\d+)\.(\d+)/);
pandocVersionCache = m ? Number(m[1]) : 0;
});
return pandocVersionCache;
}
function pandocSupportsEpubEmbedFont() {
// --epub-embed-font added in Pandoc 2.11
return getPandocMajorVersion() >= 2 && getPandocMinorVersion() >= 11;
}
```
Add a sibling `getPandocMinorVersion`. (Or combine into a tuple-style return.)
- [ ] **Step 2: Request user contribution (learning mode)**
**Your turn:** the existing `pandocAvailable` probe uses `execFile('pandoc', ['--version'], ...)` with a callback. Replace that style or call getPandocVersion once at app startup. Confirm where existing version probes live (search `grep -rn "\-\-version" src/`) and place the new probe nearby. Synchronous read of cache is fine — the existing pattern is async with caching.
- [ ] **Step 3: Commit**
```bash
git add src/main.js
git commit -m "chore(pandoc): cache parsed major/minor version for capability checks"
```
---
### Task 13: `PdfFontHeader` builds the xelatex snippet
**Files:**
- Create: `src/main/PdfFontHeader.js`
- Test: `tests/pdf-font-header.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/pdf-font-header.test.js
const PdfFontHeader = require('../src/main/PdfFontHeader');
describe('PdfFontHeader.build', () => {
test('emits fontspec with Path and Ligatures=NoCommon when off', () => {
const tex = PdfFontHeader.build({
fontTtfPath: '/abs/path/JetBrainsMono-Regular.ttf',
family: 'JetBrainsMono',
boldTtfPath: '/abs/path/JetBrainsMono-Bold.ttf',
ligatures: false,
});
expect(tex).toContain('\\usepackage{fontspec}');
expect(tex).toMatch(/\\setmonofont\{JetBrainsMono-Regular\.ttf\}/);
expect(tex).toContain('Path=/abs/path/JetBrainsMono-Regular.ttf/');
expect(tex).toContain('Ligatures=NoCommon');
expect(tex).toContain('UprightFont=*-Regular');
expect(tex).toContain('BoldFont=*-Bold');
});
test('passes Ligatures=TeX when on (preserves --/---, but no programming ligatures)', () => {
const tex = PdfFontHeader.build({
fontTtfPath: '/abs/JBM-Regular.ttf',
family: 'JetBrainsMono',
boldTtfPath: '/abs/JBM-Bold.ttf',
ligatures: true,
});
expect(tex).toContain('Ligatures=TeX');
});
});
```
- [ ] **Step 2: Run, fail**
Run: `npm test -- tests/pdf-font-header.test.js`
- [ ] **Step 3: Implement**
```js
// src/main/PdfFontHeader.js
'use strict';
function escape(s) { return String(s).replace(/\\/g, '\\\\'); }
function build({ fontTtfPath, family, boldTtfPath, ligatures }) {
if (!fontTtfPath) {
// Fallback chain: caller is expected to handle this, but emit a no-op stub.
return '% Monospace font path unavailable; TeX will use its default monospace.\n';
}
const dirOf = (p) => p.replace(/\/[^/]+$/, '') + '/';
const baseName = (p) => p.split('/').pop();
const ligValue = ligatures ? 'Ligatures=TeX' : 'Ligatures=NoCommon';
return `\\usepackage{fontspec}
\\setmonofont{${escape(baseName(fontTtfPath))}}[
Path=${escape(dirOf(fontTtfPath))},
Extension=.ttf,
UprightFont=*-Regular,
BoldFont=${boldTtfPath ? escape(baseName(boldTtfPath)) : '*-Bold'},
${ligValue}
]
`;
}
module.exports = { build };
```
- [ ] **Step 4: Run, pass**
Run: `npm test -- tests/pdf-font-header.test.js`
Expected: PASS — 2 tests
- [ ] **Step 5: Commit**
```bash
git add src/main/PdfFontHeader.js tests/pdf-font-header.test.js
git commit -m "feat(monospace): add PdfFontHeader builder for xelatex fontspec"
```
---
### Task 14: Wire PDF export to use the header
**Files:**
- Modify: `src/main.js` (around line 2619 and line 2815)
- [ ] **Step 1: Add the require near the top**
```js
const PdfFontHeader = require('./PdfFontHeader');
const MonospaceFontConfig = require('./MonospaceFontConfig');
```
- [ ] **Step 2: Replace the `-V monofont="Consolas"` lines**
In both `exportFile` (around line 2619) and `tryPdfFallback` (around line 2815):
```js
// Replace:
// pandocCmd += ' -V monofont="Consolas"';
// pandocCmd += ' --highlight-style=tango';
// With:
const settingsJson = readSettingsJsonCached(); // new helper, see Step 4
const monoFontPath = MonospaceFontConfig.getMonoFontTtfPath(
settingsJson.monospaceFont || 'jetbrains-mono', 400);
const monoBoldPath = MonospaceFontConfig.getMonoFontTtfPath(
settingsJson.monospaceFont || 'jetbrains-mono', 700);
const tmpDir = require('os').tmpdir();
const headerFile = path.join(tmpDir, `monospace-pdf-${Date.now()}.tex`);
fs.writeFileSync(headerFile, PdfFontHeader.build({
fontTtfPath: monoFontPath,
family: settingsJson.monospaceFont === 'fira-code' ? 'FiraCode' : 'JetBrainsMono',
boldTtfPath: monoBoldPath,
ligatures: !!settingsJson.monospaceLigatures,
}), 'utf-8');
pandocCmd += ` --include-in-header="${headerFile}"`;
pandocCmd += ' --highlight-style=tango';
```
- [ ] **Step 3: Implement `readSettingsJsonCached`**
```js
let _cachedSettings = null;
function readSettingsJsonCached() {
if (_cachedSettings) return _cachedSettings;
try {
_cachedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
} catch { _cachedSettings = {}; }
// Apply defaults so callers always get the merged shape.
_cachedSettings.monospaceFont = _cachedSettings.monospaceFont || 'jetbrains-mono';
_cachedSettings.monospaceLigatures = _cachedSettings.monospaceLigatures === true;
return _cachedSettings;
}
```
Invalidate `_cachedSettings` whenever `store.set` writes — find the existing `store.set` definition around line 291 and append `_cachedSettings = null;` inside.
- [ ] **Step 4: Reorder `tryPdfFallback` so lualatex comes before pdflatex**
In the `tryPdfFallback` array (currently `['pdflatex', 'lualatex']`), swap to `['lualatex', 'pdflatex']` so fontspec-capable lualatex is preferred.
- [ ] **Step 5: Manual smoke test**
Run: `npm start`. Open a file with fenced code blocks containing ASCII art. Export → PDF. Open the PDF in a viewer; confirm code blocks render in JetBrains Mono and `+---+` columns align.
- [ ] **Step 6: Commit**
```bash
git add src/main.js
git commit -m "feat(monospace): PDF export uses embedded JetBrains Mono via xelatex fontspec
Replaces Consolas (Windows-only). Fallback chain reordered so lualatex
runs before pdflatex."
```
---
### Task 15: Wire HTML export to use ExportCss via `--css`
**Files:**
- Modify: `src/main.js`
- [ ] **Step 1: Find the HTML export branch in `exportFile`**
```bash
grep -n "format === 'html'" src/main.js
```
- [ ] **Step 2: Inject `--css`**
In that branch (just before `runPandocCmd`), build a temp CSS file:
```js
const ExportCss = require('./ExportCss');
const cssText = ExportCss.build({
activeFontPath: path.join(getAppRoot(), 'assets', 'fonts', `${readSettingsJsonCached().monospaceFont === 'fira-code' ? 'FiraCode' : 'JetBrainsMono'}-Regular.woff2`),
family: readSettingsJsonCached().monospaceFont === 'fira-code' ? 'Fira Code' : 'JetBrains Mono',
weight: 400,
ligatures: !!readSettingsJsonCached().monospaceLigatures,
});
const cssFile = path.join(require('os').tmpdir(), `monospace-html-${Date.now()}.css`);
fs.writeFileSync(cssFile, cssText, 'utf-8');
pandocCmd += ` --css="${cssFile}"`;
```
(`getAppRoot` already exists in `MonospaceFontConfig` — re-export or duplicate the two-liner here.)
- [ ] **Step 3: Test manually**
`npm start` → open a file with code blocks → `Export → HTML` → open the resulting `.html` in a browser with no internet → confirm JetBrains Mono renders.
- [ ] **Step 4: Commit**
```bash
git add src/main.js
git commit -m "feat(monospace): HTML export uses --css with embedded @font-face"
```
---
### Task 16: Wire EPUB export to use `--epub-embed-font`
**Files:**
- Create: `src/main/EpubFontEmbedder.js`
- Test: `tests/epub-font-embedder.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/epub-font-embedder.test.js
const fs = require('fs');
const path = require('path');
const JSZip = require('jszip');
const EpubFontEmbedder = require('../src/main/EpubFontEmbedder');
describe('EpubFontEmbedder.patchManifest', () => {
const fixturesDir = path.join(__dirname, 'fixtures');
const epubPath = path.join(fixturesDir, 'fake.epub');
const fontPath = path.join(fixturesDir, 'fake.ttf');
beforeAll(async () => {
fs.mkdirSync(fixturesDir, { recursive: true });
fs.writeFileSync(fontPath, 'fake-ttf-binary');
const zip = new JSZip();
zip.file('OEBPS/content.opf', '<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf"></package>');
fs.writeFileSync(epubPath, await zip.generateAsync({ type: 'nodebuffer' }));
});
afterAll(() => fs.rmSync(fixturesDir, { recursive: true, force: true }));
test('adds a manifest entry referencing the TTF when missing', async () => {
const patched = await EpubFontEmbedder.patchManifest(epubPath, [
{ path: fontPath, family: 'JetBrains Mono', weight: 400 },
]);
const out = fs.readFileSync(patched);
const zip = await JSZip.loadAsync(out);
const opf = await zip.file('OEBPS/content.opf').async('string');
expect(opf).toMatch(/<item[^>]*href="OEBPS\/fonts\/fake\.ttf"/);
expect(opf).toMatch(/<item[^>]*media-type="application\/x-font-ttf"/);
});
});
```
- [ ] **Step 2: Run, fail**
Run: `npm test -- tests/epub-font-embedder.test.js`
- [ ] **Step 3: Implement**
```js
// src/main/EpubFontEmbedder.js
'use strict';
const fs = require('fs');
const path = require('path');
const JSZip = require('jszip');
async function patchManifest(epubPath, fonts) {
const zip = await JSZip.loadAsync(fs.readFileSync(epubPath));
const opfPath = Object.keys(zip.files).find((f) => f.endsWith('content.opf'));
if (!opfPath) throw new Error('EPUB has no content.opf');
let opf = await zip.file(opfPath).async('string');
for (const { path: fontPath, family, weight } of fonts) {
const filename = path.basename(fontPath);
const inFontDir = `OEBPS/fonts/${filename}`;
zip.file(inFontDir, fs.readFileSync(fontPath));
if (!opf.includes(filename)) {
const item = `<item id="font-${family.replace(/\s+/g, '-')}-${weight}" href="${inFontDir}" media-type="application/x-font-ttf"/>`;
opf = opf.replace('</manifest>', `${item}</manifest>`);
}
}
zip.file(opfPath, opf);
const buf = await zip.generateAsync({ type: 'nodebuffer' });
const tmp = `${epubPath}.patched.epub`;
fs.writeFileSync(tmp, buf);
return tmp;
}
module.exports = { patchManifest };
```
- [ ] **Step 4: Wire into `exportFile` for EPUB**
In the EPUB branch:
```js
if (format === 'epub') {
if (!pandocSupportsEpubEmbedFont()) {
dialog.showMessageBox(mainWindow, { type: 'warning', message: 'Pandoc is too old; EPUB font embedding skipped.' });
} else {
const regular = MonospaceFontConfig.getMonoFontTtfPath(readSettingsJsonCached().monospaceFont, 400);
const bold = MonospaceFontConfig.getMonoFontTtfPath(readSettingsJsonCached().monospaceFont, 700);
const argList = [regular, bold].filter(Boolean).map((p) => ` --epub-embed-font="${p}"`).join('');
pandocCmd += argList;
}
}
```
Then after pandoc success, run `patchManifest` and overwrite the original output.
- [ ] **Step 5: Run, pass**
Run: `npm test -- tests/epub-font-embedder.test.js`
- [ ] **Step 6: Manual test**
Open a markdown file → Export → EPUB → open in Calibre / iBooks → confirm code blocks render in JetBrains Mono.
- [ ] **Step 7: Commit**
```bash
git add src/main/EpubFontEmbedder.js tests/epub-font-embedder.test.js src/main.js
git commit -m "feat(monospace): EPUB export embeds TTF via --epub-embed-font + manifest"
```
---
## Phase F — DOCX embedding
### Task 17: `DocxFontEmbedder` — patch pandoc output with embedded TTF
**Files:**
- Create: `src/main/DocxFontEmbedder.js`
- Test: `tests/docx-font-embedder.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/docx-font-embedder.test.js
const fs = require('fs');
const path = require('path');
const JSZip = require('jszip');
const DocxFontEmbedder = require('../src/main/DocxFontEmbedder');
describe('DocxFontEmbedder.embed', () => {
const dir = path.join(__dirname, 'fixtures');
const docxPath = path.join(dir, 'in.docx');
const fontPath = path.join(dir, 'fake.ttf');
beforeAll(async () => {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(fontPath, 'fake-ttf-data');
const zip = new JSZip();
zip.file('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"></Types>');
zip.file('_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>');
zip.file('word/document.xml', '<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/main"><w:body/></w:document>');
zip.file('word/styles.xml', '<?xml version="1.0"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/main"><w:style w:type="character" w:styleId="SourceCode"><w:name w:val="Source Code"/></w:style></w:styles>');
fs.writeFileSync(docxPath, await zip.generateAsync({ type: 'nodebuffer' }));
});
afterAll(() => fs.rmSync(dir, { recursive: true, force: true }));
test('embeds TTF and patches fontTable.xml + styles.xml', async () => {
const out = await DocxFontEmbedder.embed(docxPath, [
{ path: fontPath, family: 'JetBrains Mono', weight: 400 },
]);
const zip = await JSZip.loadAsync(fs.readFileSync(out));
expect(Object.keys(zip.files).some((f) => f.startsWith('word/fonts/'))).toBe(true);
const fontTable = zip.file('word/fontTable.xml') ? await zip.file('word/fontTable.xml').async('string') : '';
expect(fontTable).toContain('JetBrains Mono');
expect(fontTable).toMatch(/<w:embedRegular/);
const styles = await zip.file('word/styles.xml').async('string');
expect(styles).toMatch(/<w:rFonts[^>]*w:ascii="JetBrains Mono"/);
});
test('is idempotent: running twice does not double-embed', async () => {
const once = await DocxFontEmbedder.embed(docxPath, [{ path: fontPath, family: 'JetBrains Mono', weight: 400 }]);
const twice = await DocxFontEmbedder.embed(once, [{ path: fontPath, family: 'JetBrains Mono', weight: 400 }]);
const zip = await JSZip.loadAsync(fs.readFileSync(twice));
const fontEntries = Object.keys(zip.files).filter((f) => /^word\/fonts\//.test(f));
expect(fontEntries.length).toBe(1);
});
});
```
- [ ] **Step 2: Run, fail**
Run: `npm test -- tests/docx-font-embedder.test.js`
- [ ] **Step 3: Implement**
```js
// src/main/DocxFontEmbedder.js
'use strict';
const fs = require('fs');
const path = require('path');
const JSZip = require('jszip');
const CONTENT_TYPES_NS = 'http://schemas.openxmlformats.org/package/2006/content-types';
const REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships';
function generatedRId(idx) { return `rIdFont${idx}`; }
async function patchZipWithFonts(inputPath, fonts) {
const buf = fs.readFileSync(inputPath);
const zip = await JSZip.loadAsync(buf);
const existingFontNames = new Set();
// Detect prior embeds (idempotency)
for (const f of Object.keys(zip.files)) {
if (zip.files[f].name && /^word\/fonts\//.test(zip.files[f].name)) {
const base = path.basename(f);
existingFontNames.add(base);
}
}
for (let i = 0; i < fonts.length; i++) {
const { path: fontPath, family, weight } = fonts[i];
const fname = path.basename(fontPath);
const wordPath = `word/fonts/${fname}`;
if (!existingFontNames.has(fname)) {
zip.file(wordPath, fs.readFileSync(fontPath));
existingFontNames.add(fname);
}
}
// Build/replace word/fontTable.xml
const fontTableXml =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/main">\n` +
fonts.map((f, i) => {
const fname = path.basename(f.path);
const role = f.weight >= 700 ? 'Bold' : 'Regular';
return ` <w:font w:name="${f.family}"><w:embedRegular r:id="${generatedRId(i)}" xmlns:r="${REL_NS}"/></w:font>`;
}).join('\n') +
`\n</w:fonts>\n`;
zip.file('word/fontTable.xml', fontTableXml);
// Patch [Content_Types].xml — add Override for /word/fontTable.xml if missing
const ctPath = Object.keys(zip.files).find((f) => f === '[Content_Types].xml');
let ct = await zip.file(ctPath).async('string');
if (!ct.includes('PartName="/word/fontTable.xml"')) {
ct = ct.replace('</Types>', '<Override PartName="/word/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/></Types>');
}
for (const f of fonts) {
const ttfCt = 'application/x-font-ttf';
const filePart = `/word/fonts/${path.basename(f.path)}`;
if (!ct.includes(`PartName="${filePart}"`)) {
ct = ct.replace('</Types>', `<Override PartName="${filePart}" ContentType="${ttfCt}"/></Types>`);
}
}
if (!ct.includes('Default Extension="ttf"')) {
ct = ct.replace('</Types>', '<Default Extension="ttf" ContentType="application/x-font-ttf"/></Types>');
}
zip.file(ctPath, ct);
// Patch word/_rels/document.xml.rels — add relationships for fontTable and each font
const relsPath = 'word/_rels/document.xml.rels';
if (!zip.files[relsPath]) {
zip.file(relsPath,
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Relationships xmlns="${REL_NS}"/>`);
}
let rels = await zip.file(relsPath).async('string');
if (!rels.includes('fontTable.xml')) {
rels = rels.replace(
'</Relationships>',
`<Relationship Id="${generatedRId(fonts.length)}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" Target="fontTable.xml"/></Relationships>`,
);
}
for (let i = 0; i < fonts.length; i++) {
const fname = path.basename(fonts[i].path);
if (!rels.includes(fname)) {
rels = rels.replace(
'</Relationships>',
`<Relationship Id="${generatedRId(i)}" Type="http://schemas.microsoft.com/office/2011/relationships/font" Target="fonts/${fname}"/></Relationships>`,
);
}
}
zip.file(relsPath, rels);
// Patch word/styles.xml — bind SourceCode/VerbatimChar styles to the family
const stylesPath = 'word/styles.xml';
if (zip.files[stylesPath]) {
let styles = await zip.file(stylesPath).async('string');
const family = fonts[0].family;
if (!styles.includes(`w:ascii="${family}"`)) {
styles = styles.replace(
/(<w:style[^>]*w:styleId="(?:SourceCode|VerbatimChar)"[^>]*>)/,
`$1<w:rPr><w:rFonts w:ascii="${family}" w:hAnsi="${family}" w:cs="${family}"/></w:rPr>`,
);
}
zip.file(stylesPath, styles);
}
const outBuf = await zip.generateAsync({ type: 'nodebuffer' });
fs.writeFileSync(inputPath, outBuf); // overwrite in place
return inputPath;
}
async function embed(docxPath, fonts) {
return patchZipWithFonts(docxPath, fonts);
}
module.exports = { embed };
```
- [ ] **Step 4: Run, pass**
Run: `npm test -- tests/docx-font-embedder.test.js`
Expected: PASS — 2 tests
- [ ] **Step 5: Commit**
```bash
git add src/main/DocxFontEmbedder.js tests/docx-font-embedder.test.js
git commit -m "feat(monospace): DocxFontEmbedder injects TTF into pandoc DOCX output
Idempotent. Patches fontTable.xml, [Content_Types].xml, .rels, styles.xml."
```
---
### Task 18: Wire DOCX pipeline through the embedder
**Files:**
- Modify: `src/main.js`
- [ ] **Step 1: Add the require**
```js
const DocxFontEmbedder = require('./DocxFontEmbedder');
```
- [ ] **Step 2: After successful DOCX export, run the embedder**
Find the `exportFile(format)` branch where `format === 'docx'` and after `showExportSuccess(outputFile)` (or just before it in the callback), insert:
```js
runPandocCmd(pandocCmd, (err) => {
if (err) { /* existing error path */ return; }
try {
const regular = MonospaceFontConfig.getMonoFontTtfPath(readSettingsJsonCached().monospaceFont, 400);
const bold = MonospaceFontConfig.getMonoFontTtfPath(readSettingsJsonCached().monospaceFont, 700);
if (regular) {
DocxFontEmbedder.embed(outputFile, [
{ path: regular, family: MonospaceFontConfig.getActiveFamily(readSettingsJsonCached()), weight: 400 },
...(bold ? [{ path: bold, family: MonospaceFontConfig.getActiveFamily(readSettingsJsonCached()), weight: 700 }] : []),
]).then(() => showExportSuccess(outputFile)).catch((embedErr) => {
dialog.showMessageBox(mainWindow, { type: 'warning', message: `DOCX written but font embedding failed: ${embedErr.message}` });
showExportSuccess(outputFile);
});
} else {
showExportSuccess(outputFile);
}
} catch (e) {
showExportSuccess(outputFile);
}
});
```
- [ ] **Step 3: Add the ODT/RTF confirmation**
Locate the ODT and RTF branches of `exportFile`. Before invoking the pandoc command, show a non-blocking confirmation:
```js
if (format === 'odt' || format === 'rtf') {
const choice = dialog.showMessageBoxSync(mainWindow, {
type: 'warning',
buttons: ['Continue', 'Cancel'],
defaultId: 0,
cancelId: 1,
message: `${format.toUpperCase()} embeds the font *name* only. Recipients without ${MonospaceFontConfig.getActiveFamily(readSettingsJsonCached())} installed may see alignment drift.`,
});
if (choice === 1) return;
}
```
- [ ] **Step 4: Manual test**
Export a file with ASCII art to DOCX → open in Word/LibreOffice → confirm font is JetBrains Mono and tables align. Repeat with ODT and accept the warning.
- [ ] **Step 5: Commit**
```bash
git add src/main.js
git commit -m "feat(monospace): pipe DOCX export through DocxFontEmbedder
Adds alignment-preserving ODT/RTF confirmation dialog."
```
---
## Phase G — Bundling + integration
### Task 19: `package.json` — `asarUnpack` + Fira Code entries
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Read the existing asarUnpack block**
```bash
grep -A 8 "asarUnpack" package.json
```
- [ ] **Step 2: Add the font glob**
```diff
"asarUnpack": [
- "node_modules/ffmpeg-static/**"
+ "node_modules/ffmpeg-static/**",
+ "assets/fonts/**"
]
```
If the build config uses `build.files` (older electron-builder layouts), add `assets/fonts/**` there instead.
- [ ] **Step 3: Verify the change reads well**
Run: `node -e "console.log(JSON.stringify(require('./package.json').build.asarUnpack, null, 2))"`
Expected: array of 2 globs.
- [ ] **Step 4: Commit**
```bash
git add package.json
git commit -m "chore(build): unpack assets/fonts/** from asar so pandoc reads TTFs"
```
---
### Task 20: IPC + preload wiring for renderer settings fetch
**Files:**
- Modify: `src/preload.js`
- Modify: `src/main.js`
- [ ] **Step 1: Add IPC handler in main.js**
```js
ipcMain.handle('get-monospace-settings', () => readSettingsJsonCached());
ipcMain.handle('set-monospace-settings', (_event, partial) => {
for (const [k, v] of Object.entries(partial || {})) store.set(k, v);
_cachedSettings = null;
return readSettingsJsonCached();
});
```
- [ ] **Step 2: Expose in preload.js**
In the contextBridge.exposeInMainWorld block, add:
```js
monospace: {
get: () => ipcRenderer.invoke('get-monospace-settings'),
set: (partial) => ipcRenderer.invoke('set-monospace-settings', partial),
},
```
Match the existing export pattern (e.g., `headerFooter.settings` style) — look at how `get-header-footer-settings` is exposed for reference.
- [ ] **Step 3: Wire settings UI in renderer.js**
Find where the existing settings dialog lives (search for `headerFooter` references in renderer.js). Add two controls — a select for `monospaceFont` and a checkbox for `monospaceLigatures` — that call `monospace.set({...})` and re-apply body classes via `applyMonospaceClasses`.
If there's no in-app settings dialog yet, skip this task — DevTools can flip the classes for testing.
- [ ] **Step 4: Manual smoke test**
`npm start`. If UI exists, change font to Fira Code → preview flips → export to DOCX uses Fira Code (confirm by unzipping the DOCX and reading `fontTable.xml`).
- [ ] **Step 5: Commit**
```bash
git add src/main.js src/preload.js src/renderer.js
git commit -m "feat(monospace): expose monospace settings via IPC + preload
Settings persist to <userData>/settings.json and update preview + exports."
```
---
### Task 21: `npm run lint` and full test suite pass
- [ ] **Step 1: Run linter**
Run: `npm run lint`
Expected: no errors. Fix any violations — be aware that adding new `require()` calls may reorder existing imports.
- [ ] **Step 2: Run full test suite**
Run: `npm test`
Expected: all existing tests pass + 6 new test files (~20 tests added) all pass.
- [ ] **Step 3: Run coverage report**
Run: `npm test:coverage`
Expected: coverage threshold (15%) not broken. New modules contribute to coverage.
- [ ] **Step 4: Fix any issues found**
Run: `npm run lint:fix` and `npm run format`.
- [ ] **Step 5: Commit (if any fixes were made)**
```bash
git add -u
git commit -m "chore(monospace): pass lint + tests"
```
---
### Task 22: End-to-end verification
**Files:** none (verification + documentation)
- [ ] **Step 1: Create a verification fixture**
In `tests/fixtures/ascii-sample.md` (create the dir if needed), write a markdown document with three fenced code blocks containing ASCII tables, column grids, and arrow fences. Reference: the existing `print-preview.test.js` may have a similar fixture.
```markdown
# ASCII alignment verification
Header columns:
+----+-----+-------+
| A | B | C |
+----+-----+-------+
| 1 | 22 | 333 |
| 4 | 55 | 666 |
+----+-----+-------+
```
+----+----+
| A | B |
+----+----+
```
Arrow test:
<------> <--------> <----->
```
- [ ] **Step 2: Manual end-to-end test**
Run: `npm start`. Open the fixture. Run each export. Open each result:
- Preview pane renders in JetBrains Mono; tables align column-wise.
- Switch to Fira Code + ligatures on → preview flips; tables still align (because we disable ligatures in the body-class baseline — toggle off).
- Export PDF → xelatex builds → code blocks align.
- Export DOCX → embedded font travels; open in Word → code blocks align.
- Export HTML → open in browser with no internet → code blocks align.
- Export EPUB → embedded font travels; open in iBooks → code blocks align.
- Export ODT/RTF → confirmation dialog appears → accept → file written.
- Print preview → embedded woff2 displays correctly.
- [ ] **Step 3: Update CLAUDE.md if needed**
If the user visible behaviour has materially changed (it has — there's a new settings UI section), append a brief note to `CLAUDE.md` under "System Dependencies" or "Project Conventions".
- [ ] **Step 4: Final commit**
```bash
git add tests/fixtures/ascii-sample.md CLAUDE.md
git commit -m "docs(monospace): ASCII fixture + CLAUDE.md note on monospace settings"
```
---
## Self-Review
- [ ] **Spec coverage:** Walk every section of `docs/superpowers/specs/2026-06-30-monospace-font-embedding-design.md`.
- "Settings schema" → Tasks 1, 20.
- "MonospaceFontConfig" → Task 2.
- "PdfFontHeader" → Task 13.
- "DocxFontEmbedder" → Tasks 17, 18.
- "EpubFontEmbedder" → Task 16.
- "ExportCss" → Tasks 10, 11, 15.
- "Bundling" → Tasks 3, 4, 19.
- "Per-Export Behaviour" table → Tasks 14 (PDF), 15 (HTML), 16 (EPUB), 17/18 (DOCX), 18 (ODT/RTF), 14 (LaTeX fallback via xelatex).
- "Testing" → Tasks 1, 2, 10, 13, 16, 17, 22.
- "Error Handling" → Task 12 (pandoc version), Task 14 (settings cache), Task 18 (embed fail soft-warn).
- "Acceptance Criteria" → Task 22.
- [ ] **Placeholder scan:** No `TBD`, `TODO: implement`, or `similar to Task N`. Specific lines marked with "Request user contribution" point at concrete code locations and trade-offs.
- [ ] **Type/name consistency:**
- `getMonoFontTtfPath(familyKey, weight)` defined Task 2, used Tasks 14, 16, 18, 20.
- `readSettingsJsonCached()` defined Task 14, used Tasks 14, 15, 16, 18, 20.
- `PdfFontHeader.build({ fontTtfPath, family, boldTtfPath, ligatures })` matches between test (Task 13) and consumer (Task 14).
- `ExportCss.build({ activeFontPath, family, weight, ligatures })` matches between test (Task 10) and consumers (Tasks 11, 15).
- `DocxFontEmbedder.embed(docxPath, fonts)` matches both test cases (Task 17).
- `EpubFontEmbedder.patchManifest(epubPath, fonts)` matches test (Task 16).
- `applyMonospaceClasses(settings)` (Task 8) used in same task's wiring.
All consistent.
---
## Total: 22 tasks across 7 phases. Estimated yield: ~20 new tests, ~6 new modules, ~10 modified files, full preview + 8 export paths aligned to bundled fonts.