diff --git a/src/main/index.ts b/src/main/index.ts index cec9940..5c51cdd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { diff --git a/src/main/office-export.ts b/src/main/office-export.ts index 228da2c..f701c50 100644 --- a/src/main/office-export.ts +++ b/src/main/office-export.ts @@ -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 { +// 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 { 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 { }), ]; - 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 { : 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 { // 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 { - const docxBuf = await buildDocx(bookPath); +export async function buildOdt(bookPath: string, chapters?: ChapterEntry[]): Promise { + const docxBuf = await buildDocx(bookPath, chapters); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-odt-")); try { const tmpDocx = path.join(tmpDir, "book.docx"); diff --git a/tests/run-office-export-test.mjs b/tests/run-office-export-test.mjs index d2de863..37bb371 100644 --- a/tests/run-office-export-test.mjs +++ b/tests/run-office-export-test.mjs @@ -206,7 +206,16 @@ check("docx body size is 12pt (24 half-points)", //.test(docx // 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); +// 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(//g) || []; +check("docx has a page break before later chapters", pageBreaks.length >= 4); +const firstBreak = docxXml.indexOf(""); +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**"));