Folio/tests/run-office-export-test.mjs

275 lines
12 KiB
JavaScript

// 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 whose chapters are stored as native Folio document
// JSON, then inspects the produced ZIP contents to check that the manuscript,
// its chapter order, and its formatting survive the native -> DOCX path.
// Run with: node tests/run-office-export-test.mjs
import fs from "fs";
import os from "os";
import path from "path";
import JSZip from "jszip";
import { bundleForTest } from "./helpers/bundle.mjs";
const { mod, cleanupBundle } = await bundleForTest({
name: "folio-office",
entryPoints: ["src/main/office-export.ts"],
external: ["electron"],
});
let failures = 0;
const check = (name, ok) => {
console.log(`${ok ? "PASS" : "FAIL"} ${name}`);
if (!ok) failures += 1;
};
// --- fixture book (native JSON chapters) ------------------------------------
const bookDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-office-test-"));
fs.mkdirSync(path.join(bookDir, "chapters"), { recursive: true });
const chapters = {};
const order = [];
// Build a native chapter file ({ schemaVersion, doc }).
const addChapter = (id, title, content) => {
const file = path.posix.join("chapters", `${id}.json`);
order.push(id);
chapters[id] = { id, title, file };
fs.writeFileSync(
path.join(bookDir, file),
JSON.stringify({ schemaVersion: 1, doc: { type: "doc", content } }, null, 2)
);
};
// Inline helpers --------------------------------------------------------------
const text = (t, marks = []) => ({ type: "text", text: t, marks });
const para = (content, attrs = {}) => ({ type: "paragraph", attrs, content });
const heading = (level, t) => ({ type: "heading", attrs: { level }, content: [text(t)] });
const listItem = (content) => ({ type: "listItem", content });
addChapter("ch1", "Alpha", [
heading(1, "Alpha"),
para([
text("bold", [{ type: "bold" }]),
text(" "),
text("italic", [{ type: "italic" }]),
text(" "),
text("strike", [{ type: "strike" }]),
text(" "),
text("highlight", [{ type: "highlight" }]),
text(" "),
text("under", [{ type: "underline" }]),
]),
para([
text("red", [{ type: "textColor", attrs: { color: "#e11d48" } }]),
text(" "),
text("marked", [{ type: "highlight", attrs: { color: "#fde047" } }]),
]),
{
type: "bulletList",
content: [
listItem([para([text("bullet one")])]),
listItem([
para([text("bullet two")]),
{
type: "bulletList",
content: [listItem([para([text("nested bullet")])])],
},
]),
],
},
{
type: "orderedList",
content: [
listItem([para([text("numbered one")])]),
listItem([para([text("numbered two")])]),
],
},
{
type: "blockquote",
attrs: { callout: "note" },
content: [
para([text("Callout title")], { class: "callout-title" }),
para([text("callout body")]),
],
},
{
type: "blockquote",
content: [para([text("plain quote")])],
},
{ type: "codeBlock", attrs: { language: "js" }, content: [text("const answer = 42;")] },
{ type: "horizontalRule" },
{
type: "table",
content: [
{
type: "tableRow",
content: [
{ type: "tableHeader", content: [para([text("Head A")])] },
{ type: "tableHeader", content: [para([text("Head B")])] },
],
},
{
type: "tableRow",
content: [
{ type: "tableCell", content: [para([text("cell 1")])] },
{ type: "tableCell", content: [para([text("cell 2")])] },
],
},
],
},
para([text("Folio", [{ type: "link", attrs: { href: "https://example.com" } }])]),
para([text("Go to Beta", [{ type: "link", attrs: { href: "folio:chapter/Beta" } }])]),
para([text("centered text")], { textAlign: "center" }),
]);
addChapter("ch2", "Beta", [para([text("This chapter has no heading of its own.")])]);
addChapter("ch3", "Gamma", [
heading(1, "Gamma"),
heading(2, "Subsection"),
para([text("trailing paragraph")]),
]);
addChapter("ch4", "Empty", []);
addChapter("ch5", "Unicode", [para([text("Café — naïve naïve 中文 📖")])]);
// Real image file so the DOCX embedding path (ImageRun) is exercised: a
// relative src must resolve against the book directory and the bytes survive.
fs.mkdirSync(path.join(bookDir, "assets"), { recursive: true });
fs.writeFileSync(
path.join(bookDir, "assets", "pic.png"),
Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64")
);
addChapter("ch6", "Pics", [{ type: "image", attrs: { src: "assets/pic.png", alt: "a picture" } }]);
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");
const docxStyles = await docxZip.file("word/styles.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");
const idxPics = docxText.indexOf("Pics");
check(
"docx keeps chapter order",
idxAlpha >= 0 && idxAlpha < idxBeta && idxBeta < idxGamma && idxGamma < idxUnicode && idxUnicode < idxPics
);
// The explicitly passed chapter list is the single source of truth for export
// ordering: a non-canonical subset exports in the passed order (and only those
// chapters), not the book's stored order.
const subsetBuf = await mod.buildDocx(bookDir, [chapters.ch3, chapters.ch1, chapters.ch2]);
const subsetZip = await JSZip.loadAsync(subsetBuf);
const subsetXml = await subsetZip.file("word/document.xml").async("string");
const sText = stripTags(subsetXml);
const sG = sText.indexOf("Gamma");
const sA = sText.indexOf("Alpha");
const sB = sText.indexOf("Beta");
check("docx subset honors the passed order (Gamma, Alpha, Beta)", sG >= 0 && sG < sA && sA < sB);
check("docx subset excludes unselected chapters", !sText.includes("Unicode") && !sText.includes("Pics"));
const mediaFiles = Object.keys(docxZip.files).filter((f) => f.startsWith("word/media/"));
check("docx embeds the image (no data loss)", mediaFiles.length >= 1);
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));
// Body font/size come from word/styles.xml (document defaults), mirroring the
// editor's Georgia / 12pt choice.
check("docx uses Georgia body font", /<w:rFonts w:ascii="Georgia"/.test(docxStyles));
check("docx body size is 12pt (24 half-points)", /<w:sz w:val="24"\/>/.test(docxStyles));
// Matches the editor: 1.7 line spacing and a blank line (408 twips) between
// paragraphs. Attributes may be emitted in any order, so match loosely.
check("docx has 1.7 line spacing", /<w:spacing[^>]*w:line="408"[^>]*w:lineRule="auto"[^>]*\/>/.test(docxXml));
check("docx has blank-line paragraph spacing", /<w:spacing[^>]*w:after="408"/.test(docxXml));
// Each chapter after the first begins on a new page (a real w:pageBreakBefore),
// and there must be no page break before the first chapter (no blank first page).
const pageBreaks = docxXml.match(/<w:pageBreakBefore\/>/g) || [];
check("docx has a page break before later chapters", pageBreaks.length >= 4);
const firstBreak = docxXml.indexOf("<w:pageBreakBefore/>");
const titleIdx = docxXml.indexOf("Office Test Book");
check(
"docx page breaks come after the book title (no blank first page)",
firstBreak === -1 || firstBreak > titleIdx
);
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 });
cleanupBundle();
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.");