From 667929423121a5cafee0aefc48a4770b32919b8e Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 18 Aug 2026 17:06:32 -0500 Subject: [PATCH] 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. --- package-lock.json | 156 +++++- package.json | 4 +- src/main/index.ts | 55 ++ src/main/menu.ts | 10 + src/main/office-export.ts | 865 +++++++++++++++++++++++++++++++ src/main/preload.ts | 6 + src/renderer/book.ts | 57 +- src/renderer/index.css | 8 + src/renderer/index.html | 9 +- tests/run-e2e.sh | 2 + tests/run-office-export-test.mjs | 183 +++++++ tests/selftest-scenario.js | 63 ++- 12 files changed, 1403 insertions(+), 15 deletions(-) create mode 100644 src/main/office-export.ts create mode 100644 tests/run-office-export-test.mjs diff --git a/package-lock.json b/package-lock.json index c1640c2..86ad6e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@codemirror/view": "^6.43.9", "@lezer/highlight": "^1.2.3", "archiver": "^8.0.0", + "docx": "^9.7.1", "markdown-it": "^15.0.0" }, "devDependencies": { @@ -26,6 +27,7 @@ "electron": "^32.0.0", "electron-builder": "^26.15.3", "esbuild": "^0.24.0", + "jszip": "^3.10.1", "typescript": "^5.5.0", "wait-on": "^8.0.0" } @@ -2651,6 +2653,38 @@ "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": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -3613,6 +3647,16 @@ "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": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -3718,6 +3762,12 @@ ], "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": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -3909,6 +3959,48 @@ "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": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3968,6 +4060,15 @@ "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": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", @@ -4120,6 +4221,12 @@ "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": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -4189,6 +4296,24 @@ "dev": true, "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": { "version": "4.33.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", @@ -4394,6 +4519,12 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -4846,7 +4977,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -4887,6 +5017,12 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5490,6 +5626,24 @@ "dev": true, "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": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 6389532..3e8a46a 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "start": "npm run build && npm run electron", "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", "test:e2e": "sh tests/run-e2e.sh" }, @@ -26,6 +26,7 @@ "electron": "^32.0.0", "electron-builder": "^26.15.3", "esbuild": "^0.24.0", + "jszip": "^3.10.1", "typescript": "^5.5.0", "wait-on": "^8.0.0" }, @@ -38,6 +39,7 @@ "@codemirror/view": "^6.43.9", "@lezer/highlight": "^1.2.3", "archiver": "^8.0.0", + "docx": "^9.7.1", "markdown-it": "^15.0.0" } } diff --git a/src/main/index.ts b/src/main/index.ts index d14f367..be581c9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,6 +23,7 @@ import { setChapterContent, } from "./chapters.js"; import { combineMarkdown, buildZip } from "./export.js"; +import { buildDocx, buildOdt } from "./office-export.js"; import { getSetting, setSetting } from "./settings.js"; 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) => { try { setChapterContent(requireBook(), id, content); diff --git a/src/main/menu.ts b/src/main/menu.ts index 1c5a86b..74bab66 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -59,6 +59,16 @@ export function buildMenu( enabled: !!getState().bookPath, 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" }, { label: "Close", diff --git a/src/main/office-export.ts b/src/main/office-export.ts new file mode 100644 index 0000000..dde187f --- /dev/null +++ b/src/main/office-export.ts @@ -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== -> text (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(/ /g, "\u00a0") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/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 , , 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. "") still +// applies to the plain text tokens that follow until its closing tag. +function htmlInlineRuns(html: string, state: { ctx: InlineCtx; stack: Partial[] }): 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 => ({ + 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) => { + 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[] }; + + 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 { + 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 { + 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 }); + } +} \ No newline at end of file diff --git a/src/main/preload.ts b/src/main/preload.ts index 179e06a..18785a3 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -28,6 +28,8 @@ contextBridge.exposeInMainWorld("folio", { exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"), exportZip: () => ipcRenderer.invoke("folio:exportZip"), exportPdf: () => ipcRenderer.invoke("folio:exportPdf"), + exportDocx: () => ipcRenderer.invoke("folio:exportDocx"), + exportOdt: () => ipcRenderer.invoke("folio:exportOdt"), importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text), getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"), @@ -57,6 +59,10 @@ contextBridge.exposeInMainWorld("folio", { ipcRenderer.on("folio:export-zip-requested", () => cb()), onExportPdfRequested: (cb: () => void) => 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) => ipcRenderer.on("folio:undo-requested", () => cb()), onRedoRequested: (cb: () => void) => diff --git a/src/renderer/book.ts b/src/renderer/book.ts index 2bf0e21..a6510d2 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -57,6 +57,8 @@ export type FolioAPI = { exportMarkdown: () => Promise; exportZip: () => Promise; exportPdf: () => Promise; + exportDocx: () => Promise; + exportOdt: () => Promise; importAttachment: () => Promise; copyText: (text: string) => Promise; getClipboardText: () => Promise; @@ -72,6 +74,8 @@ export type FolioAPI = { onExportMarkdownRequested: (cb: () => void) => void; onExportZipRequested: (cb: () => void) => void; onExportPdfRequested: (cb: () => void) => void; + onExportDocxRequested: (cb: () => void) => void; + onExportOdtRequested: (cb: () => void) => void; onUndoRequested: (cb: () => void) => void; onRedoRequested: (cb: () => void) => void; onSelectAllRequested: (cb: () => void) => void; @@ -124,7 +128,9 @@ export function initBook(api: FolioAPI) { const printBook = $("printBook"); const copyAllBtn = $("copyAllBtn") as HTMLButtonElement; 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 fbNextBtn = $("fbNextBtn") as HTMLButtonElement; 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)}`); } + 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 { return p.split(/[/\\]/).pop() || p; } @@ -1005,7 +1033,30 @@ export function initBook(api: FolioAPI) { const idx = fullBookChapters.findIndex((c) => c.id === fbChapterSelect.value); 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 () => { const text = editorText(fullBookEditor); if (!text) { @@ -1038,6 +1089,8 @@ export function initBook(api: FolioAPI) { api.onExportMarkdownRequested(() => void exportMarkdown()); api.onExportZipRequested(() => void exportZip()); api.onExportPdfRequested(() => void exportPdf()); + api.onExportDocxRequested(() => void exportDocx()); + api.onExportOdtRequested(() => void exportOdt()); api.onUndoRequested(() => { if (editorView.hasFocus) undo(editorView); else document.execCommand("undo"); diff --git a/src/renderer/index.css b/src/renderer/index.css index 4a1ab1d..d89e099 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -888,6 +888,14 @@ html, body { 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 { flex-basis: 100%; } diff --git a/src/renderer/index.html b/src/renderer/index.html index 04a7e0b..fe9137f 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -181,7 +181,14 @@

Full Book

words: 0 · chars: 0 - +
+ + +
diff --git a/tests/run-e2e.sh b/tests/run-e2e.sh index 44ff05c..57f9d08 100755 --- a/tests/run-e2e.sh +++ b/tests/run-e2e.sh @@ -12,5 +12,7 @@ export FOLIO_TEST_BOOK="$BOOK" export FOLIO_TEST_BOOK2="$BOOK2" export FOLIO_TEST_BOOK3="$BOOK3" 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 xvfb-run -a electron . diff --git a/tests/run-office-export-test.mjs b/tests/run-office-export-test.mjs new file mode 100644 index 0000000..939d839 --- /dev/null +++ b/tests/run-office-export-test.mjs @@ -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== under", + "", + 'red marked', + "", + "- 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]]", + "", + '
centered text
', + ].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", //.test(docxXml)); +check("docx has italic", //.test(docxXml)); +check("docx has strike", //.test(docxXml)); +check("docx has highlight", //.test(docxXml)); +check("docx has underline", //.test(docxXml)); +check("docx has text color", //.test(docxXml)); +check("docx has background shading", //.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."); \ No newline at end of file diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js index 35ff09f..132bf7c 100644 --- a/tests/selftest-scenario.js +++ b/tests/selftest-scenario.js @@ -408,16 +408,22 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__; out.fbWithEmpty = assembledWithEmpty; out.fbEmptyOk = assembledWithEmpty.includes("# Empty") && assembledWithEmpty.includes("Beta Renamed"); - // --- PDF export of the Full Book --- - // The e2e harness sets FOLIO_TEST_PDF, so the main process skips the - // native save dialog and writes to that path, returning the byte count - // and whether the buffer starts with the PDF magic number. The Full Book - // view is on screen here, so what gets printed is exactly the assembled - // manuscript (the print stylesheet hides all app chrome). - const pdfBtn = document.getElementById("exportPdfBtn"); - out.pdfBtnPresent = !!pdfBtn && !pdfBtn.disabled; + // --- Export of the Full Book (PDF / DOCX / ODT) --- + // The e2e harness sets FOLIO_TEST_PDF/DOCX/ODT, so the main process skips + // the native save dialog and writes to those paths, returning the byte + // count and whether the buffer starts with the right magic number. The + // Full Book view is on screen here, so the PDF is the assembled manuscript + // (the print stylesheet hides all app chrome). + const exportMenuToggle = document.getElementById("exportMenuToggle"); + const exportMenu = document.getElementById("exportMenu"); + out.exportMenuPresent = !!exportMenuToggle && !!exportMenu; 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)); out.pdfStatusShown = document .getElementById("fullBookStatus") @@ -485,6 +491,39 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__; pdfLongRes.bytes > pdfRes.bytes * 2 && 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 // to pass through the toolbar's HTML (, ,
), // ==highlight== becomes , callouts become styled divs, and @@ -810,7 +849,9 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__; "fbAfterDeleteOk", "fbAfterRenameOk", "fbEmptyOk", - "pdfBtnPresent", + "exportMenuPresent", + "exportMenuOpened", + "pdfItemPresent", "pdfExportPdfVisible", "pdfStatusShown", "pdfExportOk", @@ -818,6 +859,8 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__; "printBookHasAllChapters", "printBookLongFull", "pdfLongExportOk", + "docxExportOk", + "odtExportOk", "printFmtOk", "navOptionsOk", "navOpenAtOneOk",