Improve the export pipeline (shared ordering + chapter page breaks)

- buildDocx/buildOdt now accept an explicit ordered chapter list, making that
  list the single source of truth for export ordering (defaults to whole book)
- Emit real page breaks between chapters in DOCX, with none before the first
- Unify the DOCX/ODT handlers into one exportOffice pipeline (ODT still built
  from the same DOCX via LibreOffice)
This commit is contained in:
avi 2026-08-20 11:44:54 -05:00
commit 7037cd9fc0
3 changed files with 55 additions and 53 deletions

View file

@ -382,59 +382,43 @@ ipcMain.handle("folio:exportPdf", async () => {
}
});
// Export the manuscript as a DOCX. Built entirely in the main process from the
// chapter files, so it does not depend on which view is on screen. The e2e
// harness overrides the save dialog with FOLIO_TEST_DOCX.
ipcMain.handle("folio:exportDocx", async () => {
// Export the manuscript as a DOCX or ODT. Both formats share one pipeline: the
// native chapter documents are assembled in canonical order (see buildDocx), and
// ODT is produced from that same DOCX via LibreOffice so the two stay consistent.
// The e2e harness overrides the save dialog with FOLIO_TEST_DOCX / FOLIO_TEST_ODT.
async function exportOffice(format: "docx" | "odt") {
try {
const bp = requireBook();
const ext = format;
const envKey = format === "docx" ? "FOLIO_TEST_DOCX" : "FOLIO_TEST_ODT";
let filePath: string;
if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_DOCX) {
filePath = process.env.FOLIO_TEST_DOCX;
if (process.env.FOLIO_SELF_TEST === "1" && process.env[envKey]) {
filePath = process.env[envKey] as string;
} else {
const { canceled, filePath: chosen } = await showSaveDialog({
title: "Export Full Book as DOCX",
defaultPath: `${currentBookTitle()}.docx`,
filters: [{ name: "Word Document", extensions: ["docx"] }],
title: `Export Full Book as ${format.toUpperCase()}`,
defaultPath: `${currentBookTitle()}.${ext}`,
filters: [
{
name: format === "docx" ? "Word Document" : "OpenDocument Text",
extensions: [ext],
},
],
});
if (canceled || !chosen) return { canceled: true } as const;
filePath = chosen.endsWith(".docx") ? chosen : `${chosen}.docx`;
filePath = chosen.endsWith(`.${ext}`) ? chosen : `${chosen}.${ext}`;
}
const buf = await buildDocx(bp);
const buf = format === "docx" ? await buildDocx(bp) : await buildOdt(bp);
fs.writeFileSync(filePath, buf);
const docxOk = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK";
return { ok: true, filePath, bytes: buf.length, docxOk };
const ok = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK";
return { ok: true, filePath, bytes: buf.length, [`${format}Ok`]: ok };
} catch (e) {
return { error: (e as Error).message };
}
});
}
// Export the manuscript as an ODT via LibreOffice (converted from the same
// DOCX so both exports stay consistent). Fails with a helpful message when
// LibreOffice is not installed.
ipcMain.handle("folio:exportOdt", async () => {
try {
const bp = requireBook();
let filePath: string;
if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_ODT) {
filePath = process.env.FOLIO_TEST_ODT;
} else {
const { canceled, filePath: chosen } = await showSaveDialog({
title: "Export Full Book as ODT",
defaultPath: `${currentBookTitle()}.odt`,
filters: [{ name: "OpenDocument Text", extensions: ["odt"] }],
});
if (canceled || !chosen) return { canceled: true } as const;
filePath = chosen.endsWith(".odt") ? chosen : `${chosen}.odt`;
}
const buf = await buildOdt(bp);
fs.writeFileSync(filePath, buf);
const odtOk = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK";
return { ok: true, filePath, bytes: buf.length, odtOk };
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:exportDocx", () => exportOffice("docx"));
ipcMain.handle("folio:exportOdt", () => exportOffice("odt"));
ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
try {

View file

@ -29,7 +29,7 @@ import {
TextRun,
WidthType,
} from "docx";
import { loadBook } from "./project.js";
import { loadBook, type ChapterEntry } from "./project.js";
import { listChaptersInOrder, loadChapterDoc } from "./chapters.js";
import type { FolioMark, FolioNode } from "./native-doc.js";
@ -422,13 +422,17 @@ function renderImage(node: FolioNode, bookPath: string): Paragraph {
});
}
// Assemble the manuscript directly from native chapter documents, in canonical
// chapter order. The book title leads, then each chapter (prepending a title
// heading only when the content does not already carry one). A small spacer
// before chapters after the first keeps the on-paper rhythm of the editor.
export function buildDocx(bookPath: string): Promise<Buffer> {
// Assemble the manuscript directly from native chapter documents. The chapters
// to include are passed in explicitly (in canonical order) so the same ordered
// list drives every export format — the single source of truth for ordering.
// When omitted, the whole book (in stored order) is used for backward
// compatibility. The book title leads, then each chapter (prepending a title
// heading only when the content does not already carry one). Each chapter after
// the first begins on a new page; the first chapter keeps its natural position
// so no blank page is emitted up front.
export function buildDocx(bookPath: string, chapters?: ChapterEntry[]): Promise<Buffer> {
const meta = loadBook(bookPath);
const chapters = listChaptersInOrder(meta);
const used = chapters && chapters.length ? chapters : listChaptersInOrder(meta);
const blocks: BlockEl[] = [
new Paragraph({
heading: HeadingLevel.TITLE,
@ -443,7 +447,8 @@ export function buildDocx(bookPath: string): Promise<Buffer> {
}),
];
chapters.forEach((ch, ci) => {
let rendered = 0;
used.forEach((ch) => {
let doc: FolioNode;
try {
doc = loadChapterDoc(bookPath, ch.id).doc;
@ -456,10 +461,14 @@ export function buildDocx(bookPath: string): Promise<Buffer> {
: content;
if (chapterBlocks.length) {
if (ci > 0) {
blocks.push(new Paragraph({ children: [], spacing: { before: 320, after: 0 } }));
if (rendered > 0) {
// Real page break between chapters (not just whitespace).
blocks.push(
new Paragraph({ pageBreakBefore: true, spacing: { before: 0, after: 0 }, children: [] })
);
}
blocks.push(...renderNodes(chapterBlocks, bookPath));
rendered += 1;
}
});
@ -502,8 +511,8 @@ export function buildDocx(bookPath: string): Promise<Buffer> {
// Convert the generated DOCX to ODT with LibreOffice running headless. This
// keeps the two exports consistent and produces a spec-compliant ODT without
// hand-writing a second XML vocabulary.
export async function buildOdt(bookPath: string): Promise<Buffer> {
const docxBuf = await buildDocx(bookPath);
export async function buildOdt(bookPath: string, chapters?: ChapterEntry[]): Promise<Buffer> {
const docxBuf = await buildDocx(bookPath, chapters);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-odt-"));
try {
const tmpDocx = path.join(tmpDir, "book.docx");

View file

@ -206,7 +206,16 @@ check("docx body size is 12pt (24 half-points)", /<w:sz w:val="24"\/>/.test(docx
// 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);
// 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**"));