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:
parent
247f16baa5
commit
0df26d4ce6
6 changed files with 143 additions and 27 deletions
|
|
@ -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<Buffer> {
|
|||
}),
|
||||
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<Buffer> {
|
|||
}
|
||||
}
|
||||
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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -441,7 +441,16 @@ export function initBook(api: FolioAPI) {
|
|||
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) => {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:#18181b" title="Black" style="background:#18181b"></button>
|
||||
<div class="dropdown-sep"></div>
|
||||
<label class="custom-color-row" title="Pick any color">
|
||||
<input type="color" id="customTextColor" value="#3b82f6" />
|
||||
<span>Custom…</span>
|
||||
</label>
|
||||
<button class="dropdown-item" data-cmd="removecolor" title="Remove text color">Remove color</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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**"));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue