Add DOCX and ODT export for the Full Book view

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

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

View file

@ -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 .

View file

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

View file

@ -408,16 +408,22 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
out.fbWithEmpty = assembledWithEmpty;
out.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",