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:
parent
7bdddf32b2
commit
7037cd9fc0
3 changed files with 55 additions and 53 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue