From 0df26d4ce667e0974fee2118101f19b1a981452b Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 18 Aug 2026 18:39:21 -0500 Subject: [PATCH] Match the editor's spacing in PDF/DOCX/ODT and fix the toolbar dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/main/office-export.ts | 46 +++++++++++++++++++++++--------- src/renderer/book.ts | 22 ++++++++++++++- src/renderer/index.css | 38 +++++++++++++++++++++----- src/renderer/index.html | 4 +++ tests/run-office-export-test.mjs | 26 ++++++++++++------ tests/selftest-scenario.js | 34 +++++++++++++++++++++++ 6 files changed, 143 insertions(+), 27 deletions(-) diff --git a/src/main/office-export.ts b/src/main/office-export.ts index dde187f..f92209f 100644 --- a/src/main/office-export.ts +++ b/src/main/office-export.ts @@ -20,6 +20,7 @@ import { Document, ExternalHyperlink, HeadingLevel, + LineRuleType, NumberFormat, Packer, Paragraph, @@ -205,9 +206,15 @@ const defaultCtx: InlineCtx = { underline: false, }; -const paraSpacing = { after: 160 }; -const headingSpacing = { before: 240, after: 160 }; +// Typography mirrors the Full Book editor: serif at 12pt, 1.7 line spacing, +// and one blank line (1.7 x the font size, 408 twips) between prose blocks so +// the exported document has the same airy rhythm as the on-screen editor. +const lineSpacing = { line: 408, lineRule: LineRuleType.AUTO }; +const bodySpacing = { after: 408, ...lineSpacing }; +const headingSpacing = { before: 240, after: 408, ...lineSpacing }; +const listSpacing = { after: 60, ...lineSpacing }; const tableSpacing = { after: 40 }; +const codeSpacing = { before: 120, after: 160, ...lineSpacing }; function hexColor(v: string): string { return v.replace(/^#/, "").trim().toLowerCase(); @@ -470,7 +477,7 @@ function htmlBlockToParagraph(html: string): Paragraph | null { return new Paragraph({ alignment: align === "center" ? AlignmentType.CENTER : align === "right" ? AlignmentType.RIGHT : undefined, children: [new TextRun({ text })], - spacing: paraSpacing, + spacing: bodySpacing, }); } @@ -487,7 +494,7 @@ function renderListItem(itemTokens: import("markdown-it").Token[], listCtx: { re out.push( new Paragraph({ children: runs, - spacing: paraSpacing, + spacing: listSpacing, numbering: { reference: listCtx.reference, level: listCtx.level }, }) ); @@ -495,7 +502,7 @@ function renderListItem(itemTokens: import("markdown-it").Token[], listCtx: { re out.push( new Paragraph({ children: runs, - spacing: paraSpacing, + spacing: listSpacing, indent: { left: 720 * (listCtx.level + 1), hanging: 360 }, }) ); @@ -589,7 +596,7 @@ function renderQuoted(tokens: import("markdown-it").Token[], isCallout: boolean) indent: { left: 720 }, border: QUOTE_BORDER, shading: isCallout ? CALLOUT_SHADING : undefined, - spacing: paraSpacing, + spacing: bodySpacing, }) ); i += 3; @@ -632,7 +639,7 @@ function renderBlocks(tokens: import("markdown-it").Token[]): BlockEl[] { inlineTok && inlineTok.type === "inline" ? inlineRuns(inlineTok.children ?? [], isTitle ? { ...defaultCtx, bold: true } : defaultCtx) : []; - out.push(new Paragraph({ children: runs, spacing: paraSpacing })); + out.push(new Paragraph({ children: runs, spacing: bodySpacing })); i += 3; } else if (t.type === "bullet_list_open") { const { blocks, next } = renderList(tokens, i, { reference: "bullets", level: 0 }); @@ -662,7 +669,7 @@ function renderBlocks(tokens: import("markdown-it").Token[]): BlockEl[] { children: runs, shading: { type: ShadingType.CLEAR, fill: "F2F2F2" }, border: { left: { style: BorderStyle.SINGLE, size: 12, color: "CCCCCC", space: 8 } }, - spacing: { before: 120, after: 160 }, + spacing: codeSpacing, }) ); i++; @@ -766,12 +773,13 @@ export function buildDocx(bookPath: string): Promise { }), new Paragraph({ alignment: AlignmentType.CENTER, - children: [new TextRun({ italics: true, color: "666666", text: "A book compiled with Folio._" })], + children: [new TextRun({ italics: true, color: "666666", text: "A book compiled with Folio." })], spacing: { after: 360 }, }), ]; - for (const ch of chapters) { + for (let ci = 0; ci < chapters.length; ci++) { + const ch = chapters[ci]; let content = ""; try { content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8"); @@ -791,14 +799,28 @@ export function buildDocx(bookPath: string): Promise { } } const markdown = hasOwnHeading ? body : body ? `# ${ch.title}\n\n${body}` : `# ${ch.title}`; - if (markdown) blocks.push(...renderBlocks(md.parse(markdown, {}))); + if (markdown) { + // The editor leaves a 2.5rem + 1.5rem gap (about 48pt) before each + // chapter, shown as a thin rule on the chapter's first line. Mirror a + // similar break (~320 twips + a leading line) before chapters after the + // first so the manuscript keeps the same rhythm on paper as on screen. + if (ci > 0) { + blocks.push( + new Paragraph({ + children: [], + spacing: { before: 320, after: 0 }, + }) + ); + } + blocks.push(...renderBlocks(md.parse(markdown, {}))); + } } const doc = new Document({ styles: { default: { document: { - run: { font: "Georgia", size: 22 }, + run: { font: "Georgia", size: 24 }, }, }, }, diff --git a/src/renderer/book.ts b/src/renderer/book.ts index a6510d2..3ba52cf 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -441,7 +441,16 @@ export function initBook(api: FolioAPI) { void handleSpecial(item); }); - // Bind toolbar dropdown toggles. + const customTextColor = toolbar.querySelector("#customTextColor"); + customTextColor?.addEventListener("change", () => { + toggleTextColor(customTextColor.value)(editorView); + editorView.focus(); + closeDropdowns(); + }); + + // Bind toolbar dropdown toggles. The toolbar itself is a horizontal scroll + // container (overflow-x: auto), which would clip an absolutely-positioned + // menu, so an open menu is pinned to the viewport under its toggle instead. toolbar.querySelectorAll("button[data-dropdown]").forEach((toggle) => { const menu = document.getElementById(toggle.dataset.dropdown ?? ""); if (!menu) return; @@ -452,10 +461,21 @@ export function initBook(api: FolioAPI) { if (!wasOpen) { menu.classList.remove("hidden"); toggle.classList.add("open"); + const rect = toggle.getBoundingClientRect(); + const menuWidth = menu.offsetWidth; + const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8)); + menu.style.position = "fixed"; + menu.style.top = `${rect.bottom + 4}px`; + menu.style.left = `${left}px`; } }); }); + // A pinned menu goes stale if the page scrolls or the window resizes, so + // any of those closes the dropdowns. + window.addEventListener("scroll", closeDropdowns, true); + window.addEventListener("resize", closeDropdowns); + headingSelect.addEventListener("change", () => { setHeading(Number(headingSelect.value))(editorView); editorView.focus(); diff --git a/src/renderer/index.css b/src/renderer/index.css index d89e099..5bad895 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -603,11 +603,11 @@ html, body { display: none !important; } - #printBook { + #printBook { display: block !important; font-family: var(--font-serif); font-size: 12pt; - line-height: 1.6; + line-height: 1.7; color: #000; background: #fff; } @@ -629,14 +629,15 @@ html, body { #printBook h4, #printBook h5, #printBook h6 { - margin: 0 0 0.7em; - line-height: 1.3; + margin: 0 0 1.7em; + line-height: 1.35; color: #000; } #printBook h1 { font-size: 1.7em; } #printBook h2 { font-size: 1.45em; } #printBook h3 { font-size: 1.25em; } #printBook h4 { font-size: 1.1em; } + #printBook h5, #printBook h6 { font-size: 1em; } #printBook p, #printBook ul, @@ -644,9 +645,10 @@ html, body { #printBook blockquote, #printBook pre, #printBook table { - margin: 0 0 0.8em; + margin: 0 0 1.7em; } #printBook ul, #printBook ol { padding-left: 1.5em; } + #printBook li { margin: 0; } #printBook blockquote { padding-left: 1em; border-left: 3px solid #999; @@ -677,7 +679,7 @@ html, body { border-left: 4px solid #3b82f6; background: #f0f6ff; padding: 0.6em 1em; - margin: 0 0 0.8em; + margin: 0 0 1.7em; } #printBook .callout-title { font-weight: 700; @@ -884,6 +886,30 @@ html, body { outline-offset: 1px; } +.custom-color-row { + flex-basis: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 5px 6px 6px; + cursor: pointer; +} + +.custom-color-row input[type="color"] { + width: 26px; + height: 22px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: 6px; + background: transparent; + cursor: pointer; +} + +.custom-color-row span { + font-size: 0.82rem; + color: var(--color-text); +} + .color-menu .dropdown-sep { flex-basis: 100%; } diff --git a/src/renderer/index.html b/src/renderer/index.html index 9fa1c80..83457e0 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -152,6 +152,10 @@ + diff --git a/tests/run-office-export-test.mjs b/tests/run-office-export-test.mjs index 939d839..5469f2d 100644 --- a/tests/run-office-export-test.mjs +++ b/tests/run-office-export-test.mjs @@ -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", //.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", //.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:line="408"[^>]*w:lineRule="auto"[^>]*\/>/.test(docxXml)); +check("docx has blank-line paragraph 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**")); diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js index eb0ea63..f08f045 100644 --- a/tests/selftest-scenario.js +++ b/tests/selftest-scenario.js @@ -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() === 'color 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() === 'pick 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",