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
|
// Export the manuscript as a DOCX or ODT. Both formats share one pipeline: the
|
||||||
// chapter files, so it does not depend on which view is on screen. The e2e
|
// native chapter documents are assembled in canonical order (see buildDocx), and
|
||||||
// harness overrides the save dialog with FOLIO_TEST_DOCX.
|
// ODT is produced from that same DOCX via LibreOffice so the two stay consistent.
|
||||||
ipcMain.handle("folio:exportDocx", async () => {
|
// The e2e harness overrides the save dialog with FOLIO_TEST_DOCX / FOLIO_TEST_ODT.
|
||||||
|
async function exportOffice(format: "docx" | "odt") {
|
||||||
try {
|
try {
|
||||||
const bp = requireBook();
|
const bp = requireBook();
|
||||||
|
const ext = format;
|
||||||
|
const envKey = format === "docx" ? "FOLIO_TEST_DOCX" : "FOLIO_TEST_ODT";
|
||||||
let filePath: string;
|
let filePath: string;
|
||||||
if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_DOCX) {
|
if (process.env.FOLIO_SELF_TEST === "1" && process.env[envKey]) {
|
||||||
filePath = process.env.FOLIO_TEST_DOCX;
|
filePath = process.env[envKey] as string;
|
||||||
} else {
|
} else {
|
||||||
const { canceled, filePath: chosen } = await showSaveDialog({
|
const { canceled, filePath: chosen } = await showSaveDialog({
|
||||||
title: "Export Full Book as DOCX",
|
title: `Export Full Book as ${format.toUpperCase()}`,
|
||||||
defaultPath: `${currentBookTitle()}.docx`,
|
defaultPath: `${currentBookTitle()}.${ext}`,
|
||||||
filters: [{ name: "Word Document", extensions: ["docx"] }],
|
filters: [
|
||||||
|
{
|
||||||
|
name: format === "docx" ? "Word Document" : "OpenDocument Text",
|
||||||
|
extensions: [ext],
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
if (canceled || !chosen) return { canceled: true } as const;
|
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);
|
fs.writeFileSync(filePath, buf);
|
||||||
const docxOk = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK";
|
const ok = buf.length > 0 && buf.subarray(0, 2).toString("utf-8") === "PK";
|
||||||
return { ok: true, filePath, bytes: buf.length, docxOk };
|
return { ok: true, filePath, bytes: buf.length, [`${format}Ok`]: ok };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: (e as Error).message };
|
return { error: (e as Error).message };
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Export the manuscript as an ODT via LibreOffice (converted from the same
|
ipcMain.handle("folio:exportDocx", () => exportOffice("docx"));
|
||||||
// DOCX so both exports stay consistent). Fails with a helpful message when
|
ipcMain.handle("folio:exportOdt", () => exportOffice("odt"));
|
||||||
// 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:saveChapter", (_e, id: string, content: string) => {
|
ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import {
|
||||||
TextRun,
|
TextRun,
|
||||||
WidthType,
|
WidthType,
|
||||||
} from "docx";
|
} from "docx";
|
||||||
import { loadBook } from "./project.js";
|
import { loadBook, type ChapterEntry } from "./project.js";
|
||||||
import { listChaptersInOrder, loadChapterDoc } from "./chapters.js";
|
import { listChaptersInOrder, loadChapterDoc } from "./chapters.js";
|
||||||
import type { FolioMark, FolioNode } from "./native-doc.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
|
// Assemble the manuscript directly from native chapter documents. The chapters
|
||||||
// chapter order. The book title leads, then each chapter (prepending a title
|
// to include are passed in explicitly (in canonical order) so the same ordered
|
||||||
// heading only when the content does not already carry one). A small spacer
|
// list drives every export format — the single source of truth for ordering.
|
||||||
// before chapters after the first keeps the on-paper rhythm of the editor.
|
// When omitted, the whole book (in stored order) is used for backward
|
||||||
export function buildDocx(bookPath: string): Promise<Buffer> {
|
// 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 meta = loadBook(bookPath);
|
||||||
const chapters = listChaptersInOrder(meta);
|
const used = chapters && chapters.length ? chapters : listChaptersInOrder(meta);
|
||||||
const blocks: BlockEl[] = [
|
const blocks: BlockEl[] = [
|
||||||
new Paragraph({
|
new Paragraph({
|
||||||
heading: HeadingLevel.TITLE,
|
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;
|
let doc: FolioNode;
|
||||||
try {
|
try {
|
||||||
doc = loadChapterDoc(bookPath, ch.id).doc;
|
doc = loadChapterDoc(bookPath, ch.id).doc;
|
||||||
|
|
@ -456,10 +461,14 @@ export function buildDocx(bookPath: string): Promise<Buffer> {
|
||||||
: content;
|
: content;
|
||||||
|
|
||||||
if (chapterBlocks.length) {
|
if (chapterBlocks.length) {
|
||||||
if (ci > 0) {
|
if (rendered > 0) {
|
||||||
blocks.push(new Paragraph({ children: [], spacing: { before: 320, after: 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));
|
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
|
// Convert the generated DOCX to ODT with LibreOffice running headless. This
|
||||||
// keeps the two exports consistent and produces a spec-compliant ODT without
|
// keeps the two exports consistent and produces a spec-compliant ODT without
|
||||||
// hand-writing a second XML vocabulary.
|
// hand-writing a second XML vocabulary.
|
||||||
export async function buildOdt(bookPath: string): Promise<Buffer> {
|
export async function buildOdt(bookPath: string, chapters?: ChapterEntry[]): Promise<Buffer> {
|
||||||
const docxBuf = await buildDocx(bookPath);
|
const docxBuf = await buildDocx(bookPath, chapters);
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-odt-"));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-odt-"));
|
||||||
try {
|
try {
|
||||||
const tmpDocx = path.join(tmpDir, "book.docx");
|
const tmpDocx = path.join(tmpDir, "book.docx");
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// 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 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 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 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**"));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue