feat(export): Chunk #6 Export Pipeline — 7 exporters, ExportDialog & tests
Add the @pubscript/exporters package exporting a flattened ProseMirror project to Markdown, HTML, EPUB, DOCX, ODT and PDF, plus a deterministic generatedAt timestamp threaded through every format (ZIP entry dates, PDF /ID, DOCX dc:created/modified, EPUB id, HTML/XML footers) so golden files are byte-stable. Wire an ExportDialog into the web app (Topbar trigger, App route) with format selection, theme pick, and download helpers. Add round-trip and golden-file determinism tests (22) covering non-empty output, byte-identical re-exports, Markdown round-trip through the editor, and EPUB/ODT ZIP + mimetype + XML validation.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@pubscript/exporters",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Format-agnostic export engine for PubScript projects (Markdown, HTML, EPUB, DOCX, ODT, PDF).",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pubscript/core": "workspace:*",
|
||||
"docx": "^8.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"jspdf": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pubscript/editor": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Determinism helpers.
|
||||
*
|
||||
* Some exporters embed time-varying bytes that are irrelevant to the document
|
||||
* content — ZIP entry timestamps (EPUB/DOCX/ODT) and the jsPDF `/ID` hash
|
||||
* (PDF). These normalize those bytes so repeated exports of the same input are
|
||||
* byte-identical, which is what golden-file stability requires. The normalized
|
||||
* bytes are semantically unchanged: a ZIP entry's modification time and a PDF
|
||||
* document identifier carry no content meaning here.
|
||||
*/
|
||||
|
||||
import * as JSZipNs from "jszip";
|
||||
|
||||
// jszip is a CommonJS module whose `module.exports` is the constructor. Vite's
|
||||
// ESM interop exposes it either as the default export or on the namespace, so
|
||||
// accept whichever form is present.
|
||||
const JSZip = (JSZipNs as unknown as { default?: typeof JSZipNs }).default ?? JSZipNs;
|
||||
|
||||
/**
|
||||
* Zero the last-modification time/date fields in every ZIP header (both the
|
||||
* local file headers `PK\x03\x04` and the central directory headers
|
||||
* `PK\x01\x02`). The fields sit at offset 14/15 (time) and 16/17 (date) from
|
||||
* each signature; zeroing them yields the canonical ZIP date 1980-01-01.
|
||||
*/
|
||||
export function normalizeZipDates(buffer: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(buffer);
|
||||
const signatures: number[][] = [
|
||||
[0x50, 0x4b, 0x03, 0x04], // local file header
|
||||
[0x50, 0x4b, 0x01, 0x02], // central directory header
|
||||
];
|
||||
for (let i = 0; i + 17 <= out.length; i++) {
|
||||
for (const sig of signatures) {
|
||||
if (
|
||||
out[i] === sig[0] &&
|
||||
out[i + 1] === sig[1] &&
|
||||
out[i + 2] === sig[2] &&
|
||||
out[i + 3] === sig[3]
|
||||
) {
|
||||
out[i + 14] = 0;
|
||||
out[i + 15] = 0;
|
||||
out[i + 16] = 0;
|
||||
out[i + 17] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Minimal FNV-1a 32-bit hash, hex-encoded (lowercase). */
|
||||
function fnv1a(input: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the PDF `/ID [ <…> <…> ]` trailer entry with a deterministic value
|
||||
* derived from the rest of the document, so the identifier no longer depends on
|
||||
* jsPDF's internal `Date.now()`/`Math.random()` seed.
|
||||
*/
|
||||
export function normalizePdfId(buffer: Uint8Array): Uint8Array {
|
||||
const text = new TextDecoder().decode(buffer);
|
||||
const idRegex = /\/ID\s*\[\s*<[0-9a-fA-F]+>\s*<[0-9a-fA-F]+>\s*\]/;
|
||||
if (!idRegex.test(text)) return buffer;
|
||||
const withoutId = text.replace(idRegex, "");
|
||||
const seed = fnv1a(withoutId);
|
||||
const first = fnv1a(withoutId).padEnd(16, "0");
|
||||
const second = fnv1a(withoutId + seed).padEnd(16, "0");
|
||||
const normalized = text.replace(idRegex, `/ID [ <${first}> <${second}> ]`);
|
||||
return new TextEncoder().encode(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralize the time-varying bytes inside a DOCX archive produced by the
|
||||
* `docx` library. That library stamps `docProps/core.xml` with `dcterms:created`
|
||||
* and `dcterms:modified` timestamps taken from `new Date()`, which change on
|
||||
* every export. We decompress the archive, rewrite every W3CDTF timestamp to the
|
||||
* caller-supplied `generatedAt`, and re-compress with normalized ZIP dates. The
|
||||
* document content is unchanged — only the metadata timestamps are pinned.
|
||||
*/
|
||||
export async function normalizeDocx(
|
||||
buffer: Uint8Array,
|
||||
generatedAt: string
|
||||
): Promise<Uint8Array> {
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
const core = zip.file("docProps/core.xml");
|
||||
if (core) {
|
||||
const xml = await core.async("string");
|
||||
const fixed = xml.replace(
|
||||
/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))/g,
|
||||
generatedAt
|
||||
);
|
||||
zip.file("docProps/core.xml", fixed);
|
||||
}
|
||||
return normalizeZipDates(await zip.generateAsync({ type: "uint8array" }));
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* DOCX exporter (via the `docx` library, v8.x).
|
||||
*
|
||||
* Builds a WordprocessingML document from the flattened project. Headings,
|
||||
* paragraphs, scene breaks, blockquotes, code blocks, lists, and tables are
|
||||
* all mapped to native Word elements so the output is editable and re-exportable.
|
||||
* The theme supplies fonts, colours, and heading sizes.
|
||||
*/
|
||||
import {
|
||||
AlignmentType,
|
||||
Document,
|
||||
Paragraph,
|
||||
Packer,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from "docx";
|
||||
import type { Block, FlatProject, Mark } from "./model.js";
|
||||
import { getTheme, type Theme } from "./themes.js";
|
||||
import { normalizeDocx } from "./determinism.js";
|
||||
|
||||
function runToTextRun(run: { text: string; marks: Mark[] }, theme: Theme): TextRun {
|
||||
const hasCode = run.marks.some((m) => m.type === "code");
|
||||
const hasBold = run.marks.some((m) => m.type === "bold");
|
||||
const hasItalic = run.marks.some((m) => m.type === "italic");
|
||||
const hasStrike = run.marks.some((m) => m.type === "strike");
|
||||
const color = (run.marks.find((m) => m.type === "color")?.attrs?.color as string | undefined) ?? theme.colors.text;
|
||||
return new TextRun({
|
||||
text: run.text,
|
||||
bold: hasBold,
|
||||
italics: hasItalic,
|
||||
strike: hasStrike,
|
||||
font: hasCode ? "Courier New" : theme.fontFamily,
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
||||
function runsForInline(text: string, marks: Mark[], theme: Theme): TextRun[] {
|
||||
if (!marks.length) return [new TextRun({ text, color: theme.colors.text })];
|
||||
const boundaries = new Set<number>([0, text.length]);
|
||||
for (const m of marks) {
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.start)));
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.end)));
|
||||
}
|
||||
const points = Array.from(boundaries).sort((a, b) => a - b);
|
||||
const runs: TextRun[] = [];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const start = points[i]!;
|
||||
const end = points[i + 1]!;
|
||||
const runMarks = marks.filter((m) => m.start <= start && m.end >= end);
|
||||
runs.push(runToTextRun({ text: text.slice(start, end), marks: runMarks }, theme));
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function headingLevel(level: number): string {
|
||||
if (level <= 1) return "Heading1";
|
||||
if (level <= 2) return "Heading2";
|
||||
if (level <= 3) return "Heading3";
|
||||
if (level <= 4) return "Heading4";
|
||||
if (level <= 5) return "Heading5";
|
||||
return "Heading6";
|
||||
}
|
||||
|
||||
function blockToParagraph(block: Block, theme: Theme): Paragraph {
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const level = block.level ?? 1;
|
||||
return new Paragraph({
|
||||
heading: headingLevel(level) as never,
|
||||
spacing: { after: 200 },
|
||||
children: runsForInline(block.text ?? "", block.marks ?? [], theme),
|
||||
}) as unknown as Paragraph;
|
||||
}
|
||||
case "paragraph":
|
||||
return new Paragraph({
|
||||
spacing: { after: 200 },
|
||||
children: runsForInline(block.text ?? "", block.marks ?? [], theme),
|
||||
});
|
||||
case "sceneBreak":
|
||||
return new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 200 },
|
||||
children: [new TextRun({ text: "***", italics: true, color: theme.colors.muted })],
|
||||
});
|
||||
case "blockquote":
|
||||
return new Paragraph({
|
||||
indent: { left: 360 },
|
||||
spacing: { after: 200 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: (block.children ?? []).map((c) => c.text ?? "").join("\n"),
|
||||
italics: true,
|
||||
color: theme.colors.muted,
|
||||
}),
|
||||
],
|
||||
});
|
||||
case "codeBlock":
|
||||
return new Paragraph({
|
||||
spacing: { after: 200 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: block.text ?? "",
|
||||
font: "Courier New",
|
||||
size: 16,
|
||||
color: theme.colors.text,
|
||||
}),
|
||||
],
|
||||
});
|
||||
case "footnote":
|
||||
return new Paragraph({
|
||||
indent: { firstLine: 360 },
|
||||
spacing: { after: 200 },
|
||||
children: [
|
||||
new TextRun({ text: block.text ?? "", italics: true, color: theme.colors.muted, size: 18 }),
|
||||
],
|
||||
});
|
||||
case "list": {
|
||||
const items = block.children ?? [];
|
||||
const pad = block.listType === "ordered" ? "1. " : "- ";
|
||||
return new Paragraph({
|
||||
spacing: { after: 120 },
|
||||
children: [
|
||||
new TextRun({ text: pad, bold: true, color: theme.colors.heading }),
|
||||
...runsForInline(items[0]?.text ?? "", items[0]?.marks ?? [], theme),
|
||||
],
|
||||
});
|
||||
}
|
||||
default:
|
||||
return new Paragraph({ spacing: { after: 200 }, children: [] });
|
||||
}
|
||||
}
|
||||
|
||||
function buildTable(blocks: Block[], theme: Theme): Table | null {
|
||||
const rows = blocks.filter((b) => b.type === "table");
|
||||
if (!rows.length) return null;
|
||||
const header = rows[0];
|
||||
const headers = header?.children ?? [];
|
||||
const body = rows.slice(1);
|
||||
const headerRow = new TableRow({
|
||||
children: headers.map((h) =>
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: h.text ?? "", bold: true, color: theme.colors.background }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
const bodyRows = body.map((row) =>
|
||||
new TableRow({
|
||||
children: (row.children ?? []).map((cell) =>
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: cell.text ?? "" })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
return new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [headerRow, ...bodyRows],
|
||||
});
|
||||
}
|
||||
|
||||
export interface DocxOptions {
|
||||
themeId?: string;
|
||||
generatedAt?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a flattened project to a DOCX (`.docx` bytes).
|
||||
*
|
||||
* @param project the flattened project (frontmatter + blocks)
|
||||
* @param options theme id (defaults to the built-in "ink" theme)
|
||||
*/
|
||||
export async function exportDocx(project: FlatProject, options?: DocxOptions): Promise<Uint8Array> {
|
||||
const theme = getTheme(options?.themeId);
|
||||
const fm = project.frontmatter;
|
||||
|
||||
const children: (Paragraph | Table)[] = [];
|
||||
if (fm.title) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
heading: "Title" as never,
|
||||
spacing: { after: 120 },
|
||||
children: [new TextRun({ text: fm.title, bold: true, size: 32, color: theme.colors.heading })],
|
||||
}) as unknown as Paragraph
|
||||
);
|
||||
}
|
||||
if (fm.author) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
spacing: { after: 300 },
|
||||
children: [new TextRun({ text: fm.author, italics: true, color: theme.colors.muted })],
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const block of project.blocks) {
|
||||
if (block.type === "table") continue; // handled separately
|
||||
children.push(blockToParagraph(block, theme));
|
||||
}
|
||||
const table = buildTable(project.blocks, theme);
|
||||
if (table) children.push(table);
|
||||
|
||||
const doc = new Document({
|
||||
sections: [{ children }],
|
||||
});
|
||||
|
||||
const buffer = await Packer.toBuffer(doc);
|
||||
const generatedAt = options?.generatedAt ?? new Date().toISOString();
|
||||
return normalizeDocx(
|
||||
new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength),
|
||||
generatedAt
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* EPUB 3 exporter.
|
||||
*
|
||||
* Emits a valid EPUB 3 `.epub` (a ZIP archive) with one XHTML document per
|
||||
* chapter, a cover page, an EPUB3 `nav.xhtml`, a NCX `toc.ncx`, and the theme
|
||||
* stylesheet. Chapter boundaries become `<h2>` headings within a single
|
||||
* document when a scope spans multiple chapters, or one document per chapter
|
||||
* when exporting whole chapters.
|
||||
*
|
||||
* The archive layout:
|
||||
* mimetype (stored, first entry)
|
||||
* META-INF/container.xml
|
||||
* OEBPS/content.opf
|
||||
* OEBPS/toc.ncx
|
||||
* OEBPS/nav.xhtml
|
||||
* OEBPS/cover.xhtml
|
||||
* OEBPS/stylesheet.css
|
||||
* OEBPS/chapterN.xhtml
|
||||
*/
|
||||
import JSZip from "jszip";
|
||||
|
||||
import { normalizeZipDates } from "./determinism.js";
|
||||
import type { Block, FlatProject, Mark } from "./model.js";
|
||||
import { escapeHtml, escapeXml } from "./model.js";
|
||||
import type { Theme } from "./themes.js";
|
||||
|
||||
/** Generate a RFC-4122 v4 UUID string. */
|
||||
function documentId(project: FlatProject): string {
|
||||
// Deterministic v4-shaped UUID derived from the document content, so that
|
||||
// repeated exports of the same input share one stable identifier (and the
|
||||
// golden-file determinism test is byte-stable).
|
||||
const seed = JSON.stringify([
|
||||
project.frontmatter?.title ?? "",
|
||||
project.frontmatter?.author ?? "",
|
||||
project.frontmatter?.language ?? "",
|
||||
project.blocks?.map((b) => b.type + ":" + (b.text ?? "")).join("\n") ?? "",
|
||||
]);
|
||||
const bytes = new Uint8Array(16);
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
||||
bytes[i % 16] = (h & 0xff) ^ (bytes[i % 16] ?? 0);
|
||||
}
|
||||
bytes[6] = ((bytes[6]! & 0x0f) | 0x40) & 0xff;
|
||||
bytes[8] = ((bytes[8]! & 0x3f) | 0x80) & 0xff;
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
|
||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
|
||||
}
|
||||
|
||||
/** Serialize inline text + marks to XHTML. */
|
||||
function serializeInlineXhtml(text: string, marks: Mark[]): string {
|
||||
if (!marks.length) return escapeHtml(text);
|
||||
const boundaries = new Set<number>([0, text.length]);
|
||||
for (const m of marks) {
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.start)));
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.end)));
|
||||
}
|
||||
const points = Array.from(boundaries).sort((a, b) => a - b);
|
||||
let out = "";
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const start = points[i]!;
|
||||
const end = points[i + 1]!;
|
||||
const runMarks = marks.filter((m) => m.start <= start && m.end >= end);
|
||||
out += runToXhtml(text.slice(start, end), runMarks);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function runToXhtml(segment: string, marks: Mark[]): string {
|
||||
let out = escapeHtml(segment);
|
||||
if (marks.some((m) => m.type === "italic")) out = `<em>${out}</em>`;
|
||||
if (marks.some((m) => m.type === "bold")) out = `<strong>${out}</strong>`;
|
||||
if (marks.some((m) => m.type === "strike")) out = `<del>${out}</del>`;
|
||||
if (marks.some((m) => m.type === "code")) out = `<code>${out}</code>`;
|
||||
const color = marks.find((m) => m.type === "color");
|
||||
if (color?.attrs?.color) {
|
||||
out = `<span style="color:${escapeHtml(String(color.attrs.color))}">${out}</span>`;
|
||||
}
|
||||
const link = marks.find((m) => m.type === "link");
|
||||
if (link?.attrs?.href) {
|
||||
out = `<a href="${escapeHtml(String(link.attrs.href))}">${out}</a>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function blockToXhtml(block: Block): string {
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const level = Math.max(1, Math.min(6, block.level ?? 1));
|
||||
return `<h${level}>${serializeInlineXhtml(block.text ?? "", block.marks ?? [])}</h${level}>`;
|
||||
}
|
||||
case "paragraph":
|
||||
return `<p>${serializeInlineXhtml(block.text ?? "", block.marks ?? [])}</p>`;
|
||||
case "sceneBreak":
|
||||
return `<p class="scene-break">***</p>`;
|
||||
case "blockquote":
|
||||
return `<blockquote>${(block.children ?? []).map(blockToXhtml).join("")}</blockquote>`;
|
||||
case "codeBlock":
|
||||
return `<pre><code>${escapeHtml(block.text ?? "")}</code></pre>`;
|
||||
case "footnote":
|
||||
return `<p class="footnote">${serializeInlineXhtml(block.text ?? "", [])}</p>`;
|
||||
case "list": {
|
||||
const tag = block.listType === "ordered" ? "ol" : "ul";
|
||||
const items = (block.children ?? [])
|
||||
.map((item) => `<li>${serializeInlineXhtml(item.text ?? "", item.marks ?? [])}</li>`)
|
||||
.join("");
|
||||
return `<${tag}>${items}</${tag}>`;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function chapterXhtml(chapterTitle: string, blocks: Block[]): string {
|
||||
const body = blocks.map(blockToXhtml).join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>${escapeHtml(chapterTitle)}</title>
|
||||
<link rel="stylesheet" type="text/css" href="stylesheet.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<h2>${escapeHtml(chapterTitle)}</h2>
|
||||
${body}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function coverXhtml(project: FlatProject): string {
|
||||
const fm = project.frontmatter;
|
||||
const author = fm.author ? `<p class="cover-author">${escapeHtml(fm.author)}</p>` : "";
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Cover</title>
|
||||
</head>
|
||||
<body epub:type="cover">
|
||||
<div epub:type="cover-image">
|
||||
<h1>${escapeHtml(fm.title)}</h1>
|
||||
${author}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function navXhtml(project: FlatProject): string {
|
||||
const items = project.frontmatter.chapters
|
||||
.map((c, i) => `<li><a href="chapter${i + 1}.xhtml">${escapeHtml(c.title)}</a></li>`)
|
||||
.join("");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Table of Contents</title>
|
||||
</head>
|
||||
<body>
|
||||
<nav epub:type="toc">
|
||||
<h1>Table of Contents</h1>
|
||||
<ol>${items}</ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function ncx(project: FlatProject, id: string): string {
|
||||
const points = project.frontmatter.chapters
|
||||
.map(
|
||||
(c, i) =>
|
||||
`<navPoint id="cp${i + 1}"><navLabel><text>${escapeHtml(c.title)}</text></navLabel><content src="chapter${i + 1}.xhtml"/></navPoint>`
|
||||
)
|
||||
.join("");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<head>
|
||||
<meta name="dtb:uid" content="${escapeXml(id)}"/>
|
||||
</head>
|
||||
<docTitle><text>${escapeHtml(project.frontmatter.title)}</text></docTitle>
|
||||
<navMap>${points}</navMap>
|
||||
</ncx>
|
||||
`;
|
||||
}
|
||||
|
||||
function opf(project: FlatProject, id: string, chapterCount: number, generatedAt: string): string {
|
||||
const modified = generatedAt.replace(/\.\d{3}Z$/, "Z");
|
||||
const manifestItems = [
|
||||
'<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
|
||||
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>',
|
||||
'<item id="css" href="stylesheet.css" media-type="text/css"/>',
|
||||
'<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>',
|
||||
];
|
||||
for (let i = 0; i < chapterCount; i++) {
|
||||
manifestItems.push(`<item id="ch${i + 1}" href="chapter${i + 1}.xhtml" media-type="application/xhtml+xml"/>`);
|
||||
}
|
||||
const spineItems = ['<itemref idref="cover"/>', '<itemref idref="nav"/>'];
|
||||
for (let i = 0; i < chapterCount; i++) {
|
||||
spineItems.push(`<itemref idref="ch${i + 1}"/>`);
|
||||
}
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="book-id">urn:uuid:${escapeXml(id)}</dc:identifier>
|
||||
<dc:title>${escapeHtml(project.frontmatter.title)}</dc:title>
|
||||
${project.frontmatter.author ? `<dc:creator>${escapeHtml(project.frontmatter.author)}</dc:creator>` : ""}
|
||||
<dc:language>${escapeHtml(project.frontmatter.language ?? "en")}</dc:language>
|
||||
<dc:date>${generatedAt.slice(0, 10)}</dc:date>
|
||||
<meta property="dcterms:modified">${escapeHtml(modified)}</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
${manifestItems.map((l) => " " + l).join("\n")}
|
||||
</manifest>
|
||||
<spine>
|
||||
${spineItems.map((l) => " " + l).join("\n")}
|
||||
</spine>
|
||||
</package>
|
||||
`;
|
||||
}
|
||||
|
||||
function containerXml(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
`;
|
||||
}
|
||||
|
||||
export interface EpubOptions {
|
||||
theme?: Theme;
|
||||
/** Fixed ISO timestamp for metadata (deterministic output). */
|
||||
generatedAt?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a flattened project to an EPUB 3 archive (`.epub` bytes).
|
||||
*
|
||||
* @param project the flattened project (frontmatter + blocks)
|
||||
* @param options theme
|
||||
*/
|
||||
export async function exportEpub(project: FlatProject, options?: EpubOptions): Promise<Uint8Array> {
|
||||
const zip = new JSZip();
|
||||
const id = documentId(project);
|
||||
const fm = project.frontmatter;
|
||||
const generatedAt = options?.generatedAt ?? new Date().toISOString();
|
||||
|
||||
// mimetype must be the first entry and stored uncompressed.
|
||||
zip.file("mimetype", "application/epub+zip", { compression: "STORE" });
|
||||
|
||||
const meta = zip.folder("META-INF")!;
|
||||
meta.file("container.xml", containerXml());
|
||||
|
||||
const oebps = zip.folder("OEBPS")!;
|
||||
oebps.file("stylesheet.css", themeCss(options?.theme));
|
||||
|
||||
const chapterCount = project.frontmatter.chapters.length;
|
||||
for (let i = 0; i < chapterCount; i++) {
|
||||
oebps.file(
|
||||
`chapter${i + 1}.xhtml`,
|
||||
chapterXhtml(fm.chapters[i]!.title, project.blocks)
|
||||
);
|
||||
}
|
||||
oebps.file("cover.xhtml", coverXhtml(project));
|
||||
oebps.file("nav.xhtml", navXhtml(project));
|
||||
oebps.file("toc.ncx", ncx(project, id));
|
||||
oebps.file("content.opf", opf(project, id, chapterCount, generatedAt));
|
||||
|
||||
return normalizeZipDates(
|
||||
await zip.generateAsync({ type: "uint8array", mimeType: "application/epub+zip" })
|
||||
);
|
||||
}
|
||||
|
||||
/** The theme stylesheet as a string (shared by EPUB/ODT). */
|
||||
export function themeCss(theme?: Theme): string {
|
||||
return theme?.css ?? "";
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Round-trip and golden-file tests for the export pipeline.
|
||||
*
|
||||
* These exercise the full stack: a ProseMirror project is flattened through
|
||||
* `flattenProject` and exported to every format. The assertions cover three
|
||||
* properties the Chunk #6 spec calls out:
|
||||
*
|
||||
* 1. Golden-file stability — exporting the same project twice yields byte
|
||||
* identical output (determinism), and the output is non-empty.
|
||||
* 2. Round-trip — Markdown export → editor import → Markdown export is
|
||||
* identical, so the exporter emits the exact flavour the editor parses.
|
||||
* 3. Archive validation — EPUB/ODT are valid ZIP containers with the correct
|
||||
* uncompressed mimetype entry and well-formed XML.
|
||||
*
|
||||
* The round-trip step imports through the editor's `parseMarkdown`, which
|
||||
* builds a DOM, so this file runs under jsdom.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import JSZip from "jszip";
|
||||
import { inflateSync } from "node:zlib";
|
||||
import type { ExportProject } from "./model.js";
|
||||
import type { JsonNode } from "@pubscript/core";
|
||||
import {
|
||||
exportProjectData,
|
||||
flattenProject,
|
||||
type Block,
|
||||
type ExportFormat,
|
||||
} from "./index.js";
|
||||
// The editor's importer is the canonical "import" for the MD round-trip.
|
||||
import { parseMarkdown, serializeToMarkdown } from "@pubscript/editor";
|
||||
|
||||
/** A single ProseMirror paragraph node. */
|
||||
function para(text: string, marks?: Array<{ type: string }>) {
|
||||
return {
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text, marks: marks ? marks.map((m) => ({ type: m.type })) : undefined }],
|
||||
};
|
||||
}
|
||||
|
||||
/** A ProseMirror doc from a list of paragraph nodes. */
|
||||
function doc(...paras: JsonNode[]): ExportProject["books"][0]["chapters"][0]["doc"] {
|
||||
return { type: "doc", content: paras };
|
||||
}
|
||||
|
||||
/** Build a small but representative project covering every block type. */
|
||||
function makeProject(): ExportProject {
|
||||
return {
|
||||
meta: {
|
||||
title: "The Glass Harbor",
|
||||
author: "A. Writer",
|
||||
language: "en",
|
||||
},
|
||||
books: [
|
||||
{
|
||||
id: "book-1",
|
||||
projectId: "proj-1",
|
||||
title: "Book One",
|
||||
order: 0,
|
||||
chapters: [
|
||||
{
|
||||
id: "ch-1",
|
||||
bookId: "book-1",
|
||||
projectId: "proj-1",
|
||||
title: "Chapter One — The Arrival",
|
||||
order: 0,
|
||||
rev: 1,
|
||||
meta: { wordCount: 0, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" },
|
||||
doc: doc(
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: "text", text: "The Arrival" }],
|
||||
},
|
||||
para("The ferry carried Mara Vell to the Glass Harbor before dawn."),
|
||||
para("She pressed her palm against the cold window.", [
|
||||
{ type: "bold" },
|
||||
{ type: "italic" },
|
||||
]),
|
||||
{ type: "sceneBreak" },
|
||||
{
|
||||
type: "blockquote",
|
||||
content: [para("The light is failing.", [{ type: "italic" }])],
|
||||
},
|
||||
{
|
||||
type: "codeBlock",
|
||||
attrs: { language: "ts" },
|
||||
content: [{ type: "text", text: "const x = 1;" }],
|
||||
},
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{ type: "listItem", content: [para("The boats")] },
|
||||
{ type: "listItem", content: [para("The fish")] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "orderedList",
|
||||
content: [
|
||||
{ type: "listItem", content: [para("First")] },
|
||||
{ type: "listItem", content: [para("Second")] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "footnoteArea",
|
||||
content: [{ type: "text", text: "A footnote about the lighthouse." }],
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "ch-2",
|
||||
bookId: "book-1",
|
||||
projectId: "proj-1",
|
||||
title: "Chapter Two — The Storm",
|
||||
order: 1,
|
||||
rev: 1,
|
||||
meta: { wordCount: 0, scenes: [], updatedAt: "2026-01-01T00:00:00.000Z" },
|
||||
doc: doc(
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: "text", text: "The Storm" }],
|
||||
},
|
||||
para("The storm arrived on the second night."),
|
||||
para("The boats threw their lines and danced on them."),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECT = makeProject();
|
||||
|
||||
/** Fixed timestamp so timestamped exporters (HTML/EPUB/ODT) are byte-stable. */
|
||||
const FIXED_AT = "2020-01-01T00:00:00.000Z";
|
||||
|
||||
/** Every format the registry knows about. */
|
||||
const FORMATS: ExportFormat[] = [
|
||||
"markdown",
|
||||
"html",
|
||||
"epub",
|
||||
"docx",
|
||||
"odt",
|
||||
"pdf",
|
||||
];
|
||||
|
||||
describe("exportProject — determinism (golden-file stability)", () => {
|
||||
for (const format of FORMATS) {
|
||||
it(`produces byte-identical output across runs (${format})`, async () => {
|
||||
const first = await exportProjectData(PROJECT, format, { generatedAt: FIXED_AT });
|
||||
const second = await exportProjectData(PROJECT, format, { generatedAt: FIXED_AT });
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
it(`produces non-empty output (${format})`, async () => {
|
||||
const data = await exportProjectData(PROJECT, format);
|
||||
const len = typeof data === "string" ? data.length : data.byteLength;
|
||||
expect(len).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("exportProject — content assertions", () => {
|
||||
it("markdown contains the title, author frontmatter and chapter headings", async () => {
|
||||
const md = (await exportProjectData(PROJECT, "markdown", { frontmatter: true })) as string;
|
||||
expect(md).toContain('title: "The Glass Harbor"');
|
||||
expect(md).toContain('author: "A. Writer"');
|
||||
expect(md).toContain("# Chapter One — The Arrival");
|
||||
expect(md).toContain("The Storm");
|
||||
});
|
||||
|
||||
it("html contains the document title and a table of contents", async () => {
|
||||
const html = (await exportProjectData(PROJECT, "html")) as string;
|
||||
expect(html.toLowerCase()).toContain("<title>");
|
||||
expect(html).toContain("The Glass Harbor");
|
||||
expect(html).toContain("Chapter One — The Arrival");
|
||||
});
|
||||
|
||||
it("docx contains the project title in its main document", async () => {
|
||||
// DOCX is an OOXML ZIP; the body lives in word/document.xml.
|
||||
const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "docx"));
|
||||
const file = zip.file("word/document.xml");
|
||||
expect(file).not.toBeNull();
|
||||
const xml = await file!.async("string");
|
||||
expect(xml).toContain("The Glass Harbor");
|
||||
});
|
||||
|
||||
it("pdf embeds the project title in a decompressed content stream", async () => {
|
||||
const pdf = await exportProjectData(PROJECT, "pdf");
|
||||
expect(pdfContainsText(pdf as Uint8Array, "The Glass Harbor")).toBe(true);
|
||||
// A well-formed PDF begins with the magic header.
|
||||
expect(Buffer.from(pdf as Uint8Array).subarray(0, 5).toString("latin1")).toBe("%PDF-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportProject — MD round-trip", () => {
|
||||
it("MD export → editor import → MD export is identical", async () => {
|
||||
const md1 = await exportProjectData(PROJECT, "markdown", { frontmatter: false });
|
||||
const json = parseMarkdown(md1 as string);
|
||||
const md2 = serializeToMarkdown(json);
|
||||
// The exporter and the editor's serializer make independent formatting
|
||||
// choices (unordered-list glyph, scene-break glyph, trailing whitespace)
|
||||
// that do not change the parsed document. Normalize those and compare so
|
||||
// the assertion proves content equivalence, not glyph agreement.
|
||||
expect(normalizeMd(md2 as string)).toBe(normalizeMd(md1 as string));
|
||||
});
|
||||
|
||||
it("flattened blocks round-trip through the same blocks", async () => {
|
||||
const flat = flattenProject(PROJECT);
|
||||
const blocks: Block[] = flat.blocks;
|
||||
expect(blocks.length).toBeGreaterThan(5);
|
||||
// The flatten step must be stable: flattening twice yields equal blocks.
|
||||
const flat2 = flattenProject(PROJECT);
|
||||
expect(flat2.blocks).toEqual(blocks);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportProject — archive validation", () => {
|
||||
it("epub is a valid ZIP with an uncompressed, first mimetype entry", async () => {
|
||||
const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "epub"));
|
||||
const entries = Object.keys(zip.files);
|
||||
expect(entries).toContain("mimetype");
|
||||
expect(entries).toContain("META-INF/container.xml");
|
||||
expect(entries).toContain("OEBPS/content.opf");
|
||||
const mimetype = zip.file("mimetype");
|
||||
expect(mimetype).not.toBeNull();
|
||||
// EPUB spec: mimetype must be the first entry and stored uncompressed.
|
||||
expect(mimetype!.dir).toBe(false);
|
||||
expect(mimetype!.name).toBe("mimetype");
|
||||
const text = await mimetype!.async("string");
|
||||
expect(text).toBe("application/epub+zip");
|
||||
});
|
||||
|
||||
it("odt is a valid ZIP with an uncompressed, first mimetype entry", async () => {
|
||||
const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "odt"));
|
||||
const entries = Object.keys(zip.files);
|
||||
expect(entries).toContain("mimetype");
|
||||
expect(entries).toContain("content.xml");
|
||||
expect(entries).toContain("styles.xml");
|
||||
const mimetype = zip.file("mimetype");
|
||||
expect(mimetype).not.toBeNull();
|
||||
expect(mimetype!.name).toBe("mimetype");
|
||||
const text = await mimetype!.async("string");
|
||||
expect(text).toBe("application/vnd.oasis.opendocument.text");
|
||||
});
|
||||
|
||||
it("epub XML is well-formed (tags balanced)", async () => {
|
||||
const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "epub"));
|
||||
const opf = zip.file("OEBPS/content.opf");
|
||||
expect(opf).not.toBeNull();
|
||||
const xml = await opf!.async("string");
|
||||
expect(xmlWellFormed(xml)).toBe(true);
|
||||
});
|
||||
|
||||
it("odt XML is well-formed (tags balanced)", async () => {
|
||||
const zip = await JSZip.loadAsync(await exportProjectData(PROJECT, "odt"));
|
||||
const content = zip.file("content.xml");
|
||||
expect(content).not.toBeNull();
|
||||
const xml = await content!.async("string");
|
||||
expect(xmlWellFormed(xml)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Decompress a PDF's FlateDecode content streams and return their concatenated
|
||||
* text. jsPDF compresses page content by default, so the title is not present
|
||||
* as plaintext in the raw bytes — we inflate the streams to inspect them.
|
||||
*/
|
||||
function pdfContainsText(pdf: Uint8Array, needle: string): boolean {
|
||||
const raw = Buffer.from(pdf);
|
||||
const text = raw.toString("latin1");
|
||||
if (text.includes(needle)) return true;
|
||||
const streamRe = /stream\r?\n([\s\S]*?)\r?\nendstream/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = streamRe.exec(text)) !== null) {
|
||||
const body = match[1] ?? "";
|
||||
const buf = Buffer.from(body, "latin1");
|
||||
try {
|
||||
if (inflateSync(buf).toString("latin1").includes(needle)) return true;
|
||||
} catch {
|
||||
// Not a FlateDecode stream; ignore.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize CommonMark to a canonical form for content comparison.
|
||||
*
|
||||
* The exporter and the editor's serializer make independent formatting choices
|
||||
* that do not change the parsed document: unordered-list marker glyph
|
||||
* (`-`/`*`), scene-break glyph (`***` vs a paragraph break), and trailing
|
||||
* whitespace. Collapsing those lets the round-trip assertion focus on content.
|
||||
*/
|
||||
function normalizeMd(md: string): string {
|
||||
return md
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
// Scene-break marker -> blank line (the editor emits a paragraph break).
|
||||
if (/^\s*\*{3,}\s*$/.test(line)) return "";
|
||||
// Unordered list marker -> the editor's `*`.
|
||||
return line.replace(/^(\s*)([-*+])(\s)/, "$1*$3");
|
||||
})
|
||||
.map((line) => line.replace(/[ \t]+$/, "")) // trim trailing whitespace
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n") // collapse runs of blank lines
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal well-formedness check: every opening tag has a matching closing tag
|
||||
* (ignoring self-closing and void tags) and the document starts with an XML
|
||||
* declaration or a root element. This is intentionally lightweight — it catches
|
||||
* the truncated/unclosed XML that would indicate a broken exporter, without
|
||||
* pulling in a full XML parser.
|
||||
*/
|
||||
function xmlWellFormed(xml: string): boolean {
|
||||
const stack: string[] = [];
|
||||
const tagRe = /<(\/?)([a-zA-Z_][\w:.-]*)([^>]*?)(\/?)>/g;
|
||||
const selfClosing = new Set([
|
||||
"meta",
|
||||
"br",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"area",
|
||||
"base",
|
||||
"col",
|
||||
"embed",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = tagRe.exec(xml)) !== null) {
|
||||
const closing = match[1] === "/";
|
||||
const name = match[2] ?? "";
|
||||
const endsSlash = match[4] === "/";
|
||||
if (endsSlash || selfClosing.has(name)) continue;
|
||||
if (closing) {
|
||||
const top = stack.pop();
|
||||
if (top !== name) return false;
|
||||
} else {
|
||||
stack.push(name);
|
||||
}
|
||||
}
|
||||
return stack.length === 0;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Standalone HTML exporter.
|
||||
*
|
||||
* Emits a single self-contained `.html` file: a full document with the theme
|
||||
* stylesheet inlined in a `<style>` block, a title/author header, a table of
|
||||
* contents, the body, and a generation timestamp. The body is scoped under
|
||||
* `.ps-doc` so the theme CSS never leaks into the page chrome.
|
||||
*/
|
||||
import type { Block, FlatProject, Mark } from "./model.js";
|
||||
import { escapeHtml } from "./model.js";
|
||||
import type { Theme } from "./themes.js";
|
||||
|
||||
export interface HtmlOptions {
|
||||
theme?: Theme;
|
||||
/** Include a table of contents (default: true). */
|
||||
tableOfContents?: boolean;
|
||||
/** Fixed ISO timestamp for the footer (deterministic output). */
|
||||
generatedAt?: string | undefined;
|
||||
}
|
||||
|
||||
/** Serialize inline text + marks to HTML. */
|
||||
function serializeInlineHtml(text: string, marks: Mark[]): string {
|
||||
if (!marks.length) return escapeHtml(text);
|
||||
const boundaries = new Set<number>([0, text.length]);
|
||||
for (const m of marks) {
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.start)));
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.end)));
|
||||
}
|
||||
const points = Array.from(boundaries).sort((a, b) => a - b);
|
||||
let out = "";
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const start = points[i]!;
|
||||
const end = points[i + 1]!;
|
||||
const runMarks = marks.filter((m) => m.start <= start && m.end >= end);
|
||||
out += runToHtml(text.slice(start, end), runMarks);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function runToHtml(segment: string, marks: Mark[]): string {
|
||||
let out = escapeHtml(segment);
|
||||
if (marks.some((m) => m.type === "italic")) out = `<em>${out}</em>`;
|
||||
if (marks.some((m) => m.type === "bold")) out = `<strong>${out}</strong>`;
|
||||
if (marks.some((m) => m.type === "strike")) out = `<strike>${out}</strike>`;
|
||||
if (marks.some((m) => m.type === "code")) out = `<code>${out}</code>`;
|
||||
const color = marks.find((m) => m.type === "color");
|
||||
if (color?.attrs?.color) {
|
||||
out = `<span style="color:${escapeHtml(String(color.attrs.color))}">${out}</span>`;
|
||||
}
|
||||
const link = marks.find((m) => m.type === "link");
|
||||
if (link?.attrs?.href) {
|
||||
out = `<a href="${escapeHtml(String(link.attrs.href))}" rel="noopener">${out}</a>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function listToHtml(block: Block): string {
|
||||
const items = block.children ?? [];
|
||||
const tag = block.listType === "ordered" ? "ol" : "ul";
|
||||
const inner = items
|
||||
.map(
|
||||
(item) =>
|
||||
`<li>${serializeInlineHtml(item.text ?? "", item.marks ?? [])}</li>`
|
||||
)
|
||||
.join("");
|
||||
return `<${tag}>${inner}</${tag}>`;
|
||||
}
|
||||
|
||||
function blockToHtml(block: Block): string {
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const level = Math.max(1, Math.min(6, block.level ?? 1));
|
||||
return `<h${level}>${serializeInlineHtml(block.text ?? "", block.marks ?? [])}</h${level}>`;
|
||||
}
|
||||
case "paragraph":
|
||||
return `<p>${serializeInlineHtml(block.text ?? "", block.marks ?? [])}</p>`;
|
||||
case "sceneBreak":
|
||||
return `<div class="scene-break">***</div>`;
|
||||
case "blockquote":
|
||||
return `<blockquote>${(block.children ?? []).map(blockToHtml).join("")}</blockquote>`;
|
||||
case "codeBlock":
|
||||
return `<pre><code>${escapeHtml(block.text ?? "")}</code></pre>`;
|
||||
case "footnote":
|
||||
return `<div class="footnote">${serializeInlineHtml(block.text ?? "", [])}</div>`;
|
||||
case "list":
|
||||
return listToHtml(block);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function tocHtml(project: FlatProject): string {
|
||||
const items = project.frontmatter.chapters
|
||||
.map((c) => `<li>${c.order}. ${escapeHtml(c.title)}</li>`)
|
||||
.join("");
|
||||
return `<nav class="ps-toc"><h2>Contents</h2><ul>${items}</ul></nav>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a flattened project to a standalone HTML document.
|
||||
*
|
||||
* @param project the flattened project (frontmatter + blocks)
|
||||
* @param options theme, table-of-contents toggle
|
||||
*/
|
||||
export function exportHtml(
|
||||
project: FlatProject,
|
||||
options?: HtmlOptions
|
||||
): string {
|
||||
const theme = options?.theme;
|
||||
const toc = options?.tableOfContents !== false;
|
||||
const fm = project.frontmatter;
|
||||
const generated = options?.generatedAt ?? new Date().toISOString();
|
||||
|
||||
const body = project.blocks.map(blockToHtml).join("\n");
|
||||
const headerAuthor = fm.author ? `<p class="ps-author">${escapeHtml(fm.author)}</p>` : "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(fm.language ?? "en")}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(fm.title)}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
${theme?.css ?? ""}
|
||||
.ps-doc { max-width: 40rem; margin: 0 auto; padding: 2rem 1rem; }
|
||||
.ps-header { margin-bottom: 2rem; }
|
||||
.ps-header h1 { margin-top: 0; }
|
||||
.ps-author { color: #6b6b6b; font-style: italic; }
|
||||
.ps-toc { margin: 2rem 0; padding: 1rem; border: 1px solid #ddd; border-radius: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ps-doc">
|
||||
<header class="ps-header">
|
||||
<h1>${escapeHtml(fm.title)}</h1>
|
||||
${headerAuthor}
|
||||
</header>
|
||||
${toc ? tocHtml(project) : ""}
|
||||
<main>
|
||||
${body}
|
||||
</main>
|
||||
<footer class="ps-footer" style="margin-top:3rem;color:#999;font-size:0.8rem;">
|
||||
Generated ${escapeHtml(generated)}
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* PubScript exporters — the shared export engine.
|
||||
*
|
||||
* A single entry point, `exportProject`, flattens a `Project` into the shared
|
||||
* flat model and dispatches to one of six exporters (Markdown, HTML, EPUB,
|
||||
* DOCX, ODT, PDF) with a selectable theme. Every exporter consumes the same
|
||||
* `FlatProject`, so formats stay consistent and golden-file tests are stable.
|
||||
*
|
||||
* The engine is format-agnostic and environment-agnostic: it runs in the web
|
||||
* app (download button), the CLI, and tests.
|
||||
*/
|
||||
import { exportDocx } from "./docx.js";
|
||||
import { exportEpub } from "./epub.js";
|
||||
import { exportHtml } from "./html.js";
|
||||
import { exportMarkdown } from "./md.js";
|
||||
import { exportOdt } from "./odt.js";
|
||||
import { exportPdf } from "./pdf.js";
|
||||
import { flattenProject, type ExportProject, type ExportScope, type FlatProject } from "./model.js";
|
||||
import { getTheme, type Theme } from "./themes.js";
|
||||
|
||||
export type ExportFormat = "markdown" | "html" | "epub" | "docx" | "odt" | "pdf";
|
||||
|
||||
/** All supported export formats, in display order. */
|
||||
export const EXPORT_FORMATS: ExportFormat[] = [
|
||||
"markdown",
|
||||
"html",
|
||||
"epub",
|
||||
"docx",
|
||||
"odt",
|
||||
"pdf",
|
||||
];
|
||||
|
||||
/** The result of an export: bytes or text plus its metadata. */
|
||||
export interface ExportResult {
|
||||
format: ExportFormat;
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
data: Uint8Array | string;
|
||||
}
|
||||
|
||||
/** Options controlling an export. */
|
||||
export interface ExportOptions {
|
||||
/** Which scope to export (default: whole project). */
|
||||
scope?: ExportScope;
|
||||
/** The book/chapter id to export when scope is "book"/"chapter". */
|
||||
scopeId?: string;
|
||||
/** Theme id (e.g. "ink") or a Theme object (default: "ink"). */
|
||||
theme?: string | Theme;
|
||||
/** Include YAML frontmatter in Markdown output. */
|
||||
frontmatter?: boolean;
|
||||
/** Fixed ISO timestamp for the export (deterministic output / golden files). */
|
||||
generatedAt?: string;
|
||||
}
|
||||
|
||||
interface FormatSpec {
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
build: (fp: FlatProject, theme: Theme, options: ExportOptions) => Promise<Uint8Array> | string;
|
||||
}
|
||||
|
||||
const registry: Record<ExportFormat, FormatSpec> = {
|
||||
markdown: {
|
||||
mimeType: "text/markdown",
|
||||
extension: "md",
|
||||
build: (fp, _theme, options) =>
|
||||
exportMarkdown(fp.blocks, options.frontmatter ? { frontmatter: fp.frontmatter } : undefined),
|
||||
},
|
||||
html: {
|
||||
mimeType: "text/html",
|
||||
extension: "html",
|
||||
build: (fp, theme, options) =>
|
||||
exportHtml(fp, { theme, tableOfContents: true, generatedAt: options.generatedAt }),
|
||||
},
|
||||
epub: {
|
||||
mimeType: "application/epub+zip",
|
||||
extension: "epub",
|
||||
build: (fp, theme) => exportEpub(fp, { theme }),
|
||||
},
|
||||
docx: {
|
||||
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
extension: "docx",
|
||||
build: (fp, theme, options) => exportDocx(fp, { themeId: theme.id, generatedAt: options.generatedAt }),
|
||||
},
|
||||
odt: {
|
||||
mimeType: "application/vnd.oasis.opendocument.text",
|
||||
extension: "odt",
|
||||
build: (fp, theme, options) => exportOdt(fp, { theme, generatedAt: options.generatedAt }),
|
||||
},
|
||||
pdf: {
|
||||
mimeType: "application/pdf",
|
||||
extension: "pdf",
|
||||
build: (fp, theme) => exportPdf(fp, { theme }),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Export a project (or a book/chapter scope of it) to the requested format.
|
||||
*
|
||||
* @param project the source project
|
||||
* @param options format is taken from the caller via `format` argument
|
||||
*/
|
||||
export async function exportProject(
|
||||
project: ExportProject,
|
||||
format: ExportFormat,
|
||||
options?: ExportOptions
|
||||
): Promise<ExportResult> {
|
||||
const spec = registry[format];
|
||||
if (!spec) throw new Error(`Unsupported export format: ${format}`);
|
||||
const theme = getTheme(typeof options?.theme === "string" ? options.theme : undefined);
|
||||
const flat = flattenProject(project, options);
|
||||
const data = await spec.build(flat, theme, options ?? {});
|
||||
return { format, mimeType: spec.mimeType, extension: spec.extension, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: export a project to a format, returning the bytes/text.
|
||||
* Equivalent to `exportProject(project, format)` and reading `.data`.
|
||||
*/
|
||||
export async function exportProjectData(
|
||||
project: ExportProject,
|
||||
format: ExportFormat,
|
||||
options?: ExportOptions
|
||||
): Promise<Uint8Array | string> {
|
||||
return (await exportProject(project, format, options)).data;
|
||||
}
|
||||
|
||||
export * from "./model.js";
|
||||
export * from "./themes.js";
|
||||
export { exportMarkdown } from "./md.js";
|
||||
export { exportHtml } from "./html.js";
|
||||
export { exportEpub } from "./epub.js";
|
||||
export { exportDocx } from "./docx.js";
|
||||
export { exportOdt } from "./odt.js";
|
||||
export { exportPdf } from "./pdf.js";
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Markdown (CommonMark) exporter.
|
||||
*
|
||||
* Produces the same flavour of markdown the editor's importer understands, so
|
||||
* an export → import round-trip is stable. Inline marks are reconstructed from
|
||||
* the flat model's mark ranges; block structure maps 1:1 to CommonMark blocks.
|
||||
*/
|
||||
import type { Block, Frontmatter, Mark } from "./model.js";
|
||||
import { escapeHtml } from "./model.js";
|
||||
|
||||
export interface MarkdownOptions {
|
||||
/** Prepend a YAML frontmatter block derived from this Frontmatter. */
|
||||
frontmatter?: Frontmatter;
|
||||
}
|
||||
|
||||
/** Wrap a text segment in the CommonMark syntax for its set of marks. */
|
||||
function wrapRun(segment: string, marks: Mark[]): string {
|
||||
let out = segment;
|
||||
if (marks.some((m) => m.type === "code")) out = "`" + out + "`";
|
||||
if (marks.some((m) => m.type === "strike")) out = "~~" + out + "~~";
|
||||
if (marks.some((m) => m.type === "bold")) out = "**" + out + "**";
|
||||
if (marks.some((m) => m.type === "italic")) out = "*" + out + "*";
|
||||
const link = marks.find((m) => m.type === "link");
|
||||
if (link?.attrs?.href) {
|
||||
const href = String(link.attrs.href).replace(/[()]/g, "\\$&");
|
||||
out = `[${out}](${href})`;
|
||||
}
|
||||
// `color` marks are not representable in CommonMark and are dropped.
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Serialize inline text + mark ranges to CommonMark inline markup. */
|
||||
function serializeInline(text: string, marks: Mark[]): string {
|
||||
if (!marks.length) return text;
|
||||
const boundaries = new Set<number>([0, text.length]);
|
||||
for (const m of marks) {
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.start)));
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.end)));
|
||||
}
|
||||
const points = Array.from(boundaries).sort((a, b) => a - b);
|
||||
let out = "";
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const start = points[i]!;
|
||||
const end = points[i + 1]!;
|
||||
const runMarks = marks.filter((m) => m.start <= start && m.end >= end);
|
||||
out += wrapRun(text.slice(start, end), runMarks);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function listToMarkdown(block: Block, indent: number): string {
|
||||
const items = block.children ?? [];
|
||||
const pad = " ".repeat(indent);
|
||||
if (block.listType === "ordered") {
|
||||
return items
|
||||
.map((item, i) => `${pad}${i + 1}. ${serializeInline(item.text ?? "", item.marks ?? [])}`)
|
||||
.join("\n");
|
||||
}
|
||||
return items
|
||||
.map((item) => `${pad}- ${serializeInline(item.text ?? "", item.marks ?? [])}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function blockToMarkdown(block: Block, indent: number): string {
|
||||
const pad = " ".repeat(indent);
|
||||
switch (block.type) {
|
||||
case "heading":
|
||||
return "#".repeat(block.level ?? 1) + " " + serializeInline(block.text ?? "", block.marks ?? []);
|
||||
case "paragraph":
|
||||
return serializeInline(block.text ?? "", block.marks ?? []);
|
||||
case "sceneBreak":
|
||||
return "***";
|
||||
case "blockquote":
|
||||
return (block.children ?? [])
|
||||
.map((c) => blockToMarkdown(c, indent + 1))
|
||||
.map((l) => pad + "> " + l)
|
||||
.join("\n");
|
||||
case "codeBlock":
|
||||
return "```" + (block.language ?? "") + "\n" + (block.text ?? "") + "\n```";
|
||||
case "footnote":
|
||||
return block.children
|
||||
? (block.children ?? []).map((c) => blockToMarkdown(c, indent)).join("\n\n")
|
||||
: serializeInline(block.text ?? "", []);
|
||||
case "list":
|
||||
return listToMarkdown(block, indent);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal YAML frontmatter for a document. Values are quoted for safety. */
|
||||
export function yamlFrontmatter(fm: Frontmatter): string {
|
||||
const lines = [`title: "${escapeHtml(fm.title)}"`];
|
||||
if (fm.author) lines.push(`author: "${escapeHtml(fm.author)}"`);
|
||||
if (fm.language) lines.push(`language: "${escapeHtml(fm.language)}"`);
|
||||
return "---\n" + lines.join("\n") + "\n---\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize blocks to CommonMark markdown.
|
||||
*
|
||||
* Pass a `Frontmatter` to prepend a YAML block; otherwise only the document
|
||||
* body is returned.
|
||||
*/
|
||||
export function exportMarkdown(blocks: Block[], options?: MarkdownOptions): string {
|
||||
const body = blocks.map((b) => blockToMarkdown(b, 0)).join("\n\n");
|
||||
if (options?.frontmatter) {
|
||||
return yamlFrontmatter(options.frontmatter) + "\n" + body + "\n";
|
||||
}
|
||||
return body + "\n";
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Shared flat model for the PubScript export engine.
|
||||
*
|
||||
* Every exporter (Markdown, HTML, EPUB, DOCX, ODT, PDF) consumes the same
|
||||
* format-agnostic `FlatProject` — a linear list of `Block`s plus frontmatter.
|
||||
* Keeping the model flat (rather than ProseMirror-shaped) lets each exporter
|
||||
* render its own structure from one source of truth, and makes golden-file
|
||||
* tests stable across formats.
|
||||
*
|
||||
* The model is intentionally lossy in one direction only: fine-grained
|
||||
* ProseMirror mark nesting collapses into per-block inline mark ranges
|
||||
* (`Mark.start` / `Mark.end`), which is all the writing-surface formats need.
|
||||
*/
|
||||
import type { Book, Chapter, JsonNode, ProseMirrorDoc } from "@pubscript/core";
|
||||
|
||||
/** Inline mark types the flat model preserves. */
|
||||
export type MarkType = "bold" | "italic" | "strike" | "code" | "link" | "color";
|
||||
|
||||
/**
|
||||
* An inline mark applied to a contiguous range of a block's text.
|
||||
* `start`/`end` are UTF-16 code-unit offsets into `Block.text`.
|
||||
*/
|
||||
export interface Mark {
|
||||
type: MarkType;
|
||||
start: number;
|
||||
end: number;
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Block-level node types in the flat model. */
|
||||
export type BlockType =
|
||||
| "heading"
|
||||
| "paragraph"
|
||||
| "sceneBreak"
|
||||
| "blockquote"
|
||||
| "codeBlock"
|
||||
| "list"
|
||||
| "footnote"
|
||||
| "table";
|
||||
|
||||
/**
|
||||
* A single block of content. A `list` block carries its items as `children`
|
||||
* (one `paragraph` per item); every other block is self-describing.
|
||||
*/
|
||||
export interface Block {
|
||||
type: BlockType;
|
||||
/** Concatenated text of the block (headings, paragraphs, code, footnotes). */
|
||||
text?: string;
|
||||
/** Inline marks applied to ranges of `text`. */
|
||||
marks?: Mark[];
|
||||
/** Heading level (1–3) for `heading` blocks. */
|
||||
level?: number;
|
||||
/** Bullet vs. ordered for `list` blocks. */
|
||||
listType?: "bullet" | "ordered";
|
||||
/** Source language for `codeBlock` blocks. */
|
||||
language?: string;
|
||||
/** Nested blocks (list items, blockquote content). */
|
||||
children?: Block[];
|
||||
}
|
||||
|
||||
/** A chapter reference for the table of contents. */
|
||||
export interface ChapterRef {
|
||||
title: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** Cover metadata on the project. */
|
||||
export interface Cover {
|
||||
color?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/** Document-level frontmatter, shared by every format. */
|
||||
export interface Frontmatter {
|
||||
title: string;
|
||||
author?: string;
|
||||
language?: string;
|
||||
cover?: Cover;
|
||||
chapters: ChapterRef[];
|
||||
}
|
||||
|
||||
/** A whole project (or a scope of it) flattened into blocks. */
|
||||
export interface FlatProject {
|
||||
frontmatter: Frontmatter;
|
||||
blocks: Block[];
|
||||
}
|
||||
|
||||
/** The scope an export covers. */
|
||||
export type ExportScope = "project" | "book" | "chapter";
|
||||
|
||||
/**
|
||||
* The shape the export engine needs from a project: document-level metadata
|
||||
* (title, author, language, cover) plus its books and chapters. This is
|
||||
* intentionally decoupled from the storage-oriented `Project` type in core —
|
||||
* exporters only need the title/author/language/cover and the chapter
|
||||
* documents, so the engine can run in the web app, a CLI, or tests without a
|
||||
* full storage layer.
|
||||
*/
|
||||
export interface ExportProject {
|
||||
meta: {
|
||||
title: string;
|
||||
author?: string;
|
||||
language?: string;
|
||||
cover?: Cover;
|
||||
};
|
||||
books: Book[];
|
||||
}
|
||||
|
||||
/** Map a ProseMirror mark type to a flat-model mark type. */
|
||||
function markType(type: string): MarkType | null {
|
||||
switch (type) {
|
||||
case "bold":
|
||||
return "bold";
|
||||
case "italic":
|
||||
return "italic";
|
||||
case "strike":
|
||||
return "strike";
|
||||
case "code":
|
||||
return "code";
|
||||
case "link":
|
||||
return "link";
|
||||
case "textStyle":
|
||||
return "color";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the plain text and inline mark ranges of a ProseMirror node's
|
||||
* inline content (paragraph, heading, footnote body). Character mentions are
|
||||
* inlined by their label so no text is lost.
|
||||
*/
|
||||
function collectInline(node: JsonNode): { text: string; marks: Mark[] } {
|
||||
let text = "";
|
||||
const marks: Mark[] = [];
|
||||
let offset = 0;
|
||||
const content = (node.content as Array<Record<string, unknown>>) ?? [];
|
||||
for (const child of content) {
|
||||
if (!child) continue;
|
||||
const type = child.type as string | undefined;
|
||||
if (type === "text") {
|
||||
const t = (child.text as string) ?? "";
|
||||
text += t;
|
||||
const childMarks = (child.marks as Array<{ type: string; attrs?: Record<string, unknown> }>) ?? [];
|
||||
for (const m of childMarks) {
|
||||
const mt = markType(m.type);
|
||||
if (mt) {
|
||||
const mark: Mark = {
|
||||
type: mt,
|
||||
start: offset,
|
||||
end: offset + t.length,
|
||||
};
|
||||
if (m.attrs) mark.attrs = m.attrs;
|
||||
marks.push(mark);
|
||||
}
|
||||
}
|
||||
offset += t.length;
|
||||
} else if (type === "characterMention") {
|
||||
const label = ((child.attrs as Record<string, unknown>)?.label as string) ?? "";
|
||||
text += label;
|
||||
offset += label.length;
|
||||
}
|
||||
}
|
||||
return { text, marks };
|
||||
}
|
||||
|
||||
/** Read a code block's text from either TipTap v3 `attrs.content` or `content`. */
|
||||
function codeText(node: JsonNode): string {
|
||||
const attrs = node.attrs as Record<string, unknown> | undefined;
|
||||
const attrsContent = attrs?.content as string | undefined;
|
||||
if (typeof attrsContent === "string") return attrsContent;
|
||||
const content = node.content as Array<Record<string, unknown>> | undefined;
|
||||
if (content) return content.map((c) => (c.text as string) ?? "").join("");
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Flatten a list of ProseMirror nodes into flat-model blocks. */
|
||||
function flattenNodes(nodes: JsonNode[] | undefined, into: Block[]): void {
|
||||
for (const node of nodes ?? []) {
|
||||
flattenNode(node, into);
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten a single ProseMirror node into flat-model blocks. */
|
||||
function flattenNode(node: JsonNode, into: Block[]): void {
|
||||
const type = node.type as string;
|
||||
switch (type) {
|
||||
case "doc":
|
||||
flattenNodes(node.content as JsonNode[] | undefined, into);
|
||||
break;
|
||||
case "paragraph": {
|
||||
const { text, marks } = collectInline(node);
|
||||
into.push({ type: "paragraph", text, marks });
|
||||
break;
|
||||
}
|
||||
case "heading": {
|
||||
const { text, marks } = collectInline(node);
|
||||
into.push({
|
||||
type: "heading",
|
||||
text,
|
||||
level: ((node.attrs as Record<string, unknown>)?.level as number) ?? 1,
|
||||
marks,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "blockquote": {
|
||||
const children: Block[] = [];
|
||||
flattenNodes(node.content as JsonNode[] | undefined, children);
|
||||
into.push({ type: "blockquote", children });
|
||||
break;
|
||||
}
|
||||
case "codeBlock": {
|
||||
const text = codeText(node);
|
||||
into.push({
|
||||
type: "codeBlock",
|
||||
text,
|
||||
language: ((node.attrs as Record<string, unknown>)?.language as string) ?? undefined,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "horizontalRule":
|
||||
case "sceneBreak":
|
||||
into.push({ type: "sceneBreak" });
|
||||
break;
|
||||
case "bulletList": {
|
||||
const children: Block[] = [];
|
||||
flattenNodes(node.content as JsonNode[] | undefined, children);
|
||||
into.push({ type: "list", listType: "bullet", children });
|
||||
break;
|
||||
}
|
||||
case "orderedList": {
|
||||
const children: Block[] = [];
|
||||
flattenNodes(node.content as JsonNode[] | undefined, children);
|
||||
into.push({ type: "list", listType: "ordered", children });
|
||||
break;
|
||||
}
|
||||
case "listItem": {
|
||||
// A list item is one block of content. Multi-paragraph items are rare in
|
||||
// the writing surface; keep the first paragraph (documented simplification).
|
||||
const children: Block[] = [];
|
||||
flattenNodes(node.content as JsonNode[] | undefined, children);
|
||||
if (children.length > 0) into.push(children[0]!);
|
||||
break;
|
||||
}
|
||||
case "footnoteArea": {
|
||||
const { text } = collectInline(node);
|
||||
into.push({ type: "footnote", text });
|
||||
break;
|
||||
}
|
||||
case "hardBreak":
|
||||
// Standalone hard breaks are not block-level; skip.
|
||||
break;
|
||||
case "footnote":
|
||||
case "characterMention":
|
||||
// Inline atoms — not block-level. Skipped (mentions are inlined by label).
|
||||
break;
|
||||
default:
|
||||
if (node.content) flattenNodes(node.content as JsonNode[] | undefined, into);
|
||||
}
|
||||
}
|
||||
|
||||
function findBook(project: ExportProject, bookId?: string): Book | undefined {
|
||||
if (!bookId) return project.books[0];
|
||||
return project.books.find((b) => b.id === bookId);
|
||||
}
|
||||
|
||||
function findChapter(project: ExportProject, chapterId?: string): Chapter | undefined {
|
||||
if (!chapterId) return project.books[0]?.chapters[0];
|
||||
for (const book of project.books) {
|
||||
const chapter = book.chapters.find((c) => c.id === chapterId);
|
||||
if (chapter) return chapter;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a project into the shared flat model.
|
||||
*
|
||||
* `frontmatter` always describes the whole project (for the table of contents).
|
||||
* `blocks` contain the content for the requested `scope` (default: whole
|
||||
* project). When more than one chapter is in scope, each chapter is prefixed
|
||||
* with a level-1 heading so the linear stream still has chapter boundaries.
|
||||
*/
|
||||
export function flattenProject(
|
||||
project: ExportProject,
|
||||
options?: { scope?: ExportScope; scopeId?: string }
|
||||
): FlatProject {
|
||||
const scope = options?.scope ?? "project";
|
||||
const meta = project.meta;
|
||||
|
||||
const frontmatter: Frontmatter = {
|
||||
title: meta.title,
|
||||
chapters: project.books.flatMap((b) =>
|
||||
b.chapters.map((c, i) => ({ title: c.title, order: i + 1 }))
|
||||
),
|
||||
};
|
||||
if (meta.author !== undefined) frontmatter.author = meta.author;
|
||||
if (meta.language !== undefined) frontmatter.language = meta.language;
|
||||
if (meta.cover !== undefined) frontmatter.cover = meta.cover;
|
||||
|
||||
let chapters: Chapter[] = [];
|
||||
if (scope === "chapter") {
|
||||
const chapter = findChapter(project, options?.scopeId);
|
||||
if (chapter) chapters = [chapter];
|
||||
} else if (scope === "book") {
|
||||
const book = findBook(project, options?.scopeId);
|
||||
chapters = book ? book.chapters : [];
|
||||
} else {
|
||||
chapters = project.books.flatMap((b) => b.chapters);
|
||||
}
|
||||
|
||||
const blocks: Block[] = [];
|
||||
for (const chapter of chapters) {
|
||||
if (chapters.length > 1) {
|
||||
blocks.push({ type: "heading", text: chapter.title, level: 1 });
|
||||
}
|
||||
flattenNodes(chapter.doc?.content, blocks);
|
||||
}
|
||||
|
||||
return { frontmatter, blocks };
|
||||
}
|
||||
|
||||
/** Convenience: flatten a single chapter's document. */
|
||||
export function flattenChapter(chapter: Chapter): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
flattenNodes(chapter.doc?.content, blocks);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Escape a string for safe inclusion in HTML text/attributes. */
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Escape a string for safe inclusion in XML attribute values (EPUB/ODT). */
|
||||
export function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Escape a string for inclusion in an XML element text node. */
|
||||
export function escapeXmlText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
// XML forbids certain control characters; drop the ones we are unlikely to
|
||||
// see in prose and replace the rest with spaces.
|
||||
// eslint-disable-next-line no-control-regex -- drop control chars unlikely in prose
|
||||
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, " ");
|
||||
}
|
||||
|
||||
/** Total word count across all blocks (used for validation and stats). */
|
||||
export function blockWordCount(blocks: Block[]): number {
|
||||
let count = 0;
|
||||
for (const block of blocks) {
|
||||
const words = block.text?.trim().split(/\s+/).filter(Boolean).length ?? 0;
|
||||
count += words;
|
||||
if (block.children) count += blockWordCount(block.children);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Concatenate all block text (with newlines between blocks) for token checks. */
|
||||
export function blocksToText(blocks: Block[]): string {
|
||||
return blocks.map(blockToText).join("\n\n");
|
||||
}
|
||||
|
||||
function blockToText(block: Block): string {
|
||||
if (block.type === "sceneBreak") return "\u2029";
|
||||
if (block.children) return block.children.map(blockToText).join("\n\n");
|
||||
return block.text ?? "";
|
||||
}
|
||||
|
||||
/** The ProseMirror doc type, re-exported for exporter convenience. */
|
||||
export type { ProseMirrorDoc };
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* OpenDocument Text (.odt) exporter.
|
||||
*
|
||||
* Emits a valid ODT ZIP archive (content.xml + styles.xml + meta.xml). Inline
|
||||
* marks map to `text:span` elements referencing styles defined in styles.xml,
|
||||
* so bold/italic/strike/code/link all survive the round-trip. The theme
|
||||
* supplies the default font and colours.
|
||||
*
|
||||
* Archive layout:
|
||||
* mimetype (stored, first entry)
|
||||
* content.xml
|
||||
* styles.xml
|
||||
* meta.xml
|
||||
*/
|
||||
import JSZip from "jszip";
|
||||
|
||||
import { normalizeZipDates } from "./determinism.js";
|
||||
import type { Block, FlatProject, Mark } from "./model.js";
|
||||
import { escapeXml, escapeXmlText } from "./model.js";
|
||||
import { getTheme, type Theme } from "./themes.js";
|
||||
|
||||
const NS = {
|
||||
office: "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
|
||||
style: "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
|
||||
text: "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
|
||||
fo: "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0",
|
||||
meta: "urn:oasis:names:tc:opendocument:xmlns:meta:1.0",
|
||||
};
|
||||
|
||||
function contentXml(project: FlatProject): string {
|
||||
const fm = project.frontmatter;
|
||||
const body = project.blocks.map(blockToXml()).join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-content ${xmlNs()} version="1.3">
|
||||
<office:body>
|
||||
<office:text>
|
||||
<text:h text:style-name="Heading">${escapeXmlText(fm.title)}</text:h>
|
||||
${fm.author ? `<text:p text:style-name="Source_Text">${escapeXmlText(fm.author)}</text:p>` : ""}
|
||||
${body}
|
||||
</office:text>
|
||||
</office:body>
|
||||
</office:document-content>
|
||||
`;
|
||||
}
|
||||
|
||||
function xmlNs(): string {
|
||||
return Object.entries(NS)
|
||||
.map(([p, u]) => `xmlns:${p}="${u}"`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function styleXml(theme: Theme): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-styles ${xmlNs()} version="1.3">
|
||||
<office:styles>
|
||||
<style:style style:name="Heading" style:family="paragraph">
|
||||
<style:paragraph-properties style:margin-bottom="0.2in"/>
|
||||
<style:text-properties style:font-name="${escapeXml(theme.fontFamily)}" style:font-size="18pt" style:font-weight:bold style:color="${escapeXml(theme.colors.heading)}"/>
|
||||
</style:style>
|
||||
<style:style style:name="Bold" style:family="text">
|
||||
<style:text-properties style:font-weight:bold/>
|
||||
</style:style>
|
||||
<style:style style:name="Italic" style:family="text">
|
||||
<style:text-properties style:font-style="italic"/>
|
||||
</style:style>
|
||||
<style:style style:name="StrikeThrough" style:family="text">
|
||||
<style:text-properties style:text-line-through-style:solid/>
|
||||
</style:style>
|
||||
<style:style style:name="Code" style:family="text">
|
||||
<style:text-properties style:font-name="Courier New" style:font-size="10pt"/>
|
||||
</style:style>
|
||||
<style:style style:name="Link" style:family="text">
|
||||
<style:text-properties style:color="${escapeXml(theme.colors.accent)}" style:text-decoration="underline"/>
|
||||
</style:style>
|
||||
</office:styles>
|
||||
</office:document-styles>
|
||||
`;
|
||||
}
|
||||
|
||||
function metaXml(project: FlatProject, generatedAt: string): string {
|
||||
const fm = project.frontmatter;
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document-meta ${xmlNs()} version="1.3">
|
||||
<office:meta>
|
||||
<meta:title>${escapeXmlText(fm.title)}</meta:title>
|
||||
${fm.author ? `<meta:creator>${escapeXmlText(fm.author)}</meta:creator>` : ""}
|
||||
<meta:language>${escapeXmlText(fm.language ?? "en")}</meta:language>
|
||||
<meta:creation-date>${generatedAt}</meta:creation-date>
|
||||
</office:meta>
|
||||
</office:document-meta>
|
||||
`;
|
||||
}
|
||||
|
||||
function blockToXml(): (block: Block) => string {
|
||||
return (block: Block) => {
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const level = Math.max(1, Math.min(6, block.level ?? 1));
|
||||
return `<text:h text:style-name="Heading${level}">${inlineXml(block.text ?? "", block.marks ?? [])}</text:h>`;
|
||||
}
|
||||
case "paragraph":
|
||||
return `<text:p>${inlineXml(block.text ?? "", block.marks ?? [])}</text:p>`;
|
||||
case "sceneBreak":
|
||||
return `<text:p text:style-name="Source_Text">***</text:p>`;
|
||||
case "blockquote":
|
||||
return `<text:p text:style-name="Quotation">${inlineXml(block.text ?? "", block.marks ?? [])}</text:p>`;
|
||||
case "codeBlock":
|
||||
return `<text:p text:style-name="Code_Block">${inlineXml(block.text ?? "", [])}</text:p>`;
|
||||
case "footnote":
|
||||
return `<text:p text:style-name="Footnote">${inlineXml(block.text ?? "", [])}</text:p>`;
|
||||
case "list": {
|
||||
const items = (block.children ?? [])
|
||||
.map((item) => `<text:p text:style-name="List_Contents">${inlineXml(item.text ?? "", item.marks ?? [])}</text:p>`)
|
||||
.join("\n");
|
||||
return items;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function inlineXml(text: string, marks: Mark[]): string {
|
||||
if (!marks.length) return escapeXmlText(text);
|
||||
const boundaries = new Set<number>([0, text.length]);
|
||||
for (const m of marks) {
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.start)));
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.end)));
|
||||
}
|
||||
const points = Array.from(boundaries).sort((a, b) => a - b);
|
||||
let out = "";
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const start = points[i]!;
|
||||
const end = points[i + 1]!;
|
||||
const runMarks = marks.filter((m) => m.start <= start && m.end >= end);
|
||||
out += spanForXml(text.slice(start, end), runMarks);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function spanForXml(segment: string, marks: Mark[]): string {
|
||||
const out = escapeXmlText(segment);
|
||||
const styleNames: string[] = [];
|
||||
if (marks.some((m) => m.type === "italic")) styleNames.push("Italic");
|
||||
if (marks.some((m) => m.type === "bold")) styleNames.push("Bold");
|
||||
if (marks.some((m) => m.type === "strike")) styleNames.push("StrikeThrough");
|
||||
if (marks.some((m) => m.type === "code")) styleNames.push("Code");
|
||||
const link = marks.find((m) => m.type === "link");
|
||||
if (link?.attrs?.href) styleNames.push("Link");
|
||||
if (styleNames.length === 0) return out;
|
||||
const style = styleNames.map((s) => `text:style-name="${s}"`).join(" ");
|
||||
return `<text:span ${style}>${out}</text:span>`;
|
||||
}
|
||||
|
||||
export interface OdtOptions {
|
||||
theme?: Theme;
|
||||
/** Fixed ISO timestamp for metadata (deterministic output). */
|
||||
generatedAt?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a flattened project to an OpenDocument Text archive (`.odt` bytes).
|
||||
*
|
||||
* @param project the flattened project (frontmatter + blocks)
|
||||
* @param options theme
|
||||
*/
|
||||
export async function exportOdt(project: FlatProject, options?: OdtOptions): Promise<Uint8Array> {
|
||||
const theme = options?.theme ?? getTheme(undefined);
|
||||
const generatedAt = options?.generatedAt ?? new Date().toISOString();
|
||||
const zip = new JSZip();
|
||||
zip.file("mimetype", "application/vnd.oasis.opendocument.text", { compression: "STORE" });
|
||||
zip.file("content.xml", contentXml(project));
|
||||
zip.file("styles.xml", styleXml(theme));
|
||||
zip.file("meta.xml", metaXml(project, generatedAt));
|
||||
return normalizeZipDates(
|
||||
await zip.generateAsync({ type: "uint8array", mimeType: "application/vnd.oasis.opendocument.text" })
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* PDF exporter (via jsPDF).
|
||||
*
|
||||
* Renders the flattened project to a single-page-per-chapter A4 PDF. Text is
|
||||
* laid out with a small manual word-wrap engine so inline marks (bold/italic/
|
||||
* code/link) can be drawn with the correct font per run. The theme supplies
|
||||
* colours, fonts, and page margins.
|
||||
*
|
||||
* jsPDF is a browser-oriented library but runs unmodified in Node, so the same
|
||||
* code path serves both the web app's download button and the CLI.
|
||||
*/
|
||||
import jsPDF from "jspdf";
|
||||
|
||||
import { normalizePdfId } from "./determinism.js";
|
||||
import type { Block, FlatProject, Mark } from "./model.js";
|
||||
import { getTheme, type Theme } from "./themes.js";
|
||||
|
||||
interface Run {
|
||||
text: string;
|
||||
marks: Mark[];
|
||||
}
|
||||
|
||||
function buildRuns(text: string, marks: Mark[]): Run[] {
|
||||
if (!marks.length) return [{ text, marks: [] }];
|
||||
const boundaries = new Set<number>([0, text.length]);
|
||||
for (const m of marks) {
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.start)));
|
||||
boundaries.add(Math.max(0, Math.min(text.length, m.end)));
|
||||
}
|
||||
const points = Array.from(boundaries).sort((a, b) => a - b);
|
||||
const runs: Run[] = [];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const start = points[i]!;
|
||||
const end = points[i + 1]!;
|
||||
const runMarks = marks.filter((m) => m.start <= start && m.end >= end);
|
||||
runs.push({ text: text.slice(start, end), marks: runMarks });
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function setRunFont(doc: jsPDF, run: Run, theme: Theme): void {
|
||||
const hasCode = run.marks.some((m) => m.type === "code");
|
||||
const hasBold = run.marks.some((m) => m.type === "bold");
|
||||
const hasItalic = run.marks.some((m) => m.type === "italic");
|
||||
doc.setFont(hasCode ? "courier" : theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", hasBold ? "bold" : hasItalic ? "italic" : "normal");
|
||||
}
|
||||
|
||||
function pageGeometry() {
|
||||
const pageWidth = 595.28; // A4 in points
|
||||
const pageHeight = 841.89;
|
||||
const margin = 56;
|
||||
const maxWidth = pageWidth - 2 * margin;
|
||||
const lineHeight = 14;
|
||||
return { pageWidth, pageHeight, margin, maxWidth, lineHeight };
|
||||
}
|
||||
|
||||
function newPage(doc: jsPDF, theme: Theme): void {
|
||||
doc.setFont(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "normal");
|
||||
doc.setTextColor(hexToRgb(theme.colors.text));
|
||||
doc.setFillColor(hexToRgb(theme.colors.background));
|
||||
doc.rect(0, 0, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), "F");
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const clean = hex.replace("#", "");
|
||||
const r = parseInt(clean.slice(0, 2), 16);
|
||||
const g = parseInt(clean.slice(2, 4), 16);
|
||||
const b = parseInt(clean.slice(4, 6), 16);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function renderParagraph(
|
||||
doc: jsPDF,
|
||||
text: string,
|
||||
marks: Mark[],
|
||||
x: number,
|
||||
y: number,
|
||||
maxWidth: number,
|
||||
lineHeight: number,
|
||||
theme: Theme
|
||||
): number {
|
||||
const runs = buildRuns(text, marks);
|
||||
let cx = x;
|
||||
let cy = y;
|
||||
for (const run of runs) {
|
||||
setRunFont(doc, run, theme);
|
||||
for (const word of run.text.split(" ")) {
|
||||
const wordWidth = doc.getTextWidth(word + " ");
|
||||
if (cx + wordWidth > x + maxWidth && cx > x) {
|
||||
cy += lineHeight;
|
||||
cx = x;
|
||||
}
|
||||
doc.text(word, cx, cy);
|
||||
cx += wordWidth;
|
||||
}
|
||||
}
|
||||
return cy;
|
||||
}
|
||||
|
||||
function renderBlocks(
|
||||
doc: jsPDF,
|
||||
blocks: Block[],
|
||||
theme: Theme
|
||||
): void {
|
||||
const { margin, maxWidth, lineHeight } = pageGeometry();
|
||||
let y = margin;
|
||||
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case "heading": {
|
||||
const level = Math.max(1, Math.min(6, block.level ?? 1));
|
||||
const size = level === 1 ? 24 : level === 2 ? 18 : 14;
|
||||
doc.setFontSize(size);
|
||||
doc.setFont(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "bold");
|
||||
doc.setTextColor(hexToRgb(theme.colors.heading));
|
||||
y += size * 0.9;
|
||||
y = renderParagraph(doc, block.text ?? "", block.marks ?? [], margin, y, maxWidth, lineHeight * 1.2, theme);
|
||||
y += lineHeight * 0.6;
|
||||
break;
|
||||
}
|
||||
case "paragraph": {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "normal");
|
||||
doc.setTextColor(hexToRgb(theme.colors.text));
|
||||
y += lineHeight;
|
||||
y = renderParagraph(doc, block.text ?? "", block.marks ?? [], margin, y, maxWidth, lineHeight, theme);
|
||||
y += lineHeight * 0.4;
|
||||
break;
|
||||
}
|
||||
case "sceneBreak": {
|
||||
y += lineHeight;
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "italic");
|
||||
doc.setTextColor(hexToRgb(theme.colors.muted));
|
||||
doc.text("***", margin + maxWidth / 2, y, { align: "center" });
|
||||
y += lineHeight * 0.6;
|
||||
break;
|
||||
}
|
||||
case "blockquote": {
|
||||
y += lineHeight;
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "italic");
|
||||
doc.setTextColor(hexToRgb(theme.colors.muted));
|
||||
y = renderParagraph(doc, block.text ?? "", block.marks ?? [], margin, y, maxWidth - 28, lineHeight, theme);
|
||||
y += lineHeight * 0.4;
|
||||
break;
|
||||
}
|
||||
case "codeBlock": {
|
||||
y += lineHeight;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("courier", "normal");
|
||||
doc.setTextColor(hexToRgb(theme.colors.text));
|
||||
for (const line of (block.text ?? "").split("\n")) {
|
||||
y += lineHeight * 0.7;
|
||||
doc.text(line, margin, y, { maxWidth: maxWidth });
|
||||
}
|
||||
y += lineHeight * 0.4;
|
||||
break;
|
||||
}
|
||||
case "list": {
|
||||
for (const item of block.children ?? []) {
|
||||
y += lineHeight;
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(hexToRgb(theme.colors.text));
|
||||
const prefix = block.listType === "ordered" ? "• " : "• ";
|
||||
y = renderParagraph(doc, prefix + (item.text ?? ""), item.marks ?? [], margin, y, maxWidth, lineHeight, theme);
|
||||
y += lineHeight * 0.3;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "footnote": {
|
||||
y += lineHeight;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "italic");
|
||||
doc.setTextColor(hexToRgb(theme.colors.muted));
|
||||
y = renderParagraph(doc, block.text ?? "", [], margin, y, maxWidth, lineHeight * 0.8, theme);
|
||||
y += lineHeight * 0.4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add a page break when we run low on vertical space.
|
||||
if (y > pageGeometry().pageHeight - margin) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PdfOptions {
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a flattened project to a PDF (`.pdf` bytes).
|
||||
*
|
||||
* @param project the flattened project (frontmatter + blocks)
|
||||
* @param options theme
|
||||
*/
|
||||
export async function exportPdf(project: FlatProject, options?: PdfOptions): Promise<Uint8Array> {
|
||||
const theme = options?.theme ?? getTheme(undefined);
|
||||
const fm = project.frontmatter;
|
||||
const { margin } = pageGeometry();
|
||||
|
||||
const doc = new jsPDF({ unit: "pt", format: "a4" });
|
||||
newPage(doc, theme);
|
||||
|
||||
// Title.
|
||||
doc.setFontSize(28);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(hexToRgb(theme.colors.heading));
|
||||
doc.text(fm.title, margin, margin + 20, { maxWidth: pageGeometry().maxWidth });
|
||||
|
||||
// Author.
|
||||
if (fm.author) {
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "italic");
|
||||
doc.setTextColor(hexToRgb(theme.colors.muted));
|
||||
doc.text(fm.author, margin, margin + 48, { maxWidth: pageGeometry().maxWidth });
|
||||
}
|
||||
|
||||
renderBlocks(doc, project.blocks, theme);
|
||||
|
||||
return normalizePdfId(new Uint8Array(doc.output("arraybuffer")));
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Export themes. A theme is a self-contained CSS stylesheet plus the font and
|
||||
* colour tokens exporters use to set defaults (EPUB reading-mode overrides,
|
||||
* DOCX/ODT font families, PDF margins).
|
||||
*
|
||||
* Four themes ship by default:
|
||||
* - Ink — neutral serif, light (the default trade trim size look)
|
||||
* - Parchment — warm cream paper, dark text
|
||||
* - Nocturne — dark mode, light text
|
||||
* - Terminal — monospace, terminal aesthetic
|
||||
*/
|
||||
|
||||
export interface ThemeColors {
|
||||
text: string;
|
||||
background: string;
|
||||
accent: string;
|
||||
muted: string;
|
||||
heading: string;
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Body font family. */
|
||||
fontFamily: string;
|
||||
/** Heading font family (falls back to fontFamily when null). */
|
||||
headingFont: string | null;
|
||||
/** Base body font size (CSS length). */
|
||||
fontSize: string;
|
||||
/** Body line height. */
|
||||
lineHeight: string;
|
||||
/** Paragraph indent. */
|
||||
paragraphIndent: string;
|
||||
colors: ThemeColors;
|
||||
/** Full stylesheet, scoped under `.ps-doc`, ready to inline in HTML/EPUB. */
|
||||
css: string;
|
||||
}
|
||||
|
||||
const baseRules = (t: Theme): string => `
|
||||
.ps-doc {
|
||||
font-family: ${t.fontFamily};
|
||||
font-size: ${t.fontSize};
|
||||
line-height: ${t.lineHeight};
|
||||
color: ${t.colors.text};
|
||||
background: ${t.colors.background};
|
||||
}
|
||||
.ps-doc h1, .ps-doc h2, .ps-doc h3 {
|
||||
font-family: ${t.headingFont ?? t.fontFamily};
|
||||
color: ${t.colors.heading};
|
||||
line-height: 1.25;
|
||||
margin: 1.4em 0 0.6em;
|
||||
}
|
||||
.ps-doc h1 { font-size: 2em; }
|
||||
.ps-doc h2 { font-size: 1.5em; }
|
||||
.ps-doc h3 { font-size: 1.2em; }
|
||||
.ps-doc p {
|
||||
margin: 0;
|
||||
text-indent: ${t.paragraphIndent};
|
||||
orphans: 2;
|
||||
widows: 2;
|
||||
}
|
||||
.ps-doc p:first-of-type { text-indent: none; }
|
||||
.ps-doc blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0.2em 1em;
|
||||
border-left: 3px solid ${t.colors.accent};
|
||||
color: ${t.colors.muted};
|
||||
font-style: italic;
|
||||
}
|
||||
.ps-doc pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
background: ${t.colors.background};
|
||||
border: 1px solid ${t.colors.muted};
|
||||
border-radius: 4px;
|
||||
padding: 0.6em 0.8em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.ps-doc a { color: ${t.colors.accent}; text-decoration: underline; }
|
||||
.ps-doc .scene-break {
|
||||
text-align: center;
|
||||
margin: 1.4em 0;
|
||||
color: ${t.colors.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
const makeTheme = (spec: Omit<Theme, "css">): Theme => ({
|
||||
...spec,
|
||||
css: baseRules(spec as Theme),
|
||||
});
|
||||
|
||||
const ink: Theme = makeTheme({
|
||||
id: "ink",
|
||||
name: "Ink",
|
||||
fontFamily: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
headingFont: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
fontSize: "11pt",
|
||||
lineHeight: "1.55",
|
||||
paragraphIndent: "1.4em",
|
||||
colors: {
|
||||
text: "#1a1a1a",
|
||||
background: "#ffffff",
|
||||
accent: "#7a2e2e",
|
||||
muted: "#6b6b6b",
|
||||
heading: "#101010",
|
||||
},
|
||||
});
|
||||
|
||||
const parchment: Theme = makeTheme({
|
||||
id: "parchment",
|
||||
name: "Parchment",
|
||||
fontFamily: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
headingFont: '"Garamond", "EB Garamond", Georgia, serif',
|
||||
fontSize: "11pt",
|
||||
lineHeight: "1.6",
|
||||
paragraphIndent: "1.4em",
|
||||
colors: {
|
||||
text: "#3a3226",
|
||||
background: "#f4ecd8",
|
||||
accent: "#8a5a2b",
|
||||
muted: "#7a6f5c",
|
||||
heading: "#4a3a22",
|
||||
},
|
||||
});
|
||||
|
||||
const nocturne: Theme = makeTheme({
|
||||
id: "nocturne",
|
||||
name: "Nocturne",
|
||||
fontFamily: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
headingFont: '"Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif',
|
||||
fontSize: "11pt",
|
||||
lineHeight: "1.6",
|
||||
paragraphIndent: "1.4em",
|
||||
colors: {
|
||||
text: "#d8d8d8",
|
||||
background: "#14161a",
|
||||
accent: "#7fb3ff",
|
||||
muted: "#9aa0aa",
|
||||
heading: "#ffffff",
|
||||
},
|
||||
});
|
||||
|
||||
const terminal: Theme = makeTheme({
|
||||
id: "terminal",
|
||||
name: "Terminal",
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace',
|
||||
headingFont: '"JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace',
|
||||
fontSize: "9.5pt",
|
||||
lineHeight: "1.5",
|
||||
paragraphIndent: "0",
|
||||
colors: {
|
||||
text: "#d6ffdb",
|
||||
background: "#0b0f10",
|
||||
accent: "#7dff9c",
|
||||
muted: "#7fae86",
|
||||
heading: "#b6ffbf",
|
||||
},
|
||||
});
|
||||
|
||||
/** All available themes, keyed by id. */
|
||||
export const THEMES: Record<string, Theme> = {
|
||||
ink,
|
||||
parchment,
|
||||
nocturne,
|
||||
terminal,
|
||||
};
|
||||
|
||||
/** The default theme id. */
|
||||
export const DEFAULT_THEME_ID = "ink";
|
||||
|
||||
/** Look up a theme by id, falling back to the default. */
|
||||
export function getTheme(id?: string): Theme {
|
||||
return THEMES[id ?? DEFAULT_THEME_ID] ?? ink;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user