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:
parent
ad0329bbf9
commit
6679294231
12 changed files with 1403 additions and 15 deletions
156
package-lock.json
generated
156
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
865
src/main/office-export.ts
Normal file
865
src/main/office-export.ts
Normal 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(/ /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 <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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ export type FolioAPI = {
|
|||
exportMarkdown: () => Promise<any>;
|
||||
exportZip: () => Promise<any>;
|
||||
exportPdf: () => Promise<any>;
|
||||
exportDocx: () => Promise<any>;
|
||||
exportOdt: () => Promise<any>;
|
||||
importAttachment: () => Promise<any>;
|
||||
copyText: (text: string) => Promise<any>;
|
||||
getClipboardText: () => Promise<string>;
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,7 +181,14 @@
|
|||
<h2 class="chapter-title">Full Book</h2>
|
||||
<div class="editor-actions">
|
||||
<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 ▾</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>
|
||||
<span class="save-state" id="fullBookStatus"></span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 .
|
||||
|
|
|
|||
183
tests/run-office-export-test.mjs
Normal file
183
tests/run-office-export-test.mjs
Normal 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.");
|
||||
|
|
@ -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 (<u>, <span style=...>, <div>),
|
||||
// ==highlight== becomes <mark>, 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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue