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
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.");
|
||||
Loading…
Add table
Add a link
Reference in a new issue