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

@ -20,6 +20,7 @@ import {
Document, Document,
ExternalHyperlink, ExternalHyperlink,
HeadingLevel, HeadingLevel,
LineRuleType,
NumberFormat, NumberFormat,
Packer, Packer,
Paragraph, Paragraph,
@ -205,9 +206,15 @@ const defaultCtx: InlineCtx = {
underline: false, underline: false,
}; };
const paraSpacing = { after: 160 }; // Typography mirrors the Full Book editor: serif at 12pt, 1.7 line spacing,
const headingSpacing = { before: 240, after: 160 }; // 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 tableSpacing = { after: 40 };
const codeSpacing = { before: 120, after: 160, ...lineSpacing };
function hexColor(v: string): string { function hexColor(v: string): string {
return v.replace(/^#/, "").trim().toLowerCase(); return v.replace(/^#/, "").trim().toLowerCase();
@ -470,7 +477,7 @@ function htmlBlockToParagraph(html: string): Paragraph | null {
return new Paragraph({ return new Paragraph({
alignment: align === "center" ? AlignmentType.CENTER : align === "right" ? AlignmentType.RIGHT : undefined, alignment: align === "center" ? AlignmentType.CENTER : align === "right" ? AlignmentType.RIGHT : undefined,
children: [new TextRun({ text })], children: [new TextRun({ text })],
spacing: paraSpacing, spacing: bodySpacing,
}); });
} }
@ -487,7 +494,7 @@ function renderListItem(itemTokens: import("markdown-it").Token[], listCtx: { re
out.push( out.push(
new Paragraph({ new Paragraph({
children: runs, children: runs,
spacing: paraSpacing, spacing: listSpacing,
numbering: { reference: listCtx.reference, level: listCtx.level }, numbering: { reference: listCtx.reference, level: listCtx.level },
}) })
); );
@ -495,7 +502,7 @@ function renderListItem(itemTokens: import("markdown-it").Token[], listCtx: { re
out.push( out.push(
new Paragraph({ new Paragraph({
children: runs, children: runs,
spacing: paraSpacing, spacing: listSpacing,
indent: { left: 720 * (listCtx.level + 1), hanging: 360 }, indent: { left: 720 * (listCtx.level + 1), hanging: 360 },
}) })
); );
@ -589,7 +596,7 @@ function renderQuoted(tokens: import("markdown-it").Token[], isCallout: boolean)
indent: { left: 720 }, indent: { left: 720 },
border: QUOTE_BORDER, border: QUOTE_BORDER,
shading: isCallout ? CALLOUT_SHADING : undefined, shading: isCallout ? CALLOUT_SHADING : undefined,
spacing: paraSpacing, spacing: bodySpacing,
}) })
); );
i += 3; i += 3;
@ -632,7 +639,7 @@ function renderBlocks(tokens: import("markdown-it").Token[]): BlockEl[] {
inlineTok && inlineTok.type === "inline" inlineTok && inlineTok.type === "inline"
? inlineRuns(inlineTok.children ?? [], isTitle ? { ...defaultCtx, bold: true } : defaultCtx) ? 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; i += 3;
} else if (t.type === "bullet_list_open") { } else if (t.type === "bullet_list_open") {
const { blocks, next } = renderList(tokens, i, { reference: "bullets", level: 0 }); const { blocks, next } = renderList(tokens, i, { reference: "bullets", level: 0 });
@ -662,7 +669,7 @@ function renderBlocks(tokens: import("markdown-it").Token[]): BlockEl[] {
children: runs, children: runs,
shading: { type: ShadingType.CLEAR, fill: "F2F2F2" }, shading: { type: ShadingType.CLEAR, fill: "F2F2F2" },
border: { left: { style: BorderStyle.SINGLE, size: 12, color: "CCCCCC", space: 8 } }, border: { left: { style: BorderStyle.SINGLE, size: 12, color: "CCCCCC", space: 8 } },
spacing: { before: 120, after: 160 }, spacing: codeSpacing,
}) })
); );
i++; i++;
@ -766,12 +773,13 @@ export function buildDocx(bookPath: string): Promise<Buffer> {
}), }),
new Paragraph({ new Paragraph({
alignment: AlignmentType.CENTER, 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 }, spacing: { after: 360 },
}), }),
]; ];
for (const ch of chapters) { for (let ci = 0; ci < chapters.length; ci++) {
const ch = chapters[ci];
let content = ""; let content = "";
try { try {
content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8"); content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8");
@ -791,14 +799,28 @@ export function buildDocx(bookPath: string): Promise<Buffer> {
} }
} }
const markdown = hasOwnHeading ? body : body ? `# ${ch.title}\n\n${body}` : `# ${ch.title}`; 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({ const doc = new Document({
styles: { styles: {
default: { default: {
document: { document: {
run: { font: "Georgia", size: 22 }, run: { font: "Georgia", size: 24 },
}, },
}, },
}, },

View file

@ -441,7 +441,16 @@ export function initBook(api: FolioAPI) {
void handleSpecial(item); void handleSpecial(item);
}); });
// Bind toolbar dropdown toggles. const customTextColor = toolbar.querySelector<HTMLInputElement>("#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<HTMLButtonElement>("button[data-dropdown]").forEach((toggle) => { toolbar.querySelectorAll<HTMLButtonElement>("button[data-dropdown]").forEach((toggle) => {
const menu = document.getElementById(toggle.dataset.dropdown ?? ""); const menu = document.getElementById(toggle.dataset.dropdown ?? "");
if (!menu) return; if (!menu) return;
@ -452,10 +461,21 @@ export function initBook(api: FolioAPI) {
if (!wasOpen) { if (!wasOpen) {
menu.classList.remove("hidden"); menu.classList.remove("hidden");
toggle.classList.add("open"); 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", () => { headingSelect.addEventListener("change", () => {
setHeading(Number(headingSelect.value))(editorView); setHeading(Number(headingSelect.value))(editorView);
editorView.focus(); editorView.focus();

View file

@ -607,7 +607,7 @@ html, body {
display: block !important; display: block !important;
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 12pt; font-size: 12pt;
line-height: 1.6; line-height: 1.7;
color: #000; color: #000;
background: #fff; background: #fff;
} }
@ -629,14 +629,15 @@ html, body {
#printBook h4, #printBook h4,
#printBook h5, #printBook h5,
#printBook h6 { #printBook h6 {
margin: 0 0 0.7em; margin: 0 0 1.7em;
line-height: 1.3; line-height: 1.35;
color: #000; color: #000;
} }
#printBook h1 { font-size: 1.7em; } #printBook h1 { font-size: 1.7em; }
#printBook h2 { font-size: 1.45em; } #printBook h2 { font-size: 1.45em; }
#printBook h3 { font-size: 1.25em; } #printBook h3 { font-size: 1.25em; }
#printBook h4 { font-size: 1.1em; } #printBook h4 { font-size: 1.1em; }
#printBook h5, #printBook h6 { font-size: 1em; }
#printBook p, #printBook p,
#printBook ul, #printBook ul,
@ -644,9 +645,10 @@ html, body {
#printBook blockquote, #printBook blockquote,
#printBook pre, #printBook pre,
#printBook table { #printBook table {
margin: 0 0 0.8em; margin: 0 0 1.7em;
} }
#printBook ul, #printBook ol { padding-left: 1.5em; } #printBook ul, #printBook ol { padding-left: 1.5em; }
#printBook li { margin: 0; }
#printBook blockquote { #printBook blockquote {
padding-left: 1em; padding-left: 1em;
border-left: 3px solid #999; border-left: 3px solid #999;
@ -677,7 +679,7 @@ html, body {
border-left: 4px solid #3b82f6; border-left: 4px solid #3b82f6;
background: #f0f6ff; background: #f0f6ff;
padding: 0.6em 1em; padding: 0.6em 1em;
margin: 0 0 0.8em; margin: 0 0 1.7em;
} }
#printBook .callout-title { #printBook .callout-title {
font-weight: 700; font-weight: 700;
@ -884,6 +886,30 @@ html, body {
outline-offset: 1px; 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 { .color-menu .dropdown-sep {
flex-basis: 100%; flex-basis: 100%;
} }

View file

@ -152,6 +152,10 @@
<button class="color-swatch" data-cmd="color:#78716c" title="Gray" style="background:#78716c"></button> <button class="color-swatch" data-cmd="color:#78716c" title="Gray" style="background:#78716c"></button>
<button class="color-swatch" data-cmd="color:#18181b" title="Black" style="background:#18181b"></button> <button class="color-swatch" data-cmd="color:#18181b" title="Black" style="background:#18181b"></button>
<div class="dropdown-sep"></div> <div class="dropdown-sep"></div>
<label class="custom-color-row" title="Pick any color">
<input type="color" id="customTextColor" value="#3b82f6" />
<span>Custom&#8230;</span>
</label>
<button class="dropdown-item" data-cmd="removecolor" title="Remove text color">Remove color</button> <button class="dropdown-item" data-cmd="removecolor" title="Remove text color">Remove color</button>
</div> </div>
</div> </div>

View file

@ -97,6 +97,7 @@ const origManifest = fs.readFileSync(path.join(bookDir, "folio.json"));
const docxBuf = await mod.buildDocx(bookDir); const docxBuf = await mod.buildDocx(bookDir);
const docxZip = await JSZip.loadAsync(docxBuf); 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 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 [Content_Types].xml", !!docxZip.file("[Content_Types].xml"));
@ -106,7 +107,7 @@ const stripTags = (xml) => xml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").tri
const docxText = stripTags(docxXml); const docxText = stripTags(docxXml);
check("docx contains book title", docxText.includes("Office Test Book")); check("docx contains book title", docxText.includes("Office Test Book"));
check("docx contains byline", docxText.includes("A book compiled with Folio._")); check("docx contains byline", docxText.includes("A book compiled with Folio."));
const idxAlpha = docxText.indexOf("Alpha"); const idxAlpha = docxText.indexOf("Alpha");
const idxBeta = docxText.indexOf("Beta"); 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 table", /<w:tbl>/.test(docxXml));
check("docx has code font", /Consolas/.test(docxXml)); check("docx has code font", /Consolas/.test(docxXml));
check("docx has centered alignment", /w:val="center"/.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 has callout shading", /w:fill="F5F2ED"/.test(docxXml));
check("docx renders ==highlight== (no raw markup)", !docxText.includes("==highlight==")); check("docx renders ==highlight== (no raw markup)", !docxText.includes("==highlight=="));
check("docx renders **bold** (no raw markup)", !docxText.includes("**bold**")); 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)); await new Promise((r) => setTimeout(r, 50));
out.painterOk = cm.state.doc.toString() === "**src** **dst**"; 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 // leave some formatted content on disk for the autosave check
cm.dispatch({ cm.dispatch({
changes: { from: 0, to: cm.state.doc.length, insert: "**Hello** World" }, changes: { from: 0, to: cm.state.doc.length, insert: "**Hello** World" },
@ -859,6 +889,10 @@ const BOOK3_DIR = __BOOKDIR3_VALUE__;
"highlightOk", "highlightOk",
"tableOk", "tableOk",
"painterOk", "painterOk",
"colorMenuOpens",
"colorMenuOnScreen",
"colorApplied",
"customColorApplied",
"formattingAutosaved", "formattingAutosaved",
"fullBookShown", "fullBookShown",
"assembledOk", "assembledOk",