Match the editor's spacing in PDF/DOCX/ODT and fix the toolbar dropdown

Exports now use the same typography as the Full Book editor — Georgia
serif at 12pt, 1.7 line spacing, and a blank-line gap between paragraphs -
so the on-screen editor and every exported format look consistent. DOCX/
ODT also gain a chapter break before each chapter after the first.
This commit is contained in:
avi 2026-08-18 18:39:21 -05:00
commit 0df26d4ce6
6 changed files with 143 additions and 27 deletions

View file

@ -96,17 +96,18 @@ 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 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"));
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 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 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");
@ -129,6 +130,15 @@ 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));
check("docx has chapter break before later chapters", (docxXml.match(/w:before="320"/g) || []).length >= 4);
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**"));

View file

@ -204,6 +204,36 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
await new Promise((r) => setTimeout(r, 50));
out.painterOk = cm.state.doc.toString() === "**src** **dst**";
// text color via the toolbar dropdown.
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "color me" }, selection: { anchor: 0, head: 5 } });
cm.focus();
const colorToggle = document.querySelector('[data-dropdown="colorMenu"]');
colorToggle?.click();
await new Promise((r) => setTimeout(r, 50));
const colorMenuEl = document.getElementById("colorMenu");
const colorRect = colorMenuEl?.getBoundingClientRect();
out.colorMenuOpens =
!!colorRect && !colorMenuEl?.classList.contains("hidden") && colorRect.bottom <= window.innerHeight + 1;
// the dropdown is pinned to the viewport, so it must stay on screen even
// though the toolbar itself is a horizontal scroll container
out.colorMenuOnScreen = !!colorRect && colorRect.top >= 0 && colorRect.bottom <= window.innerHeight;
document.querySelector('[data-cmd="color:#e11d48"]').click();
await new Promise((r) => setTimeout(r, 50));
out.colorApplied = cm.state.doc.toString() === '<span style="color:#e11d48">color</span> me';
// custom color picker works
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "pick me" }, selection: { anchor: 0, head: 4 } });
cm.focus();
colorToggle?.click();
await new Promise((r) => setTimeout(r, 50));
const customInput = document.getElementById("customTextColor");
customInput.value = "#0066cc";
customInput.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
out.customColorApplied = cm.state.doc.toString() === '<span style="color:#0066cc">pick</span> me';
// leave some formatted content on disk for the autosave check
cm.dispatch({
changes: { from: 0, to: cm.state.doc.length, insert: "**Hello** World" },
@ -859,6 +889,10 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
"highlightOk",
"tableOk",
"painterOk",
"colorMenuOpens",
"colorMenuOnScreen",
"colorApplied",
"customColorApplied",
"formattingAutosaved",
"fullBookShown",
"assembledOk",