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",
|
"@codemirror/view": "^6.43.9",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"archiver": "^8.0.0",
|
"archiver": "^8.0.0",
|
||||||
|
"docx": "^9.7.1",
|
||||||
"markdown-it": "^15.0.0"
|
"markdown-it": "^15.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -26,6 +27,7 @@
|
||||||
"electron": "^32.0.0",
|
"electron": "^32.0.0",
|
||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
"esbuild": "^0.24.0",
|
"esbuild": "^0.24.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"wait-on": "^8.0.0"
|
"wait-on": "^8.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2651,6 +2653,38 @@
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/docx": {
|
||||||
|
"version": "9.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
|
||||||
|
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
|
"hash.js": "^1.1.7",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"nanoid": "^5.1.3",
|
||||||
|
"xml": "^1.0.1",
|
||||||
|
"xml-js": "^1.6.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/docx/node_modules/@types/node": {
|
||||||
|
"version": "25.9.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
|
||||||
|
"integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/docx/node_modules/undici-types": {
|
||||||
|
"version": "7.24.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
|
@ -3613,6 +3647,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hash.js": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"minimalistic-assert": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
|
@ -3718,6 +3762,12 @@
|
||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/inflight": {
|
"node_modules/inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
|
|
@ -3909,6 +3959,48 @@
|
||||||
"graceful-fs": "^4.1.6"
|
"graceful-fs": "^4.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
|
@ -3968,6 +4060,15 @@
|
||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/linkify-it": {
|
"node_modules/linkify-it": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
|
||||||
|
|
@ -4120,6 +4221,12 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
|
|
@ -4189,6 +4296,24 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nanoid": {
|
||||||
|
"version": "5.1.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
|
||||||
|
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || >=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-abi": {
|
"node_modules/node-abi": {
|
||||||
"version": "4.33.0",
|
"version": "4.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz",
|
||||||
|
|
@ -4394,6 +4519,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/path-is-absolute": {
|
"node_modules/path-is-absolute": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
|
|
@ -4846,7 +4977,6 @@
|
||||||
"version": "1.6.1",
|
"version": "1.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||||
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=11.0.0"
|
"node": ">=11.0.0"
|
||||||
|
|
@ -4887,6 +5017,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|
@ -5490,6 +5626,24 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/xml": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/xml-js": {
|
||||||
|
"version": "1.6.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||||
|
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": "^1.2.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xml-js": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xmlbuilder": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "15.1.1",
|
"version": "15.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
|
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
|
||||||
"start": "npm run build && npm run electron",
|
"start": "npm run build && npm run electron",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-format-test.mjs",
|
"test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-format-test.mjs && node tests/run-office-export-test.mjs",
|
||||||
"package": "electron-builder",
|
"package": "electron-builder",
|
||||||
"test:e2e": "sh tests/run-e2e.sh"
|
"test:e2e": "sh tests/run-e2e.sh"
|
||||||
},
|
},
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"electron": "^32.0.0",
|
"electron": "^32.0.0",
|
||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
"esbuild": "^0.24.0",
|
"esbuild": "^0.24.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"wait-on": "^8.0.0"
|
"wait-on": "^8.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -38,6 +39,7 @@
|
||||||
"@codemirror/view": "^6.43.9",
|
"@codemirror/view": "^6.43.9",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"archiver": "^8.0.0",
|
"archiver": "^8.0.0",
|
||||||
|
"docx": "^9.7.1",
|
||||||
"markdown-it": "^15.0.0"
|
"markdown-it": "^15.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
setChapterContent,
|
setChapterContent,
|
||||||
} from "./chapters.js";
|
} from "./chapters.js";
|
||||||
import { combineMarkdown, buildZip } from "./export.js";
|
import { combineMarkdown, buildZip } from "./export.js";
|
||||||
|
import { buildDocx, buildOdt } from "./office-export.js";
|
||||||
import { getSetting, setSetting } from "./settings.js";
|
import { getSetting, setSetting } from "./settings.js";
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
@ -358,6 +359,60 @@ ipcMain.handle("folio:exportPdf", async () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Export the manuscript as a DOCX. Built entirely in the main process from the
|
||||||
|
// chapter files, so it does not depend on which view is on screen. The e2e
|
||||||
|
// harness overrides the save dialog with FOLIO_TEST_DOCX.
|
||||||
|
ipcMain.handle("folio:exportDocx", async () => {
|
||||||
|
try {
|
||||||
|
const bp = requireBook();
|
||||||
|
let filePath: string;
|
||||||
|
if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_DOCX) {
|
||||||
|
filePath = process.env.FOLIO_TEST_DOCX;
|
||||||
|
} else {
|
||||||
|
const { canceled, filePath: chosen } = await showSaveDialog({
|
||||||
|
title: "Export Full Book as DOCX",
|
||||||
|
defaultPath: `${currentBookTitle()}.docx`,
|
||||||
|
filters: [{ name: "Word Document", extensions: ["docx"] }],
|
||||||
|
});
|
||||||
|
if (canceled || !chosen) return { canceled: true } as const;
|
||||||
|
filePath = chosen.endsWith(".docx") ? chosen : `${chosen}.docx`;
|
||||||
|
}
|
||||||
|
const buf = await buildDocx(bp);
|
||||||
|
fs.writeFileSync(filePath, buf);
|
||||||
|
const docxOk = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK";
|
||||||
|
return { ok: true, filePath, bytes: buf.length, docxOk };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: (e as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export the manuscript as an ODT via LibreOffice (converted from the same
|
||||||
|
// DOCX so both exports stay consistent). Fails with a helpful message when
|
||||||
|
// LibreOffice is not installed.
|
||||||
|
ipcMain.handle("folio:exportOdt", async () => {
|
||||||
|
try {
|
||||||
|
const bp = requireBook();
|
||||||
|
let filePath: string;
|
||||||
|
if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_ODT) {
|
||||||
|
filePath = process.env.FOLIO_TEST_ODT;
|
||||||
|
} else {
|
||||||
|
const { canceled, filePath: chosen } = await showSaveDialog({
|
||||||
|
title: "Export Full Book as ODT",
|
||||||
|
defaultPath: `${currentBookTitle()}.odt`,
|
||||||
|
filters: [{ name: "OpenDocument Text", extensions: ["odt"] }],
|
||||||
|
});
|
||||||
|
if (canceled || !chosen) return { canceled: true } as const;
|
||||||
|
filePath = chosen.endsWith(".odt") ? chosen : `${chosen}.odt`;
|
||||||
|
}
|
||||||
|
const buf = await buildOdt(bp);
|
||||||
|
fs.writeFileSync(filePath, buf);
|
||||||
|
const odtOk = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK";
|
||||||
|
return { ok: true, filePath, bytes: buf.length, odtOk };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: (e as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
|
ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
|
||||||
try {
|
try {
|
||||||
setChapterContent(requireBook(), id, content);
|
setChapterContent(requireBook(), id, content);
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,16 @@ export function buildMenu(
|
||||||
enabled: !!getState().bookPath,
|
enabled: !!getState().bookPath,
|
||||||
click: () => getWindow()?.webContents.send("folio:export-pdf-requested"),
|
click: () => getWindow()?.webContents.send("folio:export-pdf-requested"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Export Full Book (DOCX)…",
|
||||||
|
enabled: !!getState().bookPath,
|
||||||
|
click: () => getWindow()?.webContents.send("folio:export-docx-requested"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Export Full Book (ODT)…",
|
||||||
|
enabled: !!getState().bookPath,
|
||||||
|
click: () => getWindow()?.webContents.send("folio:export-odt-requested"),
|
||||||
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
label: "Close",
|
label: "Close",
|
||||||
|
|
|
||||||
865
src/main/office-export.ts
Normal file
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"),
|
exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"),
|
||||||
exportZip: () => ipcRenderer.invoke("folio:exportZip"),
|
exportZip: () => ipcRenderer.invoke("folio:exportZip"),
|
||||||
exportPdf: () => ipcRenderer.invoke("folio:exportPdf"),
|
exportPdf: () => ipcRenderer.invoke("folio:exportPdf"),
|
||||||
|
exportDocx: () => ipcRenderer.invoke("folio:exportDocx"),
|
||||||
|
exportOdt: () => ipcRenderer.invoke("folio:exportOdt"),
|
||||||
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
|
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
|
||||||
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
|
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
|
||||||
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
|
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
|
||||||
|
|
@ -57,6 +59,10 @@ contextBridge.exposeInMainWorld("folio", {
|
||||||
ipcRenderer.on("folio:export-zip-requested", () => cb()),
|
ipcRenderer.on("folio:export-zip-requested", () => cb()),
|
||||||
onExportPdfRequested: (cb: () => void) =>
|
onExportPdfRequested: (cb: () => void) =>
|
||||||
ipcRenderer.on("folio:export-pdf-requested", () => cb()),
|
ipcRenderer.on("folio:export-pdf-requested", () => cb()),
|
||||||
|
onExportDocxRequested: (cb: () => void) =>
|
||||||
|
ipcRenderer.on("folio:export-docx-requested", () => cb()),
|
||||||
|
onExportOdtRequested: (cb: () => void) =>
|
||||||
|
ipcRenderer.on("folio:export-odt-requested", () => cb()),
|
||||||
onUndoRequested: (cb: () => void) =>
|
onUndoRequested: (cb: () => void) =>
|
||||||
ipcRenderer.on("folio:undo-requested", () => cb()),
|
ipcRenderer.on("folio:undo-requested", () => cb()),
|
||||||
onRedoRequested: (cb: () => void) =>
|
onRedoRequested: (cb: () => void) =>
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ export type FolioAPI = {
|
||||||
exportMarkdown: () => Promise<any>;
|
exportMarkdown: () => Promise<any>;
|
||||||
exportZip: () => Promise<any>;
|
exportZip: () => Promise<any>;
|
||||||
exportPdf: () => Promise<any>;
|
exportPdf: () => Promise<any>;
|
||||||
|
exportDocx: () => Promise<any>;
|
||||||
|
exportOdt: () => Promise<any>;
|
||||||
importAttachment: () => Promise<any>;
|
importAttachment: () => Promise<any>;
|
||||||
copyText: (text: string) => Promise<any>;
|
copyText: (text: string) => Promise<any>;
|
||||||
getClipboardText: () => Promise<string>;
|
getClipboardText: () => Promise<string>;
|
||||||
|
|
@ -72,6 +74,8 @@ export type FolioAPI = {
|
||||||
onExportMarkdownRequested: (cb: () => void) => void;
|
onExportMarkdownRequested: (cb: () => void) => void;
|
||||||
onExportZipRequested: (cb: () => void) => void;
|
onExportZipRequested: (cb: () => void) => void;
|
||||||
onExportPdfRequested: (cb: () => void) => void;
|
onExportPdfRequested: (cb: () => void) => void;
|
||||||
|
onExportDocxRequested: (cb: () => void) => void;
|
||||||
|
onExportOdtRequested: (cb: () => void) => void;
|
||||||
onUndoRequested: (cb: () => void) => void;
|
onUndoRequested: (cb: () => void) => void;
|
||||||
onRedoRequested: (cb: () => void) => void;
|
onRedoRequested: (cb: () => void) => void;
|
||||||
onSelectAllRequested: (cb: () => void) => void;
|
onSelectAllRequested: (cb: () => void) => void;
|
||||||
|
|
@ -124,7 +128,9 @@ export function initBook(api: FolioAPI) {
|
||||||
const printBook = $("printBook");
|
const printBook = $("printBook");
|
||||||
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
|
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
|
||||||
const fullBookStatus = $("fullBookStatus");
|
const fullBookStatus = $("fullBookStatus");
|
||||||
const exportPdfBtn = $("exportPdfBtn") as HTMLButtonElement;
|
const exportMenuToggle = $("exportMenuToggle") as HTMLButtonElement;
|
||||||
|
const exportMenu = $("exportMenu");
|
||||||
|
const exportDropdown = $("exportDropdown");
|
||||||
const fbPrevBtn = $("fbPrevBtn") as HTMLButtonElement;
|
const fbPrevBtn = $("fbPrevBtn") as HTMLButtonElement;
|
||||||
const fbNextBtn = $("fbNextBtn") as HTMLButtonElement;
|
const fbNextBtn = $("fbNextBtn") as HTMLButtonElement;
|
||||||
const fbChapterSelect = $("fbChapterSelect") as HTMLSelectElement;
|
const fbChapterSelect = $("fbChapterSelect") as HTMLSelectElement;
|
||||||
|
|
@ -794,6 +800,28 @@ export function initBook(api: FolioAPI) {
|
||||||
else if (res && "filePath" in res) fullBookNotice(`PDF saved to ${basename(res.filePath)}`);
|
else if (res && "filePath" in res) fullBookNotice(`PDF saved to ${basename(res.filePath)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportDocx() {
|
||||||
|
if (!currentBook) return;
|
||||||
|
const res = await api.exportDocx();
|
||||||
|
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error);
|
||||||
|
else if (res && "canceled" in res) fullBookNotice("Export canceled");
|
||||||
|
else if (res && "filePath" in res) fullBookNotice(`DOCX saved to ${basename(res.filePath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportOdt() {
|
||||||
|
if (!currentBook) return;
|
||||||
|
const res = await api.exportOdt();
|
||||||
|
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error);
|
||||||
|
else if (res && "canceled" in res) fullBookNotice("Export canceled");
|
||||||
|
else if (res && "filePath" in res) fullBookNotice(`ODT saved to ${basename(res.filePath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportFromMenu(kind: string) {
|
||||||
|
if (kind === "pdf") await exportPdf();
|
||||||
|
else if (kind === "docx") await exportDocx();
|
||||||
|
else if (kind === "odt") await exportOdt();
|
||||||
|
}
|
||||||
|
|
||||||
function basename(p: string): string {
|
function basename(p: string): string {
|
||||||
return p.split(/[/\\]/).pop() || p;
|
return p.split(/[/\\]/).pop() || p;
|
||||||
}
|
}
|
||||||
|
|
@ -1005,7 +1033,30 @@ export function initBook(api: FolioAPI) {
|
||||||
const idx = fullBookChapters.findIndex((c) => c.id === fbChapterSelect.value);
|
const idx = fullBookChapters.findIndex((c) => c.id === fbChapterSelect.value);
|
||||||
if (idx >= 0) jumpToChapter(idx);
|
if (idx >= 0) jumpToChapter(idx);
|
||||||
});
|
});
|
||||||
exportPdfBtn.addEventListener("click", () => void exportPdf());
|
function closeExportMenu() {
|
||||||
|
exportMenu.classList.add("hidden");
|
||||||
|
exportMenuToggle.classList.remove("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
exportMenuToggle.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const wasOpen = !exportMenu.classList.contains("hidden");
|
||||||
|
closeExportMenu();
|
||||||
|
if (!wasOpen) exportMenu.classList.remove("hidden");
|
||||||
|
exportMenuToggle.classList.toggle("open", wasOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
exportMenu.addEventListener("click", (e) => {
|
||||||
|
const item = (e.target as HTMLElement).closest("[data-export]") as HTMLElement | null;
|
||||||
|
if (!item) return;
|
||||||
|
closeExportMenu();
|
||||||
|
void exportFromMenu(item.dataset.export ?? "");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (exportDropdown && !(e.target as HTMLElement).closest("#exportDropdown")) closeExportMenu();
|
||||||
|
});
|
||||||
|
|
||||||
copyAllBtn.addEventListener("click", async () => {
|
copyAllBtn.addEventListener("click", async () => {
|
||||||
const text = editorText(fullBookEditor);
|
const text = editorText(fullBookEditor);
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
|
@ -1038,6 +1089,8 @@ export function initBook(api: FolioAPI) {
|
||||||
api.onExportMarkdownRequested(() => void exportMarkdown());
|
api.onExportMarkdownRequested(() => void exportMarkdown());
|
||||||
api.onExportZipRequested(() => void exportZip());
|
api.onExportZipRequested(() => void exportZip());
|
||||||
api.onExportPdfRequested(() => void exportPdf());
|
api.onExportPdfRequested(() => void exportPdf());
|
||||||
|
api.onExportDocxRequested(() => void exportDocx());
|
||||||
|
api.onExportOdtRequested(() => void exportOdt());
|
||||||
api.onUndoRequested(() => {
|
api.onUndoRequested(() => {
|
||||||
if (editorView.hasFocus) undo(editorView);
|
if (editorView.hasFocus) undo(editorView);
|
||||||
else document.execCommand("undo");
|
else document.execCommand("undo");
|
||||||
|
|
|
||||||
|
|
@ -888,6 +888,14 @@ html, body {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Export dropdown in the Full Book header sits flush against the window's
|
||||||
|
right edge, so its menu opens toward the left instead of overflowing. */
|
||||||
|
.export-dropdown .dropdown-menu {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
.color-menu .dropdown-item {
|
.color-menu .dropdown-item {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,14 @@
|
||||||
<h2 class="chapter-title">Full Book</h2>
|
<h2 class="chapter-title">Full Book</h2>
|
||||||
<div class="editor-actions">
|
<div class="editor-actions">
|
||||||
<span class="word-count" id="fullBookStats">words: 0 · chars: 0</span>
|
<span class="word-count" id="fullBookStats">words: 0 · chars: 0</span>
|
||||||
<button class="button" id="exportPdfBtn" title="Export the entire Full Book as a PDF">Export PDF</button>
|
<div class="toolbar-dropdown export-dropdown" id="exportDropdown">
|
||||||
|
<button class="button dropdown-toggle" id="exportMenuToggle" title="Export the entire Full Book">Export ▾</button>
|
||||||
|
<div class="dropdown-menu hidden" id="exportMenu">
|
||||||
|
<button class="dropdown-item" data-export="pdf">PDF</button>
|
||||||
|
<button class="dropdown-item" data-export="docx">DOCX</button>
|
||||||
|
<button class="dropdown-item" data-export="odt">ODT</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button>
|
<button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button>
|
||||||
<span class="save-state" id="fullBookStatus"></span>
|
<span class="save-state" id="fullBookStatus"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,7 @@ export FOLIO_TEST_BOOK="$BOOK"
|
||||||
export FOLIO_TEST_BOOK2="$BOOK2"
|
export FOLIO_TEST_BOOK2="$BOOK2"
|
||||||
export FOLIO_TEST_BOOK3="$BOOK3"
|
export FOLIO_TEST_BOOK3="$BOOK3"
|
||||||
export FOLIO_TEST_PDF="$BOOK/export-test.pdf"
|
export FOLIO_TEST_PDF="$BOOK/export-test.pdf"
|
||||||
|
export FOLIO_TEST_DOCX="$BOOK/export-test.docx"
|
||||||
|
export FOLIO_TEST_ODT="$BOOK/export-test.odt"
|
||||||
trap 'rm -rf "$BOOK" "$BOOK2" "$BOOK3"' EXIT
|
trap 'rm -rf "$BOOK" "$BOOK2" "$BOOK3"' EXIT
|
||||||
xvfb-run -a electron .
|
xvfb-run -a electron .
|
||||||
|
|
|
||||||
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.fbWithEmpty = assembledWithEmpty;
|
||||||
out.fbEmptyOk = assembledWithEmpty.includes("# Empty") && assembledWithEmpty.includes("Beta Renamed");
|
out.fbEmptyOk = assembledWithEmpty.includes("# Empty") && assembledWithEmpty.includes("Beta Renamed");
|
||||||
|
|
||||||
// --- PDF export of the Full Book ---
|
// --- Export of the Full Book (PDF / DOCX / ODT) ---
|
||||||
// The e2e harness sets FOLIO_TEST_PDF, so the main process skips the
|
// The e2e harness sets FOLIO_TEST_PDF/DOCX/ODT, so the main process skips
|
||||||
// native save dialog and writes to that path, returning the byte count
|
// the native save dialog and writes to those paths, returning the byte
|
||||||
// and whether the buffer starts with the PDF magic number. The Full Book
|
// count and whether the buffer starts with the right magic number. The
|
||||||
// view is on screen here, so what gets printed is exactly the assembled
|
// Full Book view is on screen here, so the PDF is the assembled manuscript
|
||||||
// manuscript (the print stylesheet hides all app chrome).
|
// (the print stylesheet hides all app chrome).
|
||||||
const pdfBtn = document.getElementById("exportPdfBtn");
|
const exportMenuToggle = document.getElementById("exportMenuToggle");
|
||||||
out.pdfBtnPresent = !!pdfBtn && !pdfBtn.disabled;
|
const exportMenu = document.getElementById("exportMenu");
|
||||||
|
out.exportMenuPresent = !!exportMenuToggle && !!exportMenu;
|
||||||
out.pdfExportPdfVisible = !document.getElementById("fullBookView").classList.contains("hidden");
|
out.pdfExportPdfVisible = !document.getElementById("fullBookView").classList.contains("hidden");
|
||||||
pdfBtn.click();
|
exportMenuToggle.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
out.exportMenuOpened = !exportMenu.classList.contains("hidden");
|
||||||
|
const pdfItem = exportMenu.querySelector('[data-export="pdf"]');
|
||||||
|
out.pdfItemPresent = !!pdfItem;
|
||||||
|
pdfItem.click();
|
||||||
await new Promise((r) => setTimeout(r, 2500));
|
await new Promise((r) => setTimeout(r, 2500));
|
||||||
out.pdfStatusShown = document
|
out.pdfStatusShown = document
|
||||||
.getElementById("fullBookStatus")
|
.getElementById("fullBookStatus")
|
||||||
|
|
@ -485,6 +491,39 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
|
||||||
pdfLongRes.bytes > pdfRes.bytes * 2 &&
|
pdfLongRes.bytes > pdfRes.bytes * 2 &&
|
||||||
pdfLongRes.pdfOk === true;
|
pdfLongRes.pdfOk === true;
|
||||||
|
|
||||||
|
// DOCX export: main process assembles the manuscript from the chapter
|
||||||
|
// files (all four chapters, including the very long one), writes a ZIP
|
||||||
|
// (PK magic) to FOLIO_TEST_DOCX, and reports its size. The deep content
|
||||||
|
// checks live in tests/run-office-export-test.mjs; here we verify the
|
||||||
|
// end-to-end IPC path, extension, magic number, and the status notice.
|
||||||
|
const docxRes = await window.folio.exportDocx();
|
||||||
|
out.docxExport =
|
||||||
|
docxRes && "filePath" in docxRes
|
||||||
|
? { filePath: docxRes.filePath, bytes: docxRes.bytes, docxOk: docxRes.docxOk }
|
||||||
|
: docxRes;
|
||||||
|
out.docxExportOk =
|
||||||
|
!!docxRes &&
|
||||||
|
!("error" in docxRes) &&
|
||||||
|
"filePath" in docxRes &&
|
||||||
|
docxRes.filePath.endsWith(".docx") &&
|
||||||
|
typeof docxRes.bytes === "number" &&
|
||||||
|
docxRes.bytes > 1000 &&
|
||||||
|
docxRes.docxOk === true;
|
||||||
|
|
||||||
|
const odtRes = await window.folio.exportOdt();
|
||||||
|
out.odtExport =
|
||||||
|
odtRes && "filePath" in odtRes
|
||||||
|
? { filePath: odtRes.filePath, bytes: odtRes.bytes, odtOk: odtRes.odtOk }
|
||||||
|
: odtRes;
|
||||||
|
out.odtExportOk =
|
||||||
|
!!odtRes &&
|
||||||
|
!("error" in odtRes) &&
|
||||||
|
"filePath" in odtRes &&
|
||||||
|
odtRes.filePath.endsWith(".odt") &&
|
||||||
|
typeof odtRes.bytes === "number" &&
|
||||||
|
odtRes.bytes > 1000 &&
|
||||||
|
odtRes.odtOk === true;
|
||||||
|
|
||||||
// Formatting must survive into the printed book: markdown-it is configured
|
// Formatting must survive into the printed book: markdown-it is configured
|
||||||
// to pass through the toolbar's HTML (<u>, <span style=...>, <div>),
|
// to pass through the toolbar's HTML (<u>, <span style=...>, <div>),
|
||||||
// ==highlight== becomes <mark>, callouts become styled divs, and
|
// ==highlight== becomes <mark>, callouts become styled divs, and
|
||||||
|
|
@ -810,7 +849,9 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
|
||||||
"fbAfterDeleteOk",
|
"fbAfterDeleteOk",
|
||||||
"fbAfterRenameOk",
|
"fbAfterRenameOk",
|
||||||
"fbEmptyOk",
|
"fbEmptyOk",
|
||||||
"pdfBtnPresent",
|
"exportMenuPresent",
|
||||||
|
"exportMenuOpened",
|
||||||
|
"pdfItemPresent",
|
||||||
"pdfExportPdfVisible",
|
"pdfExportPdfVisible",
|
||||||
"pdfStatusShown",
|
"pdfStatusShown",
|
||||||
"pdfExportOk",
|
"pdfExportOk",
|
||||||
|
|
@ -818,6 +859,8 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
|
||||||
"printBookHasAllChapters",
|
"printBookHasAllChapters",
|
||||||
"printBookLongFull",
|
"printBookLongFull",
|
||||||
"pdfLongExportOk",
|
"pdfLongExportOk",
|
||||||
|
"docxExportOk",
|
||||||
|
"odtExportOk",
|
||||||
"printFmtOk",
|
"printFmtOk",
|
||||||
"navOptionsOk",
|
"navOptionsOk",
|
||||||
"navOpenAtOneOk",
|
"navOpenAtOneOk",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue