Add DOCX and ODT export for the Full Book view

The main process now assembles the manuscript from the chapter files and
renders it to a Word document with the docx library, using the same
markdown-it plugin set as the on-screen renderer (highlight, callouts,
wikilinks). ODT is converted from that DOCX with LibreOffice running
headless, keeping the two formats consistent; a clear error is returned
when LibreOffice is missing. The Full Book header's single Export PDF
button becomes an Export menu (PDF/DOCX/ODT) wired to the File menu.

Tests: new run-office-export-test.mjs verifies DOCX/ODT structure,
chapter order and formatting; the e2e scenario exports through the real
IPC chain and checks magic bytes, extensions and the status notices.
This commit is contained in:
avi 2026-08-18 17:06:32 -05:00
commit 6679294231
12 changed files with 1403 additions and 15 deletions

156
package-lock.json generated
View file

@ -17,6 +17,7 @@
"@codemirror/view": "^6.43.9", "@codemirror/view": "^6.43.9",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"archiver": "^8.0.0", "archiver": "^8.0.0",
"docx": "^9.7.1",
"markdown-it": "^15.0.0" "markdown-it": "^15.0.0"
}, },
"devDependencies": { "devDependencies": {
@ -26,6 +27,7 @@
"electron": "^32.0.0", "electron": "^32.0.0",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"esbuild": "^0.24.0", "esbuild": "^0.24.0",
"jszip": "^3.10.1",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"wait-on": "^8.0.0" "wait-on": "^8.0.0"
} }
@ -2651,6 +2653,38 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/docx": {
"version": "9.7.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
"license": "MIT",
"dependencies": {
"@types/node": "^25.2.3",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/@types/node": {
"version": "25.9.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
"integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/docx/node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@ -3613,6 +3647,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@ -3718,6 +3762,12 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inflight": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@ -3909,6 +3959,48 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -3968,6 +4060,15 @@
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/linkify-it": { "node_modules/linkify-it": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
@ -4120,6 +4221,12 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.6", "version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@ -4189,6 +4296,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": {
"version": "5.1.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/node-abi": { "node_modules/node-abi": {
"version": "4.33.0", "version": "4.33.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz",
@ -4394,6 +4519,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/path-is-absolute": { "node_modules/path-is-absolute": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@ -4846,7 +4977,6 @@
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=11.0.0" "node": ">=11.0.0"
@ -4887,6 +5017,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -5490,6 +5626,24 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
"version": "15.1.1", "version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",

View file

@ -15,7 +15,7 @@
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
"start": "npm run build && npm run electron", "start": "npm run build && npm run electron",
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-format-test.mjs", "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-format-test.mjs && node tests/run-office-export-test.mjs",
"package": "electron-builder", "package": "electron-builder",
"test:e2e": "sh tests/run-e2e.sh" "test:e2e": "sh tests/run-e2e.sh"
}, },
@ -26,6 +26,7 @@
"electron": "^32.0.0", "electron": "^32.0.0",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"esbuild": "^0.24.0", "esbuild": "^0.24.0",
"jszip": "^3.10.1",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"wait-on": "^8.0.0" "wait-on": "^8.0.0"
}, },
@ -38,6 +39,7 @@
"@codemirror/view": "^6.43.9", "@codemirror/view": "^6.43.9",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"archiver": "^8.0.0", "archiver": "^8.0.0",
"docx": "^9.7.1",
"markdown-it": "^15.0.0" "markdown-it": "^15.0.0"
} }
} }

View file

@ -23,6 +23,7 @@ import {
setChapterContent, setChapterContent,
} from "./chapters.js"; } from "./chapters.js";
import { combineMarkdown, buildZip } from "./export.js"; import { combineMarkdown, buildZip } from "./export.js";
import { buildDocx, buildOdt } from "./office-export.js";
import { getSetting, setSetting } from "./settings.js"; import { getSetting, setSetting } from "./settings.js";
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
@ -358,6 +359,60 @@ ipcMain.handle("folio:exportPdf", async () => {
} }
}); });
// Export the manuscript as a DOCX. Built entirely in the main process from the
// chapter files, so it does not depend on which view is on screen. The e2e
// harness overrides the save dialog with FOLIO_TEST_DOCX.
ipcMain.handle("folio:exportDocx", async () => {
try {
const bp = requireBook();
let filePath: string;
if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_DOCX) {
filePath = process.env.FOLIO_TEST_DOCX;
} else {
const { canceled, filePath: chosen } = await showSaveDialog({
title: "Export Full Book as DOCX",
defaultPath: `${currentBookTitle()}.docx`,
filters: [{ name: "Word Document", extensions: ["docx"] }],
});
if (canceled || !chosen) return { canceled: true } as const;
filePath = chosen.endsWith(".docx") ? chosen : `${chosen}.docx`;
}
const buf = await buildDocx(bp);
fs.writeFileSync(filePath, buf);
const docxOk = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK";
return { ok: true, filePath, bytes: buf.length, docxOk };
} catch (e) {
return { error: (e as Error).message };
}
});
// Export the manuscript as an ODT via LibreOffice (converted from the same
// DOCX so both exports stay consistent). Fails with a helpful message when
// LibreOffice is not installed.
ipcMain.handle("folio:exportOdt", async () => {
try {
const bp = requireBook();
let filePath: string;
if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_ODT) {
filePath = process.env.FOLIO_TEST_ODT;
} else {
const { canceled, filePath: chosen } = await showSaveDialog({
title: "Export Full Book as ODT",
defaultPath: `${currentBookTitle()}.odt`,
filters: [{ name: "OpenDocument Text", extensions: ["odt"] }],
});
if (canceled || !chosen) return { canceled: true } as const;
filePath = chosen.endsWith(".odt") ? chosen : `${chosen}.odt`;
}
const buf = await buildOdt(bp);
fs.writeFileSync(filePath, buf);
const odtOk = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK";
return { ok: true, filePath, bytes: buf.length, odtOk };
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => { ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
try { try {
setChapterContent(requireBook(), id, content); setChapterContent(requireBook(), id, content);

View file

@ -59,6 +59,16 @@ export function buildMenu(
enabled: !!getState().bookPath, enabled: !!getState().bookPath,
click: () => getWindow()?.webContents.send("folio:export-pdf-requested"), click: () => getWindow()?.webContents.send("folio:export-pdf-requested"),
}, },
{
label: "Export Full Book (DOCX)…",
enabled: !!getState().bookPath,
click: () => getWindow()?.webContents.send("folio:export-docx-requested"),
},
{
label: "Export Full Book (ODT)…",
enabled: !!getState().bookPath,
click: () => getWindow()?.webContents.send("folio:export-odt-requested"),
},
{ type: "separator" }, { type: "separator" },
{ {
label: "Close", label: "Close",

865
src/main/office-export.ts Normal file
View file

@ -0,0 +1,865 @@
// Full Book export to DOCX (and ODT) entirely in the main process.
//
// DOCX is produced with the `docx` library: the assembled manuscript is parsed
// with markdown-it (same plugin set as the on-screen renderer) and the token
// stream is converted into Word paragraphs, tables and runs. ODT is converted
// from the generated DOCX with LibreOffice running headless, which keeps the
// two formats consistent without hand-writing another office XML vocabulary.
// Both exports read the chapter files straight off disk, so the manuscript is
// always complete regardless of which view is on screen.
import MarkdownIt from "markdown-it";
import type { MarkdownIt as MarkdownItInstance, StateCore, StateInline } from "markdown-it";
import { execFile } from "child_process";
import { promisify } from "util";
import fs from "fs";
import os from "os";
import path from "path";
import {
AlignmentType,
BorderStyle,
Document,
ExternalHyperlink,
HeadingLevel,
NumberFormat,
Packer,
Paragraph,
ShadingType,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
} from "docx";
import { loadBook } from "./project.js";
import { listChaptersInOrder } from "./chapters.js";
const execFileAsync = promisify(execFile);
const MONO_FONT = "Consolas";
interface WikilinkTarget {
id: string;
title: string;
}
// ==text== -> <mark>text</mark> (mirrors src/renderer/print-format.ts)
function highlightPlugin(md: MarkdownItInstance) {
const parse = (state: StateInline, silent: boolean): boolean => {
const src = state.src;
const max = state.posMax;
const start = state.pos;
if (start + 2 > max) return false;
if (src.charCodeAt(start) !== 0x3d || src.charCodeAt(start + 1) !== 0x3d) return false;
let end = -1;
for (let i = start + 2; i < max - 1; i++) {
const ch = src.charCodeAt(i);
if (ch === 0x0a) return false;
if (ch === 0x3d && src.charCodeAt(i + 1) === 0x3d) {
end = i;
break;
}
}
if (end === -1) return false;
const content = src.slice(start + 2, end);
if (!content.trim()) return false;
if (silent) {
state.pos = end + 2;
return true;
}
state.push("highlight_open", "mark", 1);
state.push("text", "", 0).content = content;
state.push("highlight_close", "mark", -1);
state.pos = end + 2;
return true;
};
md.inline.ruler.before("emphasis", "highlight", parse);
}
// [[Title]] / [[Target|Alias]] -> links (mirrors src/renderer/wikilinks.ts)
function wikilinkPlugin(md: MarkdownItInstance, chapters: WikilinkTarget[]) {
const byTitle = new Map(chapters.map((c) => [String(c.title).toLowerCase(), c.id]));
const byId = new Set(chapters.map((c) => String(c.id)));
const parse = (state: StateInline, silent: boolean): boolean => {
const src = state.src;
const max = state.posMax;
const start = state.pos;
if (start + 2 > max) return false;
if (src.charCodeAt(start) !== 0x5b || src.charCodeAt(start + 1) !== 0x5b) return false;
let end = -1;
for (let i = start + 2; i < max - 1; i++) {
const ch = src.charCodeAt(i);
if (ch === 0x5b) return false;
if (ch === 0x0a) return false;
if (ch === 0x5d && src.charCodeAt(i + 1) === 0x5d) {
end = i;
break;
}
}
if (end === -1) return false;
const raw = src.slice(start + 2, end).trim();
if (!raw) return false;
let target = raw;
let label = raw;
const pipe = raw.indexOf("|");
if (pipe >= 0) {
target = raw.slice(0, pipe).trim();
const alias = raw.slice(pipe + 1).trim();
label = alias.length ? alias : target;
}
if (!target) return false;
if (silent) {
state.pos = end + 2;
return true;
}
const key = target.toLowerCase();
const id = byTitle.has(key) ? byTitle.get(key)! : byId.has(target) ? target : null;
const open = state.push("link_open", "a", 1);
open.attrs = [
["href", id !== null ? `folio:chapter/${encodeURIComponent(id)}` : "#"],
["class", id !== null ? "wikilink internal" : "wikilink missing"],
];
const text = state.push("text", "", 0);
text.content = label;
state.push("link_close", "a", -1);
state.pos = end + 2;
return true;
};
md.inline.ruler.before("link", "wikilink", parse);
}
const CALLOUT_RE = /^\s*\[!([a-zA-Z0-9_-]+)\]\s*(.*)$/;
// > [!note] Title ... -> styled callout blocks (mirrors src/renderer/print-format.ts)
function calloutPlugin(md: MarkdownItInstance) {
md.core.ruler.after("inline", "callout", (state: StateCore) => {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
const open = tokens[i];
if (open.type !== "blockquote_open") continue;
let depth = 1;
let closeIdx = -1;
for (let j = i + 1; j < tokens.length; j++) {
const t = tokens[j];
if (t.type === "blockquote_open") depth += 1;
else if (t.type === "blockquote_close") {
depth -= 1;
if (depth === 0) {
closeIdx = j;
break;
}
}
}
if (closeIdx === -1) continue;
const paraOpen = tokens[i + 1];
if (!paraOpen || paraOpen.type !== "paragraph_open") continue;
const inline = tokens[i + 2];
if (!inline || inline.type !== "inline" || !inline.children) continue;
const first = inline.children[0];
if (!first || first.type !== "text") continue;
const m = CALLOUT_RE.exec(first.content);
if (!m) continue;
open.type = "callout_open";
open.attrs = [["class", `callout callout-${m[1].toLowerCase()}`]];
const close = tokens[closeIdx];
close.type = "callout_close";
close.tag = "div";
first.content = "";
inline.children = inline.children.filter((c) => !(c.type === "text" && c.content === ""));
if (inline.children[0]?.type === "softbreak") inline.children.shift();
const title = m[2].trim();
if (title) {
const titleOpen = new state.Token("paragraph_open", "p", 1);
titleOpen.attrs = [["class", "callout-title"]];
const titleInline = new state.Token("inline", "", 0);
titleInline.content = title;
const titleText = new state.Token("text", "", 0);
titleText.content = title;
titleInline.children = [titleText];
const titleClose = new state.Token("paragraph_close", "p", -1);
tokens.splice(i + 1, 0, titleOpen, titleInline, titleClose);
}
}
});
}
type BlockEl = Paragraph | Table;
interface InlineCtx {
bold: boolean;
italic: boolean;
strike: boolean;
code: boolean;
underline: boolean;
highlight?: "yellow";
color?: string;
bg?: string;
}
const defaultCtx: InlineCtx = {
bold: false,
italic: false,
strike: false,
code: false,
underline: false,
};
const paraSpacing = { after: 160 };
const headingSpacing = { before: 240, after: 160 };
const tableSpacing = { after: 40 };
function hexColor(v: string): string {
return v.replace(/^#/, "").trim().toLowerCase();
}
function makeRun(ctx: InlineCtx, text: string): TextRun {
const lineBreak = text === "\n";
return new TextRun({
text: lineBreak ? "" : text,
bold: ctx.bold || undefined,
italics: ctx.italic || undefined,
strike: ctx.strike || undefined,
underline: ctx.underline ? {} : undefined,
highlight: ctx.highlight,
color: ctx.color,
shading: ctx.bg ? { type: ShadingType.CLEAR, fill: ctx.bg } : undefined,
font: ctx.code ? MONO_FONT : undefined,
break: lineBreak ? 1 : undefined,
});
}
function decodeEntities(s: string): string {
return s
.replace(/&nbsp;/g, "\u00a0")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x([0-9a-f]+);/gi, (_m, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_m, dec: string) => String.fromCodePoint(parseInt(dec, 10)));
}
// Convert a raw inline HTML fragment (toolbar's <u>, <span style=...>, etc.)
// into runs. The caller keeps one mutable state across the whole inline token
// list, so formatting opened in one html_inline token (e.g. "<u>") still
// applies to the plain text tokens that follow until its closing tag.
function htmlInlineRuns(html: string, state: { ctx: InlineCtx; stack: Partial<InlineCtx>[] }): TextRun[] {
const out: TextRun[] = [];
const { ctx, stack } = state;
let textBuf = "";
let i = 0;
const flush = () => {
if (textBuf) {
out.push(makeRun(ctx, textBuf));
textBuf = "";
}
};
const snapshot = (): Partial<InlineCtx> => ({
bold: ctx.bold,
italic: ctx.italic,
strike: ctx.strike,
code: ctx.code,
underline: ctx.underline,
highlight: ctx.highlight,
color: ctx.color,
bg: ctx.bg,
});
const restore = (snap: Partial<InlineCtx>) => {
Object.assign(ctx, snap);
};
while (i < html.length) {
const lt = html.indexOf("<", i);
if (lt === -1) {
textBuf += decodeEntities(html.slice(i));
break;
}
const gt = html.indexOf(">", lt);
if (gt === -1) {
textBuf += decodeEntities(html.slice(i));
break;
}
if (lt > i) textBuf += decodeEntities(html.slice(i, lt));
const tag = html.slice(lt + 1, gt);
const nameMatch = /^\s*\/?\s*([a-zA-Z][a-zA-Z0-9-]*)/.exec(tag);
const name = nameMatch?.[1].toLowerCase() ?? "";
const isClose = /^\s*\//.test(tag);
const styleAttr = /style\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1] ?? "";
if (!name) {
textBuf += "<" + tag + ">";
} else if (name === "br") {
flush();
out.push(makeRun(ctx, "\n"));
} else if (name === "span") {
flush();
if (!isClose) {
stack.push(snapshot());
for (const decl of styleAttr.split(";")) {
const [prop, ...rest] = decl.split(":");
const k = prop.trim().toLowerCase();
const v = rest.join(":").trim();
if (k === "color" && v) ctx.color = hexColor(v);
else if (k === "background-color" && v) ctx.bg = hexColor(v);
else if (k === "text-decoration" && v.includes("underline")) ctx.underline = true;
}
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
} else if (name === "u") {
flush();
if (!isClose) {
stack.push(snapshot());
ctx.underline = true;
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
} else if (name === "del" || name === "s") {
flush();
if (!isClose) {
stack.push(snapshot());
ctx.strike = true;
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
} else if (name === "mark") {
flush();
if (!isClose) {
stack.push(snapshot());
ctx.highlight = "yellow";
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
} else if (name === "strong" || name === "b") {
flush();
if (!isClose) {
stack.push(snapshot());
ctx.bold = true;
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
} else if (name === "em" || name === "i") {
flush();
if (!isClose) {
stack.push(snapshot());
ctx.italic = true;
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
} else if (name === "code") {
flush();
if (!isClose) {
stack.push(snapshot());
ctx.code = true;
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
}
// Unknown tags are dropped; their inner text is kept.
i = gt + 1;
}
flush();
return out;
}
// Convert inline token children into runs. Links become ExternalHyperlink
// unless they are wikilinks (rendered as plain label text, since the export
// has no in-app chapter URLs).
function inlineRuns(tokens: import("markdown-it").Token[], ctx: InlineCtx): (TextRun | ExternalHyperlink)[] {
const out: (TextRun | ExternalHyperlink)[] = [];
let i = 0;
const htmlState = { ctx: { ...ctx }, stack: [] as Partial<InlineCtx>[] };
const collect = (openType: string, closeType: string): { inner: import("markdown-it").Token[] } => {
i++;
let depth = 1;
const inner: import("markdown-it").Token[] = [];
while (i < tokens.length && depth) {
const t = tokens[i];
if (t.type === openType) depth++;
else if (t.type === closeType) depth--;
if (depth) inner.push(t);
i++;
}
return { inner };
};
while (i < tokens.length) {
const t = tokens[i];
if (t.type === "text") {
out.push(makeRun(htmlState.ctx, t.content));
i++;
} else if (t.type === "softbreak" || t.type === "hardbreak") {
out.push(makeRun(htmlState.ctx, "\n"));
i++;
} else if (t.type === "code") {
out.push(makeRun({ ...htmlState.ctx, code: true }, t.content));
i++;
} else if (t.type === "image") {
const alt = String(t.attrGet("alt") ?? t.attrGet("src") ?? "");
if (alt) out.push(makeRun({ ...htmlState.ctx, italic: true }, alt));
i++;
} else if (t.type === "strong_open") {
out.push(...inlineRuns(collect("strong_open", "strong_close").inner, { ...htmlState.ctx, bold: true }));
} else if (t.type === "em_open") {
out.push(...inlineRuns(collect("em_open", "em_close").inner, { ...htmlState.ctx, italic: true }));
} else if (t.type === "s_open") {
out.push(...inlineRuns(collect("s_open", "s_close").inner, { ...htmlState.ctx, strike: true }));
} else if (t.type === "highlight_open") {
out.push(
...inlineRuns(collect("highlight_open", "highlight_close").inner, {
...htmlState.ctx,
highlight: htmlState.ctx.highlight ?? "yellow",
})
);
} else if (t.type === "link_open") {
const href = String(t.attrGet("href") ?? "");
const isWikilink = String(t.attrGet("class") ?? "").includes("wikilink");
const children = inlineRuns(collect("link_open", "link_close").inner, htmlState.ctx);
if (isWikilink || href === "#" || href.startsWith("folio:chapter/")) {
out.push(...children);
} else if (href && children.length) {
out.push(new ExternalHyperlink({ link: href, children }));
} else {
out.push(...children);
}
} else if (t.type === "html_inline") {
out.push(...htmlInlineRuns(t.content, htmlState));
i++;
} else {
i++;
}
}
return out;
}
function headingLevel(level: number) {
switch (level) {
case 1:
return HeadingLevel.HEADING_1;
case 2:
return HeadingLevel.HEADING_2;
case 3:
return HeadingLevel.HEADING_3;
case 4:
return HeadingLevel.HEADING_4;
case 5:
return HeadingLevel.HEADING_5;
case 6:
return HeadingLevel.HEADING_6;
default:
return undefined;
}
}
function htmlBlockToParagraph(html: string): Paragraph | null {
const alignMatch = /align\s*=\s*["'](left|center|right|justify)["']/i.exec(html);
const align = alignMatch?.[1].toLowerCase();
const text = decodeEntities(html.replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim();
if (!text) return null;
return new Paragraph({
alignment: align === "center" ? AlignmentType.CENTER : align === "right" ? AlignmentType.RIGHT : undefined,
children: [new TextRun({ text })],
spacing: paraSpacing,
});
}
function renderListItem(itemTokens: import("markdown-it").Token[], listCtx: { reference: string; level: number }): BlockEl[] {
const out: BlockEl[] = [];
let i = 0;
let first = true;
while (i < itemTokens.length) {
const t = itemTokens[i];
if (t.type === "paragraph_open") {
const inlineTok = itemTokens[i + 1];
const runs = inlineTok && inlineTok.type === "inline" ? inlineRuns(inlineTok.children ?? [], defaultCtx) : [];
if (first) {
out.push(
new Paragraph({
children: runs,
spacing: paraSpacing,
numbering: { reference: listCtx.reference, level: listCtx.level },
})
);
} else {
out.push(
new Paragraph({
children: runs,
spacing: paraSpacing,
indent: { left: 720 * (listCtx.level + 1), hanging: 360 },
})
);
}
first = false;
i += 3;
} else if (t.type === "bullet_list_open" || t.type === "ordered_list_open") {
const nested = {
reference: t.type === "bullet_list_open" ? "bullets" : "numbers",
level: listCtx.level + 1,
};
const { blocks, next } = renderList(itemTokens, i, nested);
out.push(...blocks);
i = next;
} else {
i++;
}
}
return out;
}
function renderList(tokens: import("markdown-it").Token[], start: number, listCtx: { reference: string; level: number }): { blocks: BlockEl[]; next: number } {
const blocks: BlockEl[] = [];
let i = start + 1;
let depth = 1;
while (i < tokens.length && depth) {
const t = tokens[i];
if (t.type === "bullet_list_open" || t.type === "ordered_list_open") depth++;
else if (t.type === "bullet_list_close" || t.type === "ordered_list_close") depth--;
if (depth && t.type === "list_item_open") {
let j = i + 1;
let itemDepth = 1;
const itemTokens: import("markdown-it").Token[] = [];
while (j < tokens.length && itemDepth) {
const it = tokens[j];
if (it.type === "list_item_open") itemDepth++;
else if (it.type === "list_item_close") itemDepth--;
if (itemDepth) itemTokens.push(it);
j++;
}
blocks.push(...renderListItem(itemTokens, listCtx));
i = j;
} else {
i++;
}
}
return { blocks, next: i };
}
// Collect the tokens inside a balanced open/close pair and return the index
// just past the closing token.
function collectMatching(
tokens: import("markdown-it").Token[],
start: number,
openTypes: string[],
closeTypes: string[]
): { inner: import("markdown-it").Token[]; next: number } {
let i = start + 1;
let depth = 1;
const inner: import("markdown-it").Token[] = [];
while (i < tokens.length && depth) {
const t = tokens[i];
if (openTypes.includes(t.type)) depth++;
else if (closeTypes.includes(t.type)) depth--;
if (depth) inner.push(t);
i++;
}
return { inner, next: i };
}
const QUOTE_BORDER = { left: { style: BorderStyle.SINGLE, size: 16, color: "888888", space: 8 } };
const CALLOUT_SHADING = { type: ShadingType.CLEAR, fill: "F5F2ED" };
// Render blockquote / callout content. Every paragraph gets the quote indent
// and left border; callouts are additionally shaded.
function renderQuoted(tokens: import("markdown-it").Token[], isCallout: boolean): BlockEl[] {
const out: BlockEl[] = [];
let i = 0;
while (i < tokens.length) {
const t = tokens[i];
if (t.type === "paragraph_open") {
const isTitle = String(t.attrGet("class") ?? "").includes("callout-title");
const inlineTok = tokens[i + 1];
const runs =
inlineTok && inlineTok.type === "inline"
? inlineRuns(inlineTok.children ?? [], isTitle ? { ...defaultCtx, bold: true } : defaultCtx)
: [];
out.push(
new Paragraph({
children: runs,
indent: { left: 720 },
border: QUOTE_BORDER,
shading: isCallout ? CALLOUT_SHADING : undefined,
spacing: paraSpacing,
})
);
i += 3;
} else if (t.type === "bullet_list_open") {
const { blocks, next } = renderList(tokens, i, { reference: "bullets", level: 0 });
out.push(...blocks);
i = next;
} else if (t.type === "ordered_list_open") {
const { blocks, next } = renderList(tokens, i, { reference: "numbers", level: 0 });
out.push(...blocks);
i = next;
} else if (t.type === "blockquote_open" || t.type === "callout_open") {
const nested = t.type === "callout_open";
const { inner, next } = collectMatching(tokens, i, ["blockquote_open", "callout_open"], ["blockquote_close", "callout_close"]);
out.push(...renderQuoted(inner, isCallout || nested));
i = next;
} else {
i++;
}
}
return out;
}
// Render a run of block-level tokens (a whole chapter or a blockquote body).
function renderBlocks(tokens: import("markdown-it").Token[]): BlockEl[] {
const out: BlockEl[] = [];
let i = 0;
while (i < tokens.length) {
const t = tokens[i];
if (t.type === "heading_open") {
const level = Number(t.tag.slice(1));
const inlineTok = tokens[i + 1];
const runs = inlineTok && inlineTok.type === "inline" ? inlineRuns(inlineTok.children ?? [], defaultCtx) : [];
out.push(new Paragraph({ heading: headingLevel(level), children: runs, spacing: headingSpacing }));
i += 3;
} else if (t.type === "paragraph_open") {
const isTitle = String(t.attrGet("class") ?? "").includes("callout-title");
const inlineTok = tokens[i + 1];
const runs =
inlineTok && inlineTok.type === "inline"
? inlineRuns(inlineTok.children ?? [], isTitle ? { ...defaultCtx, bold: true } : defaultCtx)
: [];
out.push(new Paragraph({ children: runs, spacing: paraSpacing }));
i += 3;
} else if (t.type === "bullet_list_open") {
const { blocks, next } = renderList(tokens, i, { reference: "bullets", level: 0 });
out.push(...blocks);
i = next;
} else if (t.type === "ordered_list_open") {
const { blocks, next } = renderList(tokens, i, { reference: "numbers", level: 0 });
out.push(...blocks);
i = next;
} else if (t.type === "blockquote_open" || t.type === "callout_open") {
const isCallout = t.type === "callout_open";
const { inner, next } = collectMatching(tokens, i, ["blockquote_open", "callout_open"], ["blockquote_close", "callout_close"]);
out.push(...renderQuoted(inner, isCallout));
i = next;
} else if (t.type === "fence" || t.type === "code_block") {
const lines = t.content.replace(/\n$/, "").split("\n");
const runs = lines.map(
(line, idx) =>
new TextRun({
text: line,
font: MONO_FONT,
break: idx < lines.length - 1 ? 1 : undefined,
})
);
out.push(
new Paragraph({
children: runs,
shading: { type: ShadingType.CLEAR, fill: "F2F2F2" },
border: { left: { style: BorderStyle.SINGLE, size: 12, color: "CCCCCC", space: 8 } },
spacing: { before: 120, after: 160 },
})
);
i++;
} else if (t.type === "hr") {
out.push(
new Paragraph({
children: [],
spacing: { before: 120, after: 120 },
border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: "999999", space: 1 } },
})
);
i++;
} else if (t.type === "table_open") {
const { inner, next } = collectMatching(tokens, i, ["table_open"], ["table_close"]);
const rows: TableRow[] = [];
let cells: TableCell[] = [];
for (let k = 0; k < inner.length; k++) {
const tt = inner[k];
if (tt.type === "tr_open") cells = [];
else if (tt.type === "th_open" || tt.type === "td_open") {
const inlineTok = inner[k + 1];
const runs = inlineTok && inlineTok.type === "inline" ? inlineRuns(inlineTok.children ?? [], defaultCtx) : [];
cells.push(
new TableCell({
children: [new Paragraph({ children: runs, spacing: tableSpacing })],
shading: tt.type === "th_open" ? { type: ShadingType.CLEAR, fill: "EFEFEF" } : undefined,
})
);
} else if (tt.type === "tr_close") rows.push(new TableRow({ children: cells }));
}
out.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows }));
i = next;
} else if (t.type === "html_block") {
const p = htmlBlockToParagraph(t.content);
if (p) out.push(p);
i++;
} else {
i++;
}
}
return out;
}
// Assemble the manuscript exactly the way the Full Book view does: the book
// title on top, then each chapter in order, prepending "# Title" only for
// chapters that do not carry their own heading. The title page note mirrors
// combineMarkdown so all exports stay consistent.
function assembleBookMarkdown(bookPath: string): string {
const meta = loadBook(bookPath);
const parts: string[] = [`# ${meta.title}`, "", "_A book compiled with Folio._"];
for (const ch of listChaptersInOrder(meta)) {
let content = "";
try {
content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8");
} catch {
content = "";
}
const body = content.replace(/^\n+/, "").replace(/\s+$/, "");
const t = ch.title.trim().toLowerCase();
let hasOwnHeading = !t;
if (t) {
for (const line of content.split(/\r?\n/)) {
const m = line.match(/^#{1,6}\s+(.+?)\s*$/);
if (m && m[1].trim().toLowerCase() === t) {
hasOwnHeading = true;
break;
}
}
}
const block: string[] = [];
if (!hasOwnHeading) {
block.push(`# ${ch.title}`);
if (body) {
block.push("");
block.push(body);
}
} else if (body) {
block.push(body);
}
if (block.length) parts.push("", block.join("\n"));
}
return parts.join("\n");
}
export function buildDocx(bookPath: string): Promise<Buffer> {
const meta = loadBook(bookPath);
const chapters = listChaptersInOrder(meta);
const targets: WikilinkTarget[] = chapters.map((c) => ({ id: c.id, title: c.title }));
const md = new MarkdownIt({ html: true });
md.use(wikilinkPlugin, targets);
md.use(highlightPlugin);
md.use(calloutPlugin);
const blocks: BlockEl[] = [
new Paragraph({
heading: HeadingLevel.TITLE,
alignment: AlignmentType.CENTER,
children: [new TextRun({ text: meta.title })],
spacing: { after: 120 },
}),
new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ italics: true, color: "666666", text: "A book compiled with Folio._" })],
spacing: { after: 360 },
}),
];
for (const ch of chapters) {
let content = "";
try {
content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8");
} catch {
content = "";
}
const body = content.replace(/^\n+/, "").replace(/\s+$/, "");
const t = ch.title.trim().toLowerCase();
let hasOwnHeading = !t;
if (t) {
for (const line of content.split(/\r?\n/)) {
const m = line.match(/^#{1,6}\s+(.+?)\s*$/);
if (m && m[1].trim().toLowerCase() === t) {
hasOwnHeading = true;
break;
}
}
}
const markdown = hasOwnHeading ? body : body ? `# ${ch.title}\n\n${body}` : `# ${ch.title}`;
if (markdown) blocks.push(...renderBlocks(md.parse(markdown, {})));
}
const doc = new Document({
styles: {
default: {
document: {
run: { font: "Georgia", size: 22 },
},
},
},
numbering: {
config: [
{
reference: "bullets",
levels: [0, 1, 2, 3, 4, 5].map((level) => ({
level,
format: NumberFormat.BULLET,
text: "\u2022",
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 720 * (level + 1), hanging: 360 } } },
})),
},
{
reference: "numbers",
levels: [0, 1, 2, 3, 4, 5].map((level) => ({
level,
format: NumberFormat.DECIMAL,
text: `%${level + 1}.`,
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 720 * (level + 1), hanging: 360 } } },
})),
},
],
},
sections: [{ children: blocks }],
});
return Packer.toBuffer(doc);
}
// Convert the generated DOCX to ODT with LibreOffice running headless. This
// keeps the two exports consistent and produces a spec-compliant ODT without
// hand-writing a second XML vocabulary.
export async function buildOdt(bookPath: string): Promise<Buffer> {
const docxBuf = await buildDocx(bookPath);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-odt-"));
try {
const tmpDocx = path.join(tmpDir, "book.docx");
const outDir = path.join(tmpDir, "out");
fs.writeFileSync(tmpDocx, docxBuf);
fs.mkdirSync(outDir, { recursive: true });
try {
await execFileAsync("soffice", ["--headless", "--convert-to", "odt", "--outdir", outDir, tmpDocx], {
timeout: 60_000,
});
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
throw new Error(
"LibreOffice is not installed, so ODT export is unavailable. Install LibreOffice (or use the PDF or DOCX export instead)."
);
}
throw new Error(`LibreOffice could not convert the document: ${(e as Error).message}`);
}
const odtPath = path.join(outDir, "book.odt");
if (!fs.existsSync(odtPath)) throw new Error("LibreOffice did not produce an ODT file.");
return fs.readFileSync(odtPath);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}

View file

@ -28,6 +28,8 @@ contextBridge.exposeInMainWorld("folio", {
exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"), exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"),
exportZip: () => ipcRenderer.invoke("folio:exportZip"), exportZip: () => ipcRenderer.invoke("folio:exportZip"),
exportPdf: () => ipcRenderer.invoke("folio:exportPdf"), exportPdf: () => ipcRenderer.invoke("folio:exportPdf"),
exportDocx: () => ipcRenderer.invoke("folio:exportDocx"),
exportOdt: () => ipcRenderer.invoke("folio:exportOdt"),
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text), copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"), getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
@ -57,6 +59,10 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.on("folio:export-zip-requested", () => cb()), ipcRenderer.on("folio:export-zip-requested", () => cb()),
onExportPdfRequested: (cb: () => void) => onExportPdfRequested: (cb: () => void) =>
ipcRenderer.on("folio:export-pdf-requested", () => cb()), ipcRenderer.on("folio:export-pdf-requested", () => cb()),
onExportDocxRequested: (cb: () => void) =>
ipcRenderer.on("folio:export-docx-requested", () => cb()),
onExportOdtRequested: (cb: () => void) =>
ipcRenderer.on("folio:export-odt-requested", () => cb()),
onUndoRequested: (cb: () => void) => onUndoRequested: (cb: () => void) =>
ipcRenderer.on("folio:undo-requested", () => cb()), ipcRenderer.on("folio:undo-requested", () => cb()),
onRedoRequested: (cb: () => void) => onRedoRequested: (cb: () => void) =>

View file

@ -57,6 +57,8 @@ export type FolioAPI = {
exportMarkdown: () => Promise<any>; exportMarkdown: () => Promise<any>;
exportZip: () => Promise<any>; exportZip: () => Promise<any>;
exportPdf: () => Promise<any>; exportPdf: () => Promise<any>;
exportDocx: () => Promise<any>;
exportOdt: () => Promise<any>;
importAttachment: () => Promise<any>; importAttachment: () => Promise<any>;
copyText: (text: string) => Promise<any>; copyText: (text: string) => Promise<any>;
getClipboardText: () => Promise<string>; getClipboardText: () => Promise<string>;
@ -72,6 +74,8 @@ export type FolioAPI = {
onExportMarkdownRequested: (cb: () => void) => void; onExportMarkdownRequested: (cb: () => void) => void;
onExportZipRequested: (cb: () => void) => void; onExportZipRequested: (cb: () => void) => void;
onExportPdfRequested: (cb: () => void) => void; onExportPdfRequested: (cb: () => void) => void;
onExportDocxRequested: (cb: () => void) => void;
onExportOdtRequested: (cb: () => void) => void;
onUndoRequested: (cb: () => void) => void; onUndoRequested: (cb: () => void) => void;
onRedoRequested: (cb: () => void) => void; onRedoRequested: (cb: () => void) => void;
onSelectAllRequested: (cb: () => void) => void; onSelectAllRequested: (cb: () => void) => void;
@ -124,7 +128,9 @@ export function initBook(api: FolioAPI) {
const printBook = $("printBook"); const printBook = $("printBook");
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement; const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
const fullBookStatus = $("fullBookStatus"); const fullBookStatus = $("fullBookStatus");
const exportPdfBtn = $("exportPdfBtn") as HTMLButtonElement; const exportMenuToggle = $("exportMenuToggle") as HTMLButtonElement;
const exportMenu = $("exportMenu");
const exportDropdown = $("exportDropdown");
const fbPrevBtn = $("fbPrevBtn") as HTMLButtonElement; const fbPrevBtn = $("fbPrevBtn") as HTMLButtonElement;
const fbNextBtn = $("fbNextBtn") as HTMLButtonElement; const fbNextBtn = $("fbNextBtn") as HTMLButtonElement;
const fbChapterSelect = $("fbChapterSelect") as HTMLSelectElement; const fbChapterSelect = $("fbChapterSelect") as HTMLSelectElement;
@ -794,6 +800,28 @@ export function initBook(api: FolioAPI) {
else if (res && "filePath" in res) fullBookNotice(`PDF saved to ${basename(res.filePath)}`); else if (res && "filePath" in res) fullBookNotice(`PDF saved to ${basename(res.filePath)}`);
} }
async function exportDocx() {
if (!currentBook) return;
const res = await api.exportDocx();
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error);
else if (res && "canceled" in res) fullBookNotice("Export canceled");
else if (res && "filePath" in res) fullBookNotice(`DOCX saved to ${basename(res.filePath)}`);
}
async function exportOdt() {
if (!currentBook) return;
const res = await api.exportOdt();
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error);
else if (res && "canceled" in res) fullBookNotice("Export canceled");
else if (res && "filePath" in res) fullBookNotice(`ODT saved to ${basename(res.filePath)}`);
}
async function exportFromMenu(kind: string) {
if (kind === "pdf") await exportPdf();
else if (kind === "docx") await exportDocx();
else if (kind === "odt") await exportOdt();
}
function basename(p: string): string { function basename(p: string): string {
return p.split(/[/\\]/).pop() || p; return p.split(/[/\\]/).pop() || p;
} }
@ -1005,7 +1033,30 @@ export function initBook(api: FolioAPI) {
const idx = fullBookChapters.findIndex((c) => c.id === fbChapterSelect.value); const idx = fullBookChapters.findIndex((c) => c.id === fbChapterSelect.value);
if (idx >= 0) jumpToChapter(idx); if (idx >= 0) jumpToChapter(idx);
}); });
exportPdfBtn.addEventListener("click", () => void exportPdf()); function closeExportMenu() {
exportMenu.classList.add("hidden");
exportMenuToggle.classList.remove("open");
}
exportMenuToggle.addEventListener("click", (e) => {
e.stopPropagation();
const wasOpen = !exportMenu.classList.contains("hidden");
closeExportMenu();
if (!wasOpen) exportMenu.classList.remove("hidden");
exportMenuToggle.classList.toggle("open", wasOpen);
});
exportMenu.addEventListener("click", (e) => {
const item = (e.target as HTMLElement).closest("[data-export]") as HTMLElement | null;
if (!item) return;
closeExportMenu();
void exportFromMenu(item.dataset.export ?? "");
});
document.addEventListener("click", (e) => {
if (exportDropdown && !(e.target as HTMLElement).closest("#exportDropdown")) closeExportMenu();
});
copyAllBtn.addEventListener("click", async () => { copyAllBtn.addEventListener("click", async () => {
const text = editorText(fullBookEditor); const text = editorText(fullBookEditor);
if (!text) { if (!text) {
@ -1038,6 +1089,8 @@ export function initBook(api: FolioAPI) {
api.onExportMarkdownRequested(() => void exportMarkdown()); api.onExportMarkdownRequested(() => void exportMarkdown());
api.onExportZipRequested(() => void exportZip()); api.onExportZipRequested(() => void exportZip());
api.onExportPdfRequested(() => void exportPdf()); api.onExportPdfRequested(() => void exportPdf());
api.onExportDocxRequested(() => void exportDocx());
api.onExportOdtRequested(() => void exportOdt());
api.onUndoRequested(() => { api.onUndoRequested(() => {
if (editorView.hasFocus) undo(editorView); if (editorView.hasFocus) undo(editorView);
else document.execCommand("undo"); else document.execCommand("undo");

View file

@ -888,6 +888,14 @@ html, body {
flex-basis: 100%; flex-basis: 100%;
} }
/* Export dropdown in the Full Book header sits flush against the window's
right edge, so its menu opens toward the left instead of overflowing. */
.export-dropdown .dropdown-menu {
left: auto;
right: 0;
min-width: 120px;
}
.color-menu .dropdown-item { .color-menu .dropdown-item {
flex-basis: 100%; flex-basis: 100%;
} }

View file

@ -181,7 +181,14 @@
<h2 class="chapter-title">Full Book</h2> <h2 class="chapter-title">Full Book</h2>
<div class="editor-actions"> <div class="editor-actions">
<span class="word-count" id="fullBookStats">words: 0 · chars: 0</span> <span class="word-count" id="fullBookStats">words: 0 · chars: 0</span>
<button class="button" id="exportPdfBtn" title="Export the entire Full Book as a PDF">Export PDF</button> <div class="toolbar-dropdown export-dropdown" id="exportDropdown">
<button class="button dropdown-toggle" id="exportMenuToggle" title="Export the entire Full Book">Export &#9662;</button>
<div class="dropdown-menu hidden" id="exportMenu">
<button class="dropdown-item" data-export="pdf">PDF</button>
<button class="dropdown-item" data-export="docx">DOCX</button>
<button class="dropdown-item" data-export="odt">ODT</button>
</div>
</div>
<button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button> <button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button>
<span class="save-state" id="fullBookStatus"></span> <span class="save-state" id="fullBookStatus"></span>
</div> </div>

View file

@ -12,5 +12,7 @@ export FOLIO_TEST_BOOK="$BOOK"
export FOLIO_TEST_BOOK2="$BOOK2" export FOLIO_TEST_BOOK2="$BOOK2"
export FOLIO_TEST_BOOK3="$BOOK3" export FOLIO_TEST_BOOK3="$BOOK3"
export FOLIO_TEST_PDF="$BOOK/export-test.pdf" export FOLIO_TEST_PDF="$BOOK/export-test.pdf"
export FOLIO_TEST_DOCX="$BOOK/export-test.docx"
export FOLIO_TEST_ODT="$BOOK/export-test.odt"
trap 'rm -rf "$BOOK" "$BOOK2" "$BOOK3"' EXIT trap 'rm -rf "$BOOK" "$BOOK2" "$BOOK3"' EXIT
xvfb-run -a electron . xvfb-run -a electron .

View file

@ -0,0 +1,183 @@
// Standalone test for the DOCX/ODT Full Book export (office-export.ts).
// Bundles the main-process module with esbuild and drives buildDocx/buildOdt
// against a throwaway book, then inspects the produced ZIP contents to check
// that the manuscript, its chapter order, and its formatting survive.
// Run with: node tests/run-office-export-test.mjs
import esbuild from "esbuild";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
import JSZip from "jszip";
const out = path.resolve(`tests/.tmp-folio-office-${process.pid}.mjs`);
await esbuild.build({
entryPoints: [path.resolve("src/main/office-export.ts")],
bundle: true,
platform: "node",
format: "esm",
external: ["electron"],
outfile: out,
write: true,
logLevel: "silent",
});
const mod = await import(url.pathToFileURL(out).href);
let failures = 0;
const check = (name, ok) => {
console.log(`${ok ? "PASS" : "FAIL"} ${name}`);
if (!ok) failures += 1;
};
// --- fixture book -----------------------------------------------------------
const bookDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-office-test-"));
fs.mkdirSync(path.join(bookDir, "chapters"), { recursive: true });
const chapters = {};
const order = [];
const addChapter = (id, title, body) => {
const file = path.posix.join("chapters", `${id}.md`);
order.push(id);
chapters[id] = { id, title, file };
fs.writeFileSync(path.join(bookDir, file), body);
};
addChapter(
"ch1",
"Alpha",
[
"# Alpha",
"",
"**bold** *italic* ~~strike~~ ==highlight== <u>under</u>",
"",
'<span style="color:#e11d48">red</span> <span style="background-color:#fde047">marked</span>',
"",
"- bullet one",
"- bullet two",
" - nested bullet",
"",
"1. numbered one",
"2. numbered two",
"",
"> [!note] Callout title",
"> callout body",
"",
"> plain quote",
"",
"```js",
"const answer = 42;",
"```",
"",
"---",
"",
"| Head A | Head B |",
"| --- | --- |",
"| cell 1 | cell 2 |",
"",
"[Folio](https://example.com)",
"",
"[[Beta|Go to Beta]]",
"",
'<div align="center">centered text</div>',
].join("\n")
);
addChapter("ch2", "Beta", "This chapter has no heading of its own.");
addChapter("ch3", "Gamma", `# Gamma\n\n## Subsection\n\ntrailing paragraph`);
addChapter("ch4", "Empty", "");
addChapter("ch5", "Unicode", "Café — naïve naïve 中文 📖");
fs.writeFileSync(
path.join(bookDir, "folio.json"),
JSON.stringify({ app: "folio", version: 1, title: "Office Test Book", chapterOrder: order, chapters })
);
const origFiles = order.map((id) => chapters[id].file).map((f) => [f, fs.readFileSync(path.join(bookDir, f))]);
const origManifest = fs.readFileSync(path.join(bookDir, "folio.json"));
const docxBuf = await mod.buildDocx(bookDir);
const docxZip = await JSZip.loadAsync(docxBuf);
const docxXml = await docxZip.file("word/document.xml").async("string");
check("docx is a zip (PK magic)", docxBuf.subarray(0, 2).toString("utf-8") === "PK");
check("docx has [Content_Types].xml", !!docxZip.file("[Content_Types].xml"));
check("docx has word/document.xml", !!docxZip.file("word/document.xml"));
const stripTags = (xml) => xml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
const docxText = stripTags(docxXml);
check("docx contains book title", docxText.includes("Office Test Book"));
check("docx contains byline", docxText.includes("A book compiled with Folio._"));
const idxAlpha = docxText.indexOf("Alpha");
const idxBeta = docxText.indexOf("Beta");
const idxGamma = docxText.indexOf("Gamma");
const idxUnicode = docxText.indexOf("Unicode");
check(
"docx keeps chapter order",
idxAlpha >= 0 && idxAlpha < idxBeta && idxBeta < idxGamma && idxGamma < idxUnicode
);
check("docx has bold", /<w:b\/>/.test(docxXml));
check("docx has italic", /<w:i\/>/.test(docxXml));
check("docx has strike", /<w:strike\/>/.test(docxXml));
check("docx has highlight", /<w:highlight w:val="yellow"\/>/.test(docxXml));
check("docx has underline", /<w:u w:val="single"\/>/.test(docxXml));
check("docx has text color", /<w:color w:val="e11d48"\/>/.test(docxXml));
check("docx has background shading", /<w:shd w:fill="fde047"/.test(docxXml));
check("docx has hyperlink", /<w:hyperlink/.test(docxXml));
check("docx has heading 1", /w:val="Heading1"/.test(docxXml));
check("docx has table", /<w:tbl>/.test(docxXml));
check("docx has code font", /Consolas/.test(docxXml));
check("docx has centered alignment", /w:val="center"/.test(docxXml));
check("docx has callout shading", /w:fill="F5F2ED"/.test(docxXml));
check("docx renders ==highlight== (no raw markup)", !docxText.includes("==highlight=="));
check("docx renders **bold** (no raw markup)", !docxText.includes("**bold**"));
check("docx resolves wikilink label", docxText.includes("Go to Beta"));
check("docx has no raw wikilink brackets", !docxText.includes("[[Beta"));
check("docx headingless chapter gets title", docxText.includes("Beta") && docxText.includes("no heading"));
check("docx empty chapter is skipped without stray title duplication", !docxText.includes("Empty Empty"));
// --- ODT --------------------------------------------------------------------
let odtSkipped = false;
try {
const odtBuf = await mod.buildOdt(bookDir);
const odtZip = await JSZip.loadAsync(odtBuf);
check("odt is a zip (PK magic)", odtBuf.subarray(0, 2).toString("utf-8") === "PK");
const mime = await odtZip.file("mimetype").async("string");
check("odt has correct mimetype", mime === "application/vnd.oasis.opendocument.text");
const odtXml = await odtZip.file("content.xml").async("string");
const odtText = stripTags(odtXml);
check("odt contains book title", odtText.includes("Office Test Book"));
const oAlpha = odtText.indexOf("Alpha");
const oBeta = odtText.indexOf("Beta");
const oGamma = odtText.indexOf("Gamma");
const oUnicode = odtText.indexOf("Unicode");
check("odt keeps chapter order", oAlpha >= 0 && oAlpha < oBeta && oBeta < oGamma && oGamma < oUnicode);
check("odt keeps bold", /bold/.test(odtText) && odtXml.includes('fo:font-weight="bold"'));
check("odt keeps callout title", odtText.includes("Callout title"));
check("odt keeps heading", odtXml.includes("Heading 1") || odtXml.includes('style:family="paragraph"'));
check("odt has no raw ==highlight==", !odtText.includes("==highlight=="));
check("odt has no raw wikilink brackets", !odtText.includes("[[Beta"));
} catch (e) {
odtSkipped = true;
console.log(`WARN ODT checks skipped: ${e.message}`);
}
// --- filesystem safety ------------------------------------------------------
for (const [f, bytes] of origFiles) {
check(`chapter file unchanged: ${f}`, fs.readFileSync(path.join(bookDir, f)).equals(bytes));
}
check("folio.json unchanged", fs.readFileSync(path.join(bookDir, "folio.json")).equals(origManifest));
fs.rmSync(bookDir, { recursive: true, force: true });
fs.rmSync(out, { force: true });
if (odtSkipped) {
console.log("NOTE ODT verification requires LibreOffice; it was skipped.");
}
if (failures > 0) {
console.error(`\n${failures} check(s) failed.`);
process.exit(1);
}
console.log("\nAll office-export checks passed.");

View file

@ -408,16 +408,22 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
out.fbWithEmpty = assembledWithEmpty; out.fbWithEmpty = assembledWithEmpty;
out.fbEmptyOk = assembledWithEmpty.includes("# Empty") && assembledWithEmpty.includes("Beta Renamed"); out.fbEmptyOk = assembledWithEmpty.includes("# Empty") && assembledWithEmpty.includes("Beta Renamed");
// --- PDF export of the Full Book --- // --- Export of the Full Book (PDF / DOCX / ODT) ---
// The e2e harness sets FOLIO_TEST_PDF, so the main process skips the // The e2e harness sets FOLIO_TEST_PDF/DOCX/ODT, so the main process skips
// native save dialog and writes to that path, returning the byte count // the native save dialog and writes to those paths, returning the byte
// and whether the buffer starts with the PDF magic number. The Full Book // count and whether the buffer starts with the right magic number. The
// view is on screen here, so what gets printed is exactly the assembled // Full Book view is on screen here, so the PDF is the assembled manuscript
// manuscript (the print stylesheet hides all app chrome). // (the print stylesheet hides all app chrome).
const pdfBtn = document.getElementById("exportPdfBtn"); const exportMenuToggle = document.getElementById("exportMenuToggle");
out.pdfBtnPresent = !!pdfBtn && !pdfBtn.disabled; const exportMenu = document.getElementById("exportMenu");
out.exportMenuPresent = !!exportMenuToggle && !!exportMenu;
out.pdfExportPdfVisible = !document.getElementById("fullBookView").classList.contains("hidden"); out.pdfExportPdfVisible = !document.getElementById("fullBookView").classList.contains("hidden");
pdfBtn.click(); exportMenuToggle.click();
await new Promise((r) => setTimeout(r, 50));
out.exportMenuOpened = !exportMenu.classList.contains("hidden");
const pdfItem = exportMenu.querySelector('[data-export="pdf"]');
out.pdfItemPresent = !!pdfItem;
pdfItem.click();
await new Promise((r) => setTimeout(r, 2500)); await new Promise((r) => setTimeout(r, 2500));
out.pdfStatusShown = document out.pdfStatusShown = document
.getElementById("fullBookStatus") .getElementById("fullBookStatus")
@ -485,6 +491,39 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
pdfLongRes.bytes > pdfRes.bytes * 2 && pdfLongRes.bytes > pdfRes.bytes * 2 &&
pdfLongRes.pdfOk === true; pdfLongRes.pdfOk === true;
// DOCX export: main process assembles the manuscript from the chapter
// files (all four chapters, including the very long one), writes a ZIP
// (PK magic) to FOLIO_TEST_DOCX, and reports its size. The deep content
// checks live in tests/run-office-export-test.mjs; here we verify the
// end-to-end IPC path, extension, magic number, and the status notice.
const docxRes = await window.folio.exportDocx();
out.docxExport =
docxRes && "filePath" in docxRes
? { filePath: docxRes.filePath, bytes: docxRes.bytes, docxOk: docxRes.docxOk }
: docxRes;
out.docxExportOk =
!!docxRes &&
!("error" in docxRes) &&
"filePath" in docxRes &&
docxRes.filePath.endsWith(".docx") &&
typeof docxRes.bytes === "number" &&
docxRes.bytes > 1000 &&
docxRes.docxOk === true;
const odtRes = await window.folio.exportOdt();
out.odtExport =
odtRes && "filePath" in odtRes
? { filePath: odtRes.filePath, bytes: odtRes.bytes, odtOk: odtRes.odtOk }
: odtRes;
out.odtExportOk =
!!odtRes &&
!("error" in odtRes) &&
"filePath" in odtRes &&
odtRes.filePath.endsWith(".odt") &&
typeof odtRes.bytes === "number" &&
odtRes.bytes > 1000 &&
odtRes.odtOk === true;
// Formatting must survive into the printed book: markdown-it is configured // Formatting must survive into the printed book: markdown-it is configured
// to pass through the toolbar's HTML (<u>, <span style=...>, <div>), // to pass through the toolbar's HTML (<u>, <span style=...>, <div>),
// ==highlight== becomes <mark>, callouts become styled divs, and // ==highlight== becomes <mark>, callouts become styled divs, and
@ -810,7 +849,9 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
"fbAfterDeleteOk", "fbAfterDeleteOk",
"fbAfterRenameOk", "fbAfterRenameOk",
"fbEmptyOk", "fbEmptyOk",
"pdfBtnPresent", "exportMenuPresent",
"exportMenuOpened",
"pdfItemPresent",
"pdfExportPdfVisible", "pdfExportPdfVisible",
"pdfStatusShown", "pdfStatusShown",
"pdfExportOk", "pdfExportOk",
@ -818,6 +859,8 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
"printBookHasAllChapters", "printBookHasAllChapters",
"printBookLongFull", "printBookLongFull",
"pdfLongExportOk", "pdfLongExportOk",
"docxExportOk",
"odtExportOk",
"printFmtOk", "printFmtOk",
"navOptionsOk", "navOptionsOk",
"navOpenAtOneOk", "navOpenAtOneOk",