Add DOCX and ODT export for the Full Book view

The main process now assembles the manuscript from the chapter files and
renders it to a Word document with the docx library, using the same
markdown-it plugin set as the on-screen renderer (highlight, callouts,
wikilinks). ODT is converted from that DOCX with LibreOffice running
headless, keeping the two formats consistent; a clear error is returned
when LibreOffice is missing. The Full Book header's single Export PDF
button becomes an Export menu (PDF/DOCX/ODT) wired to the File menu.

Tests: new run-office-export-test.mjs verifies DOCX/ODT structure,
chapter order and formatting; the e2e scenario exports through the real
IPC chain and checks magic bytes, extensions and the status notices.
This commit is contained in:
avi 2026-08-18 17:06:32 -05:00
commit 6679294231
12 changed files with 1403 additions and 15 deletions

View file

@ -23,6 +23,7 @@ import {
setChapterContent,
} from "./chapters.js";
import { combineMarkdown, buildZip } from "./export.js";
import { buildDocx, buildOdt } from "./office-export.js";
import { getSetting, setSetting } from "./settings.js";
let mainWindow: BrowserWindow | null = null;
@ -358,6 +359,60 @@ 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 () => {
try {
const bp = requireBook();
let filePath: string;
if (process.env.FOLIO_SELF_TEST === "1" && process.env.FOLIO_TEST_DOCX) {
filePath = process.env.FOLIO_TEST_DOCX;
} else {
const { canceled, filePath: chosen } = await showSaveDialog({
title: "Export Full Book as DOCX",
defaultPath: `${currentBookTitle()}.docx`,
filters: [{ name: "Word Document", extensions: ["docx"] }],
});
if (canceled || !chosen) return { canceled: true } as const;
filePath = chosen.endsWith(".docx") ? chosen : `${chosen}.docx`;
}
const buf = await buildDocx(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 };
} 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:saveChapter", (_e, id: string, content: string) => {
try {
setChapterContent(requireBook(), id, content);

View file

@ -59,6 +59,16 @@ export function buildMenu(
enabled: !!getState().bookPath,
click: () => getWindow()?.webContents.send("folio:export-pdf-requested"),
},
{
label: "Export Full Book (DOCX)…",
enabled: !!getState().bookPath,
click: () => getWindow()?.webContents.send("folio:export-docx-requested"),
},
{
label: "Export Full Book (ODT)…",
enabled: !!getState().bookPath,
click: () => getWindow()?.webContents.send("folio:export-odt-requested"),
},
{ type: "separator" },
{
label: "Close",

865
src/main/office-export.ts Normal file
View file

@ -0,0 +1,865 @@
// Full Book export to DOCX (and ODT) entirely in the main process.
//
// DOCX is produced with the `docx` library: the assembled manuscript is parsed
// with markdown-it (same plugin set as the on-screen renderer) and the token
// stream is converted into Word paragraphs, tables and runs. ODT is converted
// from the generated DOCX with LibreOffice running headless, which keeps the
// two formats consistent without hand-writing another office XML vocabulary.
// Both exports read the chapter files straight off disk, so the manuscript is
// always complete regardless of which view is on screen.
import MarkdownIt from "markdown-it";
import type { MarkdownIt as MarkdownItInstance, StateCore, StateInline } from "markdown-it";
import { execFile } from "child_process";
import { promisify } from "util";
import fs from "fs";
import os from "os";
import path from "path";
import {
AlignmentType,
BorderStyle,
Document,
ExternalHyperlink,
HeadingLevel,
NumberFormat,
Packer,
Paragraph,
ShadingType,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
} from "docx";
import { loadBook } from "./project.js";
import { listChaptersInOrder } from "./chapters.js";
const execFileAsync = promisify(execFile);
const MONO_FONT = "Consolas";
interface WikilinkTarget {
id: string;
title: string;
}
// ==text== -> <mark>text</mark> (mirrors src/renderer/print-format.ts)
function highlightPlugin(md: MarkdownItInstance) {
const parse = (state: StateInline, silent: boolean): boolean => {
const src = state.src;
const max = state.posMax;
const start = state.pos;
if (start + 2 > max) return false;
if (src.charCodeAt(start) !== 0x3d || src.charCodeAt(start + 1) !== 0x3d) return false;
let end = -1;
for (let i = start + 2; i < max - 1; i++) {
const ch = src.charCodeAt(i);
if (ch === 0x0a) return false;
if (ch === 0x3d && src.charCodeAt(i + 1) === 0x3d) {
end = i;
break;
}
}
if (end === -1) return false;
const content = src.slice(start + 2, end);
if (!content.trim()) return false;
if (silent) {
state.pos = end + 2;
return true;
}
state.push("highlight_open", "mark", 1);
state.push("text", "", 0).content = content;
state.push("highlight_close", "mark", -1);
state.pos = end + 2;
return true;
};
md.inline.ruler.before("emphasis", "highlight", parse);
}
// [[Title]] / [[Target|Alias]] -> links (mirrors src/renderer/wikilinks.ts)
function wikilinkPlugin(md: MarkdownItInstance, chapters: WikilinkTarget[]) {
const byTitle = new Map(chapters.map((c) => [String(c.title).toLowerCase(), c.id]));
const byId = new Set(chapters.map((c) => String(c.id)));
const parse = (state: StateInline, silent: boolean): boolean => {
const src = state.src;
const max = state.posMax;
const start = state.pos;
if (start + 2 > max) return false;
if (src.charCodeAt(start) !== 0x5b || src.charCodeAt(start + 1) !== 0x5b) return false;
let end = -1;
for (let i = start + 2; i < max - 1; i++) {
const ch = src.charCodeAt(i);
if (ch === 0x5b) return false;
if (ch === 0x0a) return false;
if (ch === 0x5d && src.charCodeAt(i + 1) === 0x5d) {
end = i;
break;
}
}
if (end === -1) return false;
const raw = src.slice(start + 2, end).trim();
if (!raw) return false;
let target = raw;
let label = raw;
const pipe = raw.indexOf("|");
if (pipe >= 0) {
target = raw.slice(0, pipe).trim();
const alias = raw.slice(pipe + 1).trim();
label = alias.length ? alias : target;
}
if (!target) return false;
if (silent) {
state.pos = end + 2;
return true;
}
const key = target.toLowerCase();
const id = byTitle.has(key) ? byTitle.get(key)! : byId.has(target) ? target : null;
const open = state.push("link_open", "a", 1);
open.attrs = [
["href", id !== null ? `folio:chapter/${encodeURIComponent(id)}` : "#"],
["class", id !== null ? "wikilink internal" : "wikilink missing"],
];
const text = state.push("text", "", 0);
text.content = label;
state.push("link_close", "a", -1);
state.pos = end + 2;
return true;
};
md.inline.ruler.before("link", "wikilink", parse);
}
const CALLOUT_RE = /^\s*\[!([a-zA-Z0-9_-]+)\]\s*(.*)$/;
// > [!note] Title ... -> styled callout blocks (mirrors src/renderer/print-format.ts)
function calloutPlugin(md: MarkdownItInstance) {
md.core.ruler.after("inline", "callout", (state: StateCore) => {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
const open = tokens[i];
if (open.type !== "blockquote_open") continue;
let depth = 1;
let closeIdx = -1;
for (let j = i + 1; j < tokens.length; j++) {
const t = tokens[j];
if (t.type === "blockquote_open") depth += 1;
else if (t.type === "blockquote_close") {
depth -= 1;
if (depth === 0) {
closeIdx = j;
break;
}
}
}
if (closeIdx === -1) continue;
const paraOpen = tokens[i + 1];
if (!paraOpen || paraOpen.type !== "paragraph_open") continue;
const inline = tokens[i + 2];
if (!inline || inline.type !== "inline" || !inline.children) continue;
const first = inline.children[0];
if (!first || first.type !== "text") continue;
const m = CALLOUT_RE.exec(first.content);
if (!m) continue;
open.type = "callout_open";
open.attrs = [["class", `callout callout-${m[1].toLowerCase()}`]];
const close = tokens[closeIdx];
close.type = "callout_close";
close.tag = "div";
first.content = "";
inline.children = inline.children.filter((c) => !(c.type === "text" && c.content === ""));
if (inline.children[0]?.type === "softbreak") inline.children.shift();
const title = m[2].trim();
if (title) {
const titleOpen = new state.Token("paragraph_open", "p", 1);
titleOpen.attrs = [["class", "callout-title"]];
const titleInline = new state.Token("inline", "", 0);
titleInline.content = title;
const titleText = new state.Token("text", "", 0);
titleText.content = title;
titleInline.children = [titleText];
const titleClose = new state.Token("paragraph_close", "p", -1);
tokens.splice(i + 1, 0, titleOpen, titleInline, titleClose);
}
}
});
}
type BlockEl = Paragraph | Table;
interface InlineCtx {
bold: boolean;
italic: boolean;
strike: boolean;
code: boolean;
underline: boolean;
highlight?: "yellow";
color?: string;
bg?: string;
}
const defaultCtx: InlineCtx = {
bold: false,
italic: false,
strike: false,
code: false,
underline: false,
};
const paraSpacing = { after: 160 };
const headingSpacing = { before: 240, after: 160 };
const tableSpacing = { after: 40 };
function hexColor(v: string): string {
return v.replace(/^#/, "").trim().toLowerCase();
}
function makeRun(ctx: InlineCtx, text: string): TextRun {
const lineBreak = text === "\n";
return new TextRun({
text: lineBreak ? "" : text,
bold: ctx.bold || undefined,
italics: ctx.italic || undefined,
strike: ctx.strike || undefined,
underline: ctx.underline ? {} : undefined,
highlight: ctx.highlight,
color: ctx.color,
shading: ctx.bg ? { type: ShadingType.CLEAR, fill: ctx.bg } : undefined,
font: ctx.code ? MONO_FONT : undefined,
break: lineBreak ? 1 : undefined,
});
}
function decodeEntities(s: string): string {
return s
.replace(/&nbsp;/g, "\u00a0")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x([0-9a-f]+);/gi, (_m, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_m, dec: string) => String.fromCodePoint(parseInt(dec, 10)));
}
// Convert a raw inline HTML fragment (toolbar's <u>, <span style=...>, etc.)
// into runs. The caller keeps one mutable state across the whole inline token
// list, so formatting opened in one html_inline token (e.g. "<u>") still
// applies to the plain text tokens that follow until its closing tag.
function htmlInlineRuns(html: string, state: { ctx: InlineCtx; stack: Partial<InlineCtx>[] }): TextRun[] {
const out: TextRun[] = [];
const { ctx, stack } = state;
let textBuf = "";
let i = 0;
const flush = () => {
if (textBuf) {
out.push(makeRun(ctx, textBuf));
textBuf = "";
}
};
const snapshot = (): Partial<InlineCtx> => ({
bold: ctx.bold,
italic: ctx.italic,
strike: ctx.strike,
code: ctx.code,
underline: ctx.underline,
highlight: ctx.highlight,
color: ctx.color,
bg: ctx.bg,
});
const restore = (snap: Partial<InlineCtx>) => {
Object.assign(ctx, snap);
};
while (i < html.length) {
const lt = html.indexOf("<", i);
if (lt === -1) {
textBuf += decodeEntities(html.slice(i));
break;
}
const gt = html.indexOf(">", lt);
if (gt === -1) {
textBuf += decodeEntities(html.slice(i));
break;
}
if (lt > i) textBuf += decodeEntities(html.slice(i, lt));
const tag = html.slice(lt + 1, gt);
const nameMatch = /^\s*\/?\s*([a-zA-Z][a-zA-Z0-9-]*)/.exec(tag);
const name = nameMatch?.[1].toLowerCase() ?? "";
const isClose = /^\s*\//.test(tag);
const styleAttr = /style\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1] ?? "";
if (!name) {
textBuf += "<" + tag + ">";
} else if (name === "br") {
flush();
out.push(makeRun(ctx, "\n"));
} else if (name === "span") {
flush();
if (!isClose) {
stack.push(snapshot());
for (const decl of styleAttr.split(";")) {
const [prop, ...rest] = decl.split(":");
const k = prop.trim().toLowerCase();
const v = rest.join(":").trim();
if (k === "color" && v) ctx.color = hexColor(v);
else if (k === "background-color" && v) ctx.bg = hexColor(v);
else if (k === "text-decoration" && v.includes("underline")) ctx.underline = true;
}
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
} else if (name === "u") {
flush();
if (!isClose) {
stack.push(snapshot());
ctx.underline = true;
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
} else if (name === "del" || name === "s") {
flush();
if (!isClose) {
stack.push(snapshot());
ctx.strike = true;
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
} else if (name === "mark") {
flush();
if (!isClose) {
stack.push(snapshot());
ctx.highlight = "yellow";
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
} else if (name === "strong" || name === "b") {
flush();
if (!isClose) {
stack.push(snapshot());
ctx.bold = true;
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
} else if (name === "em" || name === "i") {
flush();
if (!isClose) {
stack.push(snapshot());
ctx.italic = true;
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
} else if (name === "code") {
flush();
if (!isClose) {
stack.push(snapshot());
ctx.code = true;
} else {
const snap = stack.pop();
if (snap) restore(snap);
}
}
// Unknown tags are dropped; their inner text is kept.
i = gt + 1;
}
flush();
return out;
}
// Convert inline token children into runs. Links become ExternalHyperlink
// unless they are wikilinks (rendered as plain label text, since the export
// has no in-app chapter URLs).
function inlineRuns(tokens: import("markdown-it").Token[], ctx: InlineCtx): (TextRun | ExternalHyperlink)[] {
const out: (TextRun | ExternalHyperlink)[] = [];
let i = 0;
const htmlState = { ctx: { ...ctx }, stack: [] as Partial<InlineCtx>[] };
const collect = (openType: string, closeType: string): { inner: import("markdown-it").Token[] } => {
i++;
let depth = 1;
const inner: import("markdown-it").Token[] = [];
while (i < tokens.length && depth) {
const t = tokens[i];
if (t.type === openType) depth++;
else if (t.type === closeType) depth--;
if (depth) inner.push(t);
i++;
}
return { inner };
};
while (i < tokens.length) {
const t = tokens[i];
if (t.type === "text") {
out.push(makeRun(htmlState.ctx, t.content));
i++;
} else if (t.type === "softbreak" || t.type === "hardbreak") {
out.push(makeRun(htmlState.ctx, "\n"));
i++;
} else if (t.type === "code") {
out.push(makeRun({ ...htmlState.ctx, code: true }, t.content));
i++;
} else if (t.type === "image") {
const alt = String(t.attrGet("alt") ?? t.attrGet("src") ?? "");
if (alt) out.push(makeRun({ ...htmlState.ctx, italic: true }, alt));
i++;
} else if (t.type === "strong_open") {
out.push(...inlineRuns(collect("strong_open", "strong_close").inner, { ...htmlState.ctx, bold: true }));
} else if (t.type === "em_open") {
out.push(...inlineRuns(collect("em_open", "em_close").inner, { ...htmlState.ctx, italic: true }));
} else if (t.type === "s_open") {
out.push(...inlineRuns(collect("s_open", "s_close").inner, { ...htmlState.ctx, strike: true }));
} else if (t.type === "highlight_open") {
out.push(
...inlineRuns(collect("highlight_open", "highlight_close").inner, {
...htmlState.ctx,
highlight: htmlState.ctx.highlight ?? "yellow",
})
);
} else if (t.type === "link_open") {
const href = String(t.attrGet("href") ?? "");
const isWikilink = String(t.attrGet("class") ?? "").includes("wikilink");
const children = inlineRuns(collect("link_open", "link_close").inner, htmlState.ctx);
if (isWikilink || href === "#" || href.startsWith("folio:chapter/")) {
out.push(...children);
} else if (href && children.length) {
out.push(new ExternalHyperlink({ link: href, children }));
} else {
out.push(...children);
}
} else if (t.type === "html_inline") {
out.push(...htmlInlineRuns(t.content, htmlState));
i++;
} else {
i++;
}
}
return out;
}
function headingLevel(level: number) {
switch (level) {
case 1:
return HeadingLevel.HEADING_1;
case 2:
return HeadingLevel.HEADING_2;
case 3:
return HeadingLevel.HEADING_3;
case 4:
return HeadingLevel.HEADING_4;
case 5:
return HeadingLevel.HEADING_5;
case 6:
return HeadingLevel.HEADING_6;
default:
return undefined;
}
}
function htmlBlockToParagraph(html: string): Paragraph | null {
const alignMatch = /align\s*=\s*["'](left|center|right|justify)["']/i.exec(html);
const align = alignMatch?.[1].toLowerCase();
const text = decodeEntities(html.replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim();
if (!text) return null;
return new Paragraph({
alignment: align === "center" ? AlignmentType.CENTER : align === "right" ? AlignmentType.RIGHT : undefined,
children: [new TextRun({ text })],
spacing: paraSpacing,
});
}
function renderListItem(itemTokens: import("markdown-it").Token[], listCtx: { reference: string; level: number }): BlockEl[] {
const out: BlockEl[] = [];
let i = 0;
let first = true;
while (i < itemTokens.length) {
const t = itemTokens[i];
if (t.type === "paragraph_open") {
const inlineTok = itemTokens[i + 1];
const runs = inlineTok && inlineTok.type === "inline" ? inlineRuns(inlineTok.children ?? [], defaultCtx) : [];
if (first) {
out.push(
new Paragraph({
children: runs,
spacing: paraSpacing,
numbering: { reference: listCtx.reference, level: listCtx.level },
})
);
} else {
out.push(
new Paragraph({
children: runs,
spacing: paraSpacing,
indent: { left: 720 * (listCtx.level + 1), hanging: 360 },
})
);
}
first = false;
i += 3;
} else if (t.type === "bullet_list_open" || t.type === "ordered_list_open") {
const nested = {
reference: t.type === "bullet_list_open" ? "bullets" : "numbers",
level: listCtx.level + 1,
};
const { blocks, next } = renderList(itemTokens, i, nested);
out.push(...blocks);
i = next;
} else {
i++;
}
}
return out;
}
function renderList(tokens: import("markdown-it").Token[], start: number, listCtx: { reference: string; level: number }): { blocks: BlockEl[]; next: number } {
const blocks: BlockEl[] = [];
let i = start + 1;
let depth = 1;
while (i < tokens.length && depth) {
const t = tokens[i];
if (t.type === "bullet_list_open" || t.type === "ordered_list_open") depth++;
else if (t.type === "bullet_list_close" || t.type === "ordered_list_close") depth--;
if (depth && t.type === "list_item_open") {
let j = i + 1;
let itemDepth = 1;
const itemTokens: import("markdown-it").Token[] = [];
while (j < tokens.length && itemDepth) {
const it = tokens[j];
if (it.type === "list_item_open") itemDepth++;
else if (it.type === "list_item_close") itemDepth--;
if (itemDepth) itemTokens.push(it);
j++;
}
blocks.push(...renderListItem(itemTokens, listCtx));
i = j;
} else {
i++;
}
}
return { blocks, next: i };
}
// Collect the tokens inside a balanced open/close pair and return the index
// just past the closing token.
function collectMatching(
tokens: import("markdown-it").Token[],
start: number,
openTypes: string[],
closeTypes: string[]
): { inner: import("markdown-it").Token[]; next: number } {
let i = start + 1;
let depth = 1;
const inner: import("markdown-it").Token[] = [];
while (i < tokens.length && depth) {
const t = tokens[i];
if (openTypes.includes(t.type)) depth++;
else if (closeTypes.includes(t.type)) depth--;
if (depth) inner.push(t);
i++;
}
return { inner, next: i };
}
const QUOTE_BORDER = { left: { style: BorderStyle.SINGLE, size: 16, color: "888888", space: 8 } };
const CALLOUT_SHADING = { type: ShadingType.CLEAR, fill: "F5F2ED" };
// Render blockquote / callout content. Every paragraph gets the quote indent
// and left border; callouts are additionally shaded.
function renderQuoted(tokens: import("markdown-it").Token[], isCallout: boolean): BlockEl[] {
const out: BlockEl[] = [];
let i = 0;
while (i < tokens.length) {
const t = tokens[i];
if (t.type === "paragraph_open") {
const isTitle = String(t.attrGet("class") ?? "").includes("callout-title");
const inlineTok = tokens[i + 1];
const runs =
inlineTok && inlineTok.type === "inline"
? inlineRuns(inlineTok.children ?? [], isTitle ? { ...defaultCtx, bold: true } : defaultCtx)
: [];
out.push(
new Paragraph({
children: runs,
indent: { left: 720 },
border: QUOTE_BORDER,
shading: isCallout ? CALLOUT_SHADING : undefined,
spacing: paraSpacing,
})
);
i += 3;
} else if (t.type === "bullet_list_open") {
const { blocks, next } = renderList(tokens, i, { reference: "bullets", level: 0 });
out.push(...blocks);
i = next;
} else if (t.type === "ordered_list_open") {
const { blocks, next } = renderList(tokens, i, { reference: "numbers", level: 0 });
out.push(...blocks);
i = next;
} else if (t.type === "blockquote_open" || t.type === "callout_open") {
const nested = t.type === "callout_open";
const { inner, next } = collectMatching(tokens, i, ["blockquote_open", "callout_open"], ["blockquote_close", "callout_close"]);
out.push(...renderQuoted(inner, isCallout || nested));
i = next;
} else {
i++;
}
}
return out;
}
// Render a run of block-level tokens (a whole chapter or a blockquote body).
function renderBlocks(tokens: import("markdown-it").Token[]): BlockEl[] {
const out: BlockEl[] = [];
let i = 0;
while (i < tokens.length) {
const t = tokens[i];
if (t.type === "heading_open") {
const level = Number(t.tag.slice(1));
const inlineTok = tokens[i + 1];
const runs = inlineTok && inlineTok.type === "inline" ? inlineRuns(inlineTok.children ?? [], defaultCtx) : [];
out.push(new Paragraph({ heading: headingLevel(level), children: runs, spacing: headingSpacing }));
i += 3;
} else if (t.type === "paragraph_open") {
const isTitle = String(t.attrGet("class") ?? "").includes("callout-title");
const inlineTok = tokens[i + 1];
const runs =
inlineTok && inlineTok.type === "inline"
? inlineRuns(inlineTok.children ?? [], isTitle ? { ...defaultCtx, bold: true } : defaultCtx)
: [];
out.push(new Paragraph({ children: runs, spacing: paraSpacing }));
i += 3;
} else if (t.type === "bullet_list_open") {
const { blocks, next } = renderList(tokens, i, { reference: "bullets", level: 0 });
out.push(...blocks);
i = next;
} else if (t.type === "ordered_list_open") {
const { blocks, next } = renderList(tokens, i, { reference: "numbers", level: 0 });
out.push(...blocks);
i = next;
} else if (t.type === "blockquote_open" || t.type === "callout_open") {
const isCallout = t.type === "callout_open";
const { inner, next } = collectMatching(tokens, i, ["blockquote_open", "callout_open"], ["blockquote_close", "callout_close"]);
out.push(...renderQuoted(inner, isCallout));
i = next;
} else if (t.type === "fence" || t.type === "code_block") {
const lines = t.content.replace(/\n$/, "").split("\n");
const runs = lines.map(
(line, idx) =>
new TextRun({
text: line,
font: MONO_FONT,
break: idx < lines.length - 1 ? 1 : undefined,
})
);
out.push(
new Paragraph({
children: runs,
shading: { type: ShadingType.CLEAR, fill: "F2F2F2" },
border: { left: { style: BorderStyle.SINGLE, size: 12, color: "CCCCCC", space: 8 } },
spacing: { before: 120, after: 160 },
})
);
i++;
} else if (t.type === "hr") {
out.push(
new Paragraph({
children: [],
spacing: { before: 120, after: 120 },
border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: "999999", space: 1 } },
})
);
i++;
} else if (t.type === "table_open") {
const { inner, next } = collectMatching(tokens, i, ["table_open"], ["table_close"]);
const rows: TableRow[] = [];
let cells: TableCell[] = [];
for (let k = 0; k < inner.length; k++) {
const tt = inner[k];
if (tt.type === "tr_open") cells = [];
else if (tt.type === "th_open" || tt.type === "td_open") {
const inlineTok = inner[k + 1];
const runs = inlineTok && inlineTok.type === "inline" ? inlineRuns(inlineTok.children ?? [], defaultCtx) : [];
cells.push(
new TableCell({
children: [new Paragraph({ children: runs, spacing: tableSpacing })],
shading: tt.type === "th_open" ? { type: ShadingType.CLEAR, fill: "EFEFEF" } : undefined,
})
);
} else if (tt.type === "tr_close") rows.push(new TableRow({ children: cells }));
}
out.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows }));
i = next;
} else if (t.type === "html_block") {
const p = htmlBlockToParagraph(t.content);
if (p) out.push(p);
i++;
} else {
i++;
}
}
return out;
}
// Assemble the manuscript exactly the way the Full Book view does: the book
// title on top, then each chapter in order, prepending "# Title" only for
// chapters that do not carry their own heading. The title page note mirrors
// combineMarkdown so all exports stay consistent.
function assembleBookMarkdown(bookPath: string): string {
const meta = loadBook(bookPath);
const parts: string[] = [`# ${meta.title}`, "", "_A book compiled with Folio._"];
for (const ch of listChaptersInOrder(meta)) {
let content = "";
try {
content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8");
} catch {
content = "";
}
const body = content.replace(/^\n+/, "").replace(/\s+$/, "");
const t = ch.title.trim().toLowerCase();
let hasOwnHeading = !t;
if (t) {
for (const line of content.split(/\r?\n/)) {
const m = line.match(/^#{1,6}\s+(.+?)\s*$/);
if (m && m[1].trim().toLowerCase() === t) {
hasOwnHeading = true;
break;
}
}
}
const block: string[] = [];
if (!hasOwnHeading) {
block.push(`# ${ch.title}`);
if (body) {
block.push("");
block.push(body);
}
} else if (body) {
block.push(body);
}
if (block.length) parts.push("", block.join("\n"));
}
return parts.join("\n");
}
export function buildDocx(bookPath: string): Promise<Buffer> {
const meta = loadBook(bookPath);
const chapters = listChaptersInOrder(meta);
const targets: WikilinkTarget[] = chapters.map((c) => ({ id: c.id, title: c.title }));
const md = new MarkdownIt({ html: true });
md.use(wikilinkPlugin, targets);
md.use(highlightPlugin);
md.use(calloutPlugin);
const blocks: BlockEl[] = [
new Paragraph({
heading: HeadingLevel.TITLE,
alignment: AlignmentType.CENTER,
children: [new TextRun({ text: meta.title })],
spacing: { after: 120 },
}),
new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ italics: true, color: "666666", text: "A book compiled with Folio._" })],
spacing: { after: 360 },
}),
];
for (const ch of chapters) {
let content = "";
try {
content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8");
} catch {
content = "";
}
const body = content.replace(/^\n+/, "").replace(/\s+$/, "");
const t = ch.title.trim().toLowerCase();
let hasOwnHeading = !t;
if (t) {
for (const line of content.split(/\r?\n/)) {
const m = line.match(/^#{1,6}\s+(.+?)\s*$/);
if (m && m[1].trim().toLowerCase() === t) {
hasOwnHeading = true;
break;
}
}
}
const markdown = hasOwnHeading ? body : body ? `# ${ch.title}\n\n${body}` : `# ${ch.title}`;
if (markdown) blocks.push(...renderBlocks(md.parse(markdown, {})));
}
const doc = new Document({
styles: {
default: {
document: {
run: { font: "Georgia", size: 22 },
},
},
},
numbering: {
config: [
{
reference: "bullets",
levels: [0, 1, 2, 3, 4, 5].map((level) => ({
level,
format: NumberFormat.BULLET,
text: "\u2022",
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 720 * (level + 1), hanging: 360 } } },
})),
},
{
reference: "numbers",
levels: [0, 1, 2, 3, 4, 5].map((level) => ({
level,
format: NumberFormat.DECIMAL,
text: `%${level + 1}.`,
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 720 * (level + 1), hanging: 360 } } },
})),
},
],
},
sections: [{ children: blocks }],
});
return Packer.toBuffer(doc);
}
// 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);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-odt-"));
try {
const tmpDocx = path.join(tmpDir, "book.docx");
const outDir = path.join(tmpDir, "out");
fs.writeFileSync(tmpDocx, docxBuf);
fs.mkdirSync(outDir, { recursive: true });
try {
await execFileAsync("soffice", ["--headless", "--convert-to", "odt", "--outdir", outDir, tmpDocx], {
timeout: 60_000,
});
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
throw new Error(
"LibreOffice is not installed, so ODT export is unavailable. Install LibreOffice (or use the PDF or DOCX export instead)."
);
}
throw new Error(`LibreOffice could not convert the document: ${(e as Error).message}`);
}
const odtPath = path.join(outDir, "book.odt");
if (!fs.existsSync(odtPath)) throw new Error("LibreOffice did not produce an ODT file.");
return fs.readFileSync(odtPath);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}

View file

@ -28,6 +28,8 @@ contextBridge.exposeInMainWorld("folio", {
exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"),
exportZip: () => ipcRenderer.invoke("folio:exportZip"),
exportPdf: () => ipcRenderer.invoke("folio:exportPdf"),
exportDocx: () => ipcRenderer.invoke("folio:exportDocx"),
exportOdt: () => ipcRenderer.invoke("folio:exportOdt"),
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
@ -57,6 +59,10 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.on("folio:export-zip-requested", () => cb()),
onExportPdfRequested: (cb: () => void) =>
ipcRenderer.on("folio:export-pdf-requested", () => cb()),
onExportDocxRequested: (cb: () => void) =>
ipcRenderer.on("folio:export-docx-requested", () => cb()),
onExportOdtRequested: (cb: () => void) =>
ipcRenderer.on("folio:export-odt-requested", () => cb()),
onUndoRequested: (cb: () => void) =>
ipcRenderer.on("folio:undo-requested", () => cb()),
onRedoRequested: (cb: () => void) =>

View file

@ -57,6 +57,8 @@ export type FolioAPI = {
exportMarkdown: () => Promise<any>;
exportZip: () => Promise<any>;
exportPdf: () => Promise<any>;
exportDocx: () => Promise<any>;
exportOdt: () => Promise<any>;
importAttachment: () => Promise<any>;
copyText: (text: string) => Promise<any>;
getClipboardText: () => Promise<string>;
@ -72,6 +74,8 @@ export type FolioAPI = {
onExportMarkdownRequested: (cb: () => void) => void;
onExportZipRequested: (cb: () => void) => void;
onExportPdfRequested: (cb: () => void) => void;
onExportDocxRequested: (cb: () => void) => void;
onExportOdtRequested: (cb: () => void) => void;
onUndoRequested: (cb: () => void) => void;
onRedoRequested: (cb: () => void) => void;
onSelectAllRequested: (cb: () => void) => void;
@ -124,7 +128,9 @@ export function initBook(api: FolioAPI) {
const printBook = $("printBook");
const copyAllBtn = $("copyAllBtn") as HTMLButtonElement;
const fullBookStatus = $("fullBookStatus");
const exportPdfBtn = $("exportPdfBtn") as HTMLButtonElement;
const exportMenuToggle = $("exportMenuToggle") as HTMLButtonElement;
const exportMenu = $("exportMenu");
const exportDropdown = $("exportDropdown");
const fbPrevBtn = $("fbPrevBtn") as HTMLButtonElement;
const fbNextBtn = $("fbNextBtn") as HTMLButtonElement;
const fbChapterSelect = $("fbChapterSelect") as HTMLSelectElement;
@ -794,6 +800,28 @@ export function initBook(api: FolioAPI) {
else if (res && "filePath" in res) fullBookNotice(`PDF saved to ${basename(res.filePath)}`);
}
async function exportDocx() {
if (!currentBook) return;
const res = await api.exportDocx();
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error);
else if (res && "canceled" in res) fullBookNotice("Export canceled");
else if (res && "filePath" in res) fullBookNotice(`DOCX saved to ${basename(res.filePath)}`);
}
async function exportOdt() {
if (!currentBook) return;
const res = await api.exportOdt();
if (res && "error" in res) await alertMessage("Export failed", (res as { error: string }).error);
else if (res && "canceled" in res) fullBookNotice("Export canceled");
else if (res && "filePath" in res) fullBookNotice(`ODT saved to ${basename(res.filePath)}`);
}
async function exportFromMenu(kind: string) {
if (kind === "pdf") await exportPdf();
else if (kind === "docx") await exportDocx();
else if (kind === "odt") await exportOdt();
}
function basename(p: string): string {
return p.split(/[/\\]/).pop() || p;
}
@ -1005,7 +1033,30 @@ export function initBook(api: FolioAPI) {
const idx = fullBookChapters.findIndex((c) => c.id === fbChapterSelect.value);
if (idx >= 0) jumpToChapter(idx);
});
exportPdfBtn.addEventListener("click", () => void exportPdf());
function closeExportMenu() {
exportMenu.classList.add("hidden");
exportMenuToggle.classList.remove("open");
}
exportMenuToggle.addEventListener("click", (e) => {
e.stopPropagation();
const wasOpen = !exportMenu.classList.contains("hidden");
closeExportMenu();
if (!wasOpen) exportMenu.classList.remove("hidden");
exportMenuToggle.classList.toggle("open", wasOpen);
});
exportMenu.addEventListener("click", (e) => {
const item = (e.target as HTMLElement).closest("[data-export]") as HTMLElement | null;
if (!item) return;
closeExportMenu();
void exportFromMenu(item.dataset.export ?? "");
});
document.addEventListener("click", (e) => {
if (exportDropdown && !(e.target as HTMLElement).closest("#exportDropdown")) closeExportMenu();
});
copyAllBtn.addEventListener("click", async () => {
const text = editorText(fullBookEditor);
if (!text) {
@ -1038,6 +1089,8 @@ export function initBook(api: FolioAPI) {
api.onExportMarkdownRequested(() => void exportMarkdown());
api.onExportZipRequested(() => void exportZip());
api.onExportPdfRequested(() => void exportPdf());
api.onExportDocxRequested(() => void exportDocx());
api.onExportOdtRequested(() => void exportOdt());
api.onUndoRequested(() => {
if (editorView.hasFocus) undo(editorView);
else document.execCommand("undo");

View file

@ -888,6 +888,14 @@ html, body {
flex-basis: 100%;
}
/* Export dropdown in the Full Book header sits flush against the window's
right edge, so its menu opens toward the left instead of overflowing. */
.export-dropdown .dropdown-menu {
left: auto;
right: 0;
min-width: 120px;
}
.color-menu .dropdown-item {
flex-basis: 100%;
}

View file

@ -181,7 +181,14 @@
<h2 class="chapter-title">Full Book</h2>
<div class="editor-actions">
<span class="word-count" id="fullBookStats">words: 0 · chars: 0</span>
<button class="button" id="exportPdfBtn" title="Export the entire Full Book as a PDF">Export PDF</button>
<div class="toolbar-dropdown export-dropdown" id="exportDropdown">
<button class="button dropdown-toggle" id="exportMenuToggle" title="Export the entire Full Book">Export &#9662;</button>
<div class="dropdown-menu hidden" id="exportMenu">
<button class="dropdown-item" data-export="pdf">PDF</button>
<button class="dropdown-item" data-export="docx">DOCX</button>
<button class="dropdown-item" data-export="odt">ODT</button>
</div>
</div>
<button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button>
<span class="save-state" id="fullBookStatus"></span>
</div>