mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +05:30
docs(spec): monospace font embedding + naming differentiation design
Port master's v4.5.0 monospace font embedding feature (JetBrains Mono + Fira Code TTF assets, embedded into PDF/DOCX/EPUB/HTML exports) and rename branch identity to Markdown Converter React so dev build coexists with the installed markdown-converter deb without appId/productName collisions. Amit Haridas
This commit is contained in:
@@ -0,0 +1,258 @@
|
|||||||
|
# Monospace Font Embedding + Naming Differentiation
|
||||||
|
|
||||||
|
**Date:** 2026-07-22
|
||||||
|
**Branch:** react-electron
|
||||||
|
**Goal:** Bring `react-electron` to feature parity with `master` by porting the v4.5.0 "embedded monospace font" feature, and differentiate the dev build identity from the installed `markdown-converter` deb so the two can coexist on the same machine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The `react-electron` branch is the active development branch — a major rewrite of the renderer in React 19 + TypeScript + Tailwind/shadcn while keeping the main process in CommonJS. As of commit `bb4e874` it is **ahead** of `master` in nearly every dimension (auto-updater, crash writer, migration runner, command palette, first-run wizard, updates settings, crash report modal, 30+ React modals, large-file mode, scoped CSS).
|
||||||
|
|
||||||
|
A repo-wide diff (`git diff --stat master..react-electron`) shows 301 files changed, +38,293/-26,786 lines. Master is **behind** react-electron.
|
||||||
|
|
||||||
|
The **only** feature present in `master` and absent in `react-electron` is the monospace-font-embedding feature added in master v4.5.0 (commits `3f8679c` through `b218c76`, 19 commits). It bundles JetBrains Mono + Fira Code TTFs and embeds them into PDF/DOCX/EPUB/HTML exports so ASCII art and code blocks render with the exact font the user sees in the preview.
|
||||||
|
|
||||||
|
A second concern: the user has `markdown-converter` installed as a `.deb`. Running `npm run dev` from this repo currently uses the same `appId` (`com.concreteinfo.markdownconverter`) and `productName` (`MarkdownConverter`), which triggers Electron's single-instance lock and confuses file-association routing. We rename this branch's identity to `Markdown Converter React` / `markdown-converter-react` to coexist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
1. Port the monospace font embedding feature from `master` v4.5.0 into the react-electron branch:
|
||||||
|
- All five `src/main/*.js` modules (`MonospaceFontConfig`, `PdfFontHeader`, `DocxFontEmbedder`, `EpubFontEmbedder`, `ExportCss`)
|
||||||
|
- Settings schema module (`src/main/settings/monospaceSettings.js`)
|
||||||
|
- Two new IPC handlers (`get-monospace-settings`, `set-monospace-settings`) plus preload allowlist and TypeScript declaration
|
||||||
|
- Renderer body-class toggle and CSS tokens (`--font-mono-active`, `--font-mono-feature`)
|
||||||
|
- Bundled font assets: `JetBrainsMono-{Regular,Bold}.ttf`, `FiraCode-{Regular,Bold}.ttf` plus LICENSE files
|
||||||
|
- `asarUnpack` directive for `assets/fonts/**`
|
||||||
|
- `download-tools.js` extension to fetch Fira Code
|
||||||
|
- Wire `buildMonospaceExportCss`, `buildPdfFontHeader`, `DocxFontEmbedder.embed`, `EpubFontEmbedder.embed` into `src/main/index.js` export pipelines (PDF, DOCX, EPUB, HTML paths)
|
||||||
|
- Print-preview font integration (`src/renderer/components/preview/PrintPreviewFrame.tsx` or equivalent)
|
||||||
|
- Tests: one test file per new module mirroring master's coverage plus an E2E smoke test
|
||||||
|
|
||||||
|
2. Rename branch identity:
|
||||||
|
- `package.json` `name` → `markdown-converter-react`
|
||||||
|
- `package.json` `build.productName` → `Markdown Converter React`
|
||||||
|
- `package.json` `build.appId` → `com.concreteinfo.markdownconverter.react`
|
||||||
|
- `package.json` `build.linux.executableName` → `markdown-converter-react`
|
||||||
|
- Window title prefix → `Markdown Converter — React Dev` in dev mode (use `process.env.VITE_DEV_SERVER_URL` as the gate)
|
||||||
|
- Settings file path remains `userData/settings.json` (no migration needed) — but add a new key `appVariant: "react"` so the renderer can show the correct title
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- React renderer rewrites (already ahead of master)
|
||||||
|
- Auto-updater, crash, migration (already ahead of master)
|
||||||
|
- Tauri / Web migration
|
||||||
|
- Pandoc version bump
|
||||||
|
- Documentation beyond this spec, the implementation plan, and the changelog entry
|
||||||
|
- Changes to v4-to-v5 settings migration (we leave the existing runner alone)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Module layout (new files)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/
|
||||||
|
MonospaceFontConfig.js # path resolver — finds bundled TTF
|
||||||
|
PdfFontHeader.js # builds .tex fontspec header for xelatex/lualatex
|
||||||
|
DocxFontEmbedder.js # injects TTF into pandoc DOCX output (post-process)
|
||||||
|
EpubFontEmbedder.js # EPUB --epub-embed-font + manifest patch
|
||||||
|
ExportCss.js # builds CSS string with woff2 embedded as base64
|
||||||
|
settings/
|
||||||
|
monospaceSettings.js # schema, defaults, family map
|
||||||
|
ipc/
|
||||||
|
monospace-handlers.js # IPC handler registration
|
||||||
|
utils/
|
||||||
|
(existing) # no change
|
||||||
|
|
||||||
|
src/renderer/
|
||||||
|
hooks/
|
||||||
|
use-monospace-classes.ts # toggles body.mono-{jetbrains,fira}{,-ligatures} classes
|
||||||
|
components/preview/
|
||||||
|
PrintPreviewFrame.tsx # wire print preview to monospace settings via IPC
|
||||||
|
styles/globals.css # add --font-mono-active/--font-mono-feature declarations
|
||||||
|
types/electron.d.ts # extend ElectronAPI with monospace.{getSettings,saveSettings}
|
||||||
|
|
||||||
|
src/preload.js # add monospace channels to ALLOWED_SEND_CHANNELS + expose on window.electronAPI.monospace
|
||||||
|
|
||||||
|
assets/fonts/
|
||||||
|
JetBrainsMono-Regular.ttf # bundled, ~270 KB
|
||||||
|
JetBrainsMono-Bold.ttf # bundled, ~278 KB
|
||||||
|
JetBrainsMono-LICENSE.txt
|
||||||
|
FiraCode-Regular.ttf # bundled, ~300 KB
|
||||||
|
FiraCode-Bold.ttf # bundled, ~300 KB
|
||||||
|
FiraCode-LICENSE.txt
|
||||||
|
|
||||||
|
scripts/download-tools.js # extend to fetch Fira Code
|
||||||
|
|
||||||
|
tests/
|
||||||
|
unit/main/monospace/
|
||||||
|
MonospaceFontConfig.test.js
|
||||||
|
monospaceSettings.test.js
|
||||||
|
PdfFontHeader.test.js
|
||||||
|
DocxFontEmbedder.test.js
|
||||||
|
EpubFontEmbedder.test.js
|
||||||
|
ExportCss.test.js
|
||||||
|
monospace-handlers.test.js
|
||||||
|
smoke-e2e-monospace.js # e2e: convert ASCII-art Markdown → PDF/DOCX/EPUB/HTML, assert TTF references appear in output
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key interfaces (TypeScript-style for clarity)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/main/settings/monospaceSettings.js
|
||||||
|
type MonoFont = 'jetbrains-mono' | 'fira-code';
|
||||||
|
const FAMILY_BY_KEY: Record<MonoFont, string> = {
|
||||||
|
'jetbrains-mono': 'JetBrains Mono',
|
||||||
|
'fira-code': 'Fira Code',
|
||||||
|
};
|
||||||
|
function getActiveMonoFont(settings): string;
|
||||||
|
function isLigaturesEnabled(settings): boolean;
|
||||||
|
function safeMonospaceSettings(input): { monospaceFont: MonoFont; monospaceLigatures: boolean };
|
||||||
|
|
||||||
|
// src/main/MonospaceFontConfig.js
|
||||||
|
function getMonoFontTtfPath(familyKey, weight = 400): string | null; // null if asset missing
|
||||||
|
function ligaturesEnabled(settings): boolean;
|
||||||
|
function getActiveFamily(settings): string;
|
||||||
|
|
||||||
|
// src/main/PdfFontHeader.js
|
||||||
|
function buildPdfFontHeader(settings, ttfPath, fontFamily): { headerPath: string; familyName: string };
|
||||||
|
|
||||||
|
// src/main/DocxFontEmbedder.js
|
||||||
|
async function embedDocxFont(inputDocxPath, outputDocxPath, ttfPath, fontFamily): Promise<void>;
|
||||||
|
async function buildDocxWithEmbeddedFont(pandocArgs, settings): Promise<{args, postProcess}>;
|
||||||
|
|
||||||
|
// src/main/EpubFontEmbedder.js
|
||||||
|
async function embedEpubFont(epubPath, ttfPath, fontFamily): Promise<void>;
|
||||||
|
function withEpubEmbedFontArgs(pandocArgs, ttfPath, fontFamily): string[];
|
||||||
|
|
||||||
|
// src/main/ExportCss.js
|
||||||
|
function buildExportCss(settings, { woff2Base64, family, ligatures }): string;
|
||||||
|
function buildFontFaceBlock(familyKey, woff2Base64): string;
|
||||||
|
|
||||||
|
// src/main/ipc/monospace-handlers.js
|
||||||
|
function register({ getMainWindow }): void;
|
||||||
|
// ipcMain.handle('get-monospace-settings', () => ({ monospaceFont, monospaceLigatures }))
|
||||||
|
// ipcMain.handle('set-monospace-settings', (_e, partial) => safeMonospaceSettings(partial) → persist + broadcast)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renderer integration
|
||||||
|
|
||||||
|
- `use-monospace-classes.ts` hook runs once on mount, calls `electronAPI.monospace.getSettings()`, applies `document.body.classList` of:
|
||||||
|
- `mono-jetbrains-mono` | `mono-fira-code`
|
||||||
|
- `mono-ligatures-on` | `mono-ligatures-off`
|
||||||
|
- CSS in `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; }
|
||||||
|
```
|
||||||
|
- All existing `font-family: monospace` and `font-family: var(--font-mono)` usages continue to work; the new tokens sit above them.
|
||||||
|
|
||||||
|
### Wiring into export pipelines
|
||||||
|
|
||||||
|
In `src/main/index.js` `exportWithPandoc` (PDF branch):
|
||||||
|
- Read `monospaceFont` + `monospaceLigatures` from settings
|
||||||
|
- Resolve TTF path via `MonospaceFontConfig.getMonoFontTtfPath`
|
||||||
|
- If path exists: build font header via `PdfFontHeader.buildPdfFontHeader`, pass `--include-in-header=…`
|
||||||
|
- If path missing: log warning, fall back to existing `-V monofont=Consolas` behavior
|
||||||
|
|
||||||
|
In DOCX branch: invoke `DocxFontEmbedder.embedDocxFont` post-pandoc.
|
||||||
|
In EPUB branch: prepend `--epub-embed-font=…` to pandoc args; call `EpubFontEmbedder.embedEpubFont` to patch manifest.
|
||||||
|
In HTML branch: write CSS via `ExportCss.buildExportCss` (embed woff2 as base64) and reference in `<style>`.
|
||||||
|
|
||||||
|
### Naming differentiation
|
||||||
|
|
||||||
|
`package.json` changes:
|
||||||
|
```diff
|
||||||
|
- "name": "markdown-converter"
|
||||||
|
+ "name": "markdown-converter-react"
|
||||||
|
- "productName": "MarkdownConverter"
|
||||||
|
+ "productName": "Markdown Converter React"
|
||||||
|
- "appId": "com.concreteinfo.markdownconverter"
|
||||||
|
+ "appId": "com.concreteinfo.markdownconverter.react"
|
||||||
|
+ "linux": {
|
||||||
|
+ "executableName": "markdown-converter-react",
|
||||||
|
+ "synopsis": "Markdown editor (React build)",
|
||||||
|
+ "description": "Markdown editor and universal file converter — React build"
|
||||||
|
+ }
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/main/window/index.js`:
|
||||||
|
```js
|
||||||
|
const isDev = !!process.env.VITE_DEV_SERVER_URL;
|
||||||
|
const titleSuffix = isDev ? ' — React Dev' : '';
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
title: `Markdown Converter${titleSuffix}`,
|
||||||
|
...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings impact
|
||||||
|
|
||||||
|
Add two keys to `migration-transform.js` v5 schema with safe defaults:
|
||||||
|
- `monospaceFont: z.enum(['jetbrains-mono', 'fira-code']).default('jetbrains-mono')`
|
||||||
|
- `monospaceLigatures: z.boolean().default(false)`
|
||||||
|
- `appVariant: z.enum(['classic', 'react']).default('react')`
|
||||||
|
|
||||||
|
Existing user settings remain valid; the migration just fills in the new defaults.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing strategy (TDD)
|
||||||
|
|
||||||
|
For each new module: red-green-refactor.
|
||||||
|
|
||||||
|
1. `MonospaceFontConfig.test.js` — uses tmpdir + mocked `process.resourcesPath` to assert resolution from `assets/fonts/JetBrainsMono-Regular.ttf`. Covers missing-file fallback.
|
||||||
|
2. `monospaceSettings.test.js` — schema validation: rejects unknown fonts, coerces ligatures boolean, fills defaults.
|
||||||
|
3. `PdfFontHeader.test.js` — writes a temp `.tex` file, asserts `\setmonofont{JetBrainsMono-Regular.ttf}[...]` lines match.
|
||||||
|
4. `DocxFontEmbedder.test.js` — given a minimal DOCX zip, asserts `word/fonts/` contains the TTF and `word/_rels/fontTable.xml.rels` references it.
|
||||||
|
5. `EpubFontEmbedder.test.js` — asserts `META-INF/container.xml` and `content.opf` updated after `--epub-embed-font`.
|
||||||
|
6. `ExportCss.test.js` — base64 round-trip: `atob(css.match(/base64,([A-Za-z0-9+/=]+)/)[1])` returns the original woff2 bytes.
|
||||||
|
7. `monospace-handlers.test.js` — invokes the registered handlers against `ipcMain` mock; asserts settings persistence + broadcast.
|
||||||
|
8. `smoke-e2e-monospace.js` — runs `pandoc` end-to-end against a fixture markdown with ASCII art; greps output for font references.
|
||||||
|
|
||||||
|
Coverage threshold maintained: jest at 15%, vitest at v8 + renderer lines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **TTF download license** — Fira Code is OFL-1.1; JetBrains Mono is OFL-1.1. Both allow redistribution; LICENSE files ship alongside TTFs. No risk.
|
||||||
|
- **Pandoc version sensitivity** — `--epub-embed-font` requires pandoc ≥ 2.19. The download-tools script pins to 3.9.0.2; check `pandocVersion` and gracefully fall back if older.
|
||||||
|
- **asarUnpack size** — adding ~1.2 MB of TTFs to packaged builds. Acceptable; matches master.
|
||||||
|
- **Dev/prod appId split** — if a user installs both `markdown-converter` and `markdown-converter-react` debs, their settings files are separate (`userData/com.concreteinfo.markdownconverter.react/settings.json`). Documented in README.
|
||||||
|
- **Test environment** — the e2e smoke test requires pandoc available locally. We'll `test.skip` if `which pandoc` fails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
|
||||||
|
- [ ] All planned steps implemented, not just the first/easiest ones
|
||||||
|
- [ ] No forbidden markers (`TODO`, `FIXME`, `XXX`, `HACK`, `not implemented`, `placeholder`, `stub`, `coming soon`) in changed source
|
||||||
|
- [ ] New test suites pass; existing 638 tests still pass
|
||||||
|
- [ ] `npm run build:linux` produces a `.deb` artifact named `markdown-converter-react_*_amd64.deb`
|
||||||
|
- [ ] App launches in dev mode with title `Markdown Converter — React Dev`
|
||||||
|
- [ ] E2E smoke test confirms TTF references in PDF/DOCX/EPUB/HTML output
|
||||||
|
- [ ] CHANGELOG.md updated with a "5.1.0 — react-electron parity" entry
|
||||||
|
- [ ] No unrelated refactors; every changed line traces to the request
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Learning collaboration
|
||||||
|
|
||||||
|
Per the project's learning-mode preference, the user will be asked to contribute two 5-10 line decisions during implementation:
|
||||||
|
|
||||||
|
1. **Default monospace family** in `monospaceSettings.js` (`jetbrains-mono` vs `fira-code` vs `system-fallback`).
|
||||||
|
2. **Body-class gating strategy** in `use-monospace-classes.ts` (apply on every settings change vs debounce vs once-on-mount).
|
||||||
|
|
||||||
|
These choices shape the feature's behavior in ways that benefit from the user's domain knowledge.
|
||||||
Reference in New Issue
Block a user