diff --git a/src/index.html b/src/index.html
index 32fa7f3..bd4e7f2 100644
--- a/src/index.html
+++ b/src/index.html
@@ -8951,6 +8951,240 @@
syncInputs();
render();
}
+ // Pagination engine. Pure function shape so it's testable in
+ // isolation. Takes a reflow entry + the book state, returns
+ // `{ pages, overflow, warnings, masterId, frame }`.
+ //
+ // pages: array of `{ blocks }` where each block is one
+ // markdown token already laid out:
+ // { kind, lines: string[], style, lineHeightIn, y, blockH }
+ // overflow: blocks that wouldn't fit on any page (e.g. a frame
+ // too small to hold a single line)
+ // warnings: human-readable hints (long words, missing frame…)
+ // masterId: the master used (may be null)
+ // frame: the text-frame's geometry in canvas-inch space
+ // (x, y, w, h, padding) — null when no master / no frame.
+ //
+ // Phase C.5.2 ships paragraph + heading + blockquote + code +
+ // list + hr layout with greedy line breaking. Inline runs
+ // (bold / italic / inline-code) collapse to plain text using
+ // the block's base style; Phase I gives them per-run styles.
+ function paginateChapter(entry, book) {
+ const result = { pages: [], overflow: [], warnings: [], masterId: null, frame: null };
+ if (!entry || entry.type !== 'reflow') return result;
+ if (typeof window.marked === 'undefined' || !window.marked.lexer) {
+ result.warnings.push('marked library failed to load');
+ return result;
+ }
+ const md = String(entry.chapterMarkdown || '');
+ const styles = (book?.spec?.textStyles) || defaultBookTextStyles();
+ const masterId = entry.masterId || book?.spec?.defaultMasterId || null;
+ result.masterId = masterId;
+
+ // Locate the text-frame layer on the master.
+ let frame = null;
+ if (masterId) {
+ const target = `book-master:${masterId}`;
+ for (const l of state.layers || []) {
+ if (l.target !== target) continue;
+ if (l.type === 'shape' && l.shape?.kind === 'text-frame') {
+ frame = l;
+ break;
+ }
+ }
+ }
+ if (!frame) {
+ result.warnings.push(masterId
+ ? 'Master has no text-frame layer. Add one in master-edit mode to paginate.'
+ : 'No master selected for this chapter.');
+ return result;
+ }
+ const padding = Number(frame.shape?.padding) || 0;
+ const frameW = Math.max(0.5, (Number(frame.w) || 0) - 2 * padding);
+ const frameH = Math.max(0.5, (Number(frame.h) || 0) - 2 * padding);
+ result.frame = {
+ x: Number(frame.x) || 0,
+ y: Number(frame.y) || 0,
+ w: Number(frame.w) || 0,
+ h: Number(frame.h) || 0,
+ padding,
+ };
+
+ const tokens = window.marked.lexer(md);
+
+ // One shared Canvas2D context for text-width measurement.
+ // Sized at 96 px/inch so widths come out in pixels and we
+ // divide back into inches at the end.
+ const ctx = document.createElement('canvas').getContext('2d');
+ const styleFor = (kind) => styles[kind] || styles.body || {};
+ const setFont = (style) => {
+ const fs = Number(style.fontSize) || 11;
+ const fw = Number(style.fontWeight) || 400;
+ const fst = style.fontStyle === 'italic' ? 'italic ' : '';
+ const ff = style.fontFamily || 'Georgia, serif';
+ ctx.font = `${fst}${fw} ${fs * 96 / 72}px ${ff}`; // pt → px at 96dpi
+ };
+ const lineHeightIn = (style) => {
+ const fs = Number(style.fontSize) || 11;
+ const lh = Number(style.lineHeight) || 1.5;
+ return (fs * 96 / 72 / 96) * lh; // pt → inches × line-height
+ };
+
+ // Greedy wrap for a single string into multiple lines that
+ // fit `availableW` inches. Long single words overflow with a
+ // warning; we don't auto-hyphenate.
+ function wrapTextIntoLines(text, availableW) {
+ const lines = [];
+ // Preserve hard line breaks (paragraph internal `\n`) by
+ // wrapping each segment separately.
+ const segments = String(text).split('\n');
+ for (let s = 0; s < segments.length; s++) {
+ const words = segments[s].split(/\s+/).filter(Boolean);
+ if (!words.length) {
+ // Empty paragraph line — emit a blank line so spacing
+ // stays correct.
+ if (s > 0) lines.push('');
+ continue;
+ }
+ let line = '';
+ for (const word of words) {
+ const candidate = line ? line + ' ' + word : word;
+ const widthIn = ctx.measureText(candidate).width / 96;
+ if (widthIn > availableW && line) {
+ lines.push(line);
+ line = word;
+ // Check if the single word still overflows the column.
+ if (ctx.measureText(word).width / 96 > availableW) {
+ result.warnings.push(`Long word doesn't fit: "${word.slice(0, 24)}${word.length > 24 ? '…' : ''}"`);
+ }
+ } else {
+ line = candidate;
+ }
+ }
+ if (line) lines.push(line);
+ }
+ return lines;
+ }
+
+ // Convert a single marked token to a "block": one or more
+ // measured lines + the style metadata + the total block
+ // height. Returns null for tokens we skip silently (space).
+ function blockForToken(token) {
+ if (!token) return null;
+ if (token.type === 'space') return null;
+ // Headings: depth 1-6 → h1-h6 style key.
+ let styleKey = 'body';
+ if (token.type === 'heading') styleKey = `h${Math.min(6, Math.max(1, token.depth || 1))}`;
+ else if (token.type === 'paragraph') styleKey = 'body';
+ else if (token.type === 'blockquote') styleKey = 'blockquote';
+ else if (token.type === 'code') styleKey = 'codeBlock';
+ else if (token.type === 'hr') styleKey = 'hr';
+ else if (token.type === 'list') styleKey = 'list';
+ else styleKey = 'body';
+
+ const style = styleFor(styleKey);
+ const marginBefore = Number(style.marginBefore) || 0;
+ const marginAfter = Number(style.marginAfter) || 0;
+ const marginLeft = Number(style.marginLeft) || 0;
+ const marginRight = Number(style.marginRight) || 0;
+ const availW = Math.max(0.1, frameW - marginLeft - marginRight);
+ setFont(style);
+ const lineH = lineHeightIn(style);
+
+ let lines = [];
+ let extraH = 0;
+ if (token.type === 'hr') {
+ // Render as a one-line rule.
+ lines = [''];
+ extraH = (Number(style.thickness) || 0.5) / 96;
+ } else if (token.type === 'code' || token.type === 'codeBlock') {
+ // Code blocks: each `\n`-separated line stays a line,
+ // no wrapping (the user controls line lengths).
+ lines = String(token.text || '').split('\n');
+ } else if (token.type === 'list') {
+ // Flatten list items into "• text" lines. Each item
+ // wraps using the list style.
+ const items = Array.isArray(token.items) ? token.items : [];
+ const ordered = !!token.ordered;
+ items.forEach((item, i) => {
+ const marker = ordered ? `${i + 1}. ` : '• ';
+ const text = String(item.text || '');
+ const itemLines = wrapTextIntoLines(marker + text, availW);
+ lines = lines.concat(itemLines);
+ });
+ } else if (token.type === 'blockquote') {
+ // marked emits blockquote with inner tokens; concatenate
+ // their plain text + wrap.
+ const inner = (token.tokens || [])
+ .map(t => t.text || t.raw || '')
+ .join('\n\n');
+ lines = wrapTextIntoLines(inner, availW);
+ } else {
+ // heading / paragraph / fallback: flat text.
+ lines = wrapTextIntoLines(token.text || '', availW);
+ }
+
+ const linesH = (lines.length || 1) * lineH + extraH;
+ const blockH = marginBefore + linesH + marginAfter;
+ return {
+ kind: styleKey,
+ tokenType: token.type,
+ lines,
+ style,
+ lineHeightIn: lineH,
+ marginBefore, marginAfter, marginLeft, marginRight,
+ blockH,
+ keepWithNext: !!style.keepWithNext,
+ };
+ }
+
+ // Lay blocks out into pages.
+ let currentPage = { blocks: [] };
+ let yCursor = 0;
+ const pages = [currentPage];
+ const pushPage = () => {
+ currentPage = { blocks: [] };
+ yCursor = 0;
+ pages.push(currentPage);
+ };
+
+ for (let i = 0; i < tokens.length; i++) {
+ const block = blockForToken(tokens[i]);
+ if (!block) continue;
+
+ // Empty page + block too tall to ever fit → record as
+ // overflow and skip so we don't loop forever.
+ if (block.blockH > frameH && currentPage.blocks.length === 0) {
+ result.overflow.push(block);
+ result.warnings.push(`Block too tall for the text-frame: ${block.kind}.`);
+ continue;
+ }
+ // Page break: doesn't fit on current page → push a new page.
+ if (yCursor + block.blockH > frameH && currentPage.blocks.length > 0) {
+ pushPage();
+ }
+ // keepWithNext: if this block is a heading and there's a
+ // next block, peek ahead — if the heading would land at
+ // the very bottom (its bottom equals the frame), push it
+ // to the next page so it stays with the following content.
+ if (block.keepWithNext) {
+ const next = (i + 1 < tokens.length) ? blockForToken(tokens[i + 1]) : null;
+ if (next && yCursor + block.blockH + next.blockH > frameH
+ && currentPage.blocks.length > 0) {
+ pushPage();
+ }
+ }
+ block.y = yCursor + block.marginBefore;
+ currentPage.blocks.push(block);
+ yCursor += block.blockH;
+ }
+
+ // Drop a trailing empty page if no blocks were laid out at all.
+ if (pages.length > 1 && pages[pages.length - 1].blocks.length === 0) pages.pop();
+ result.pages = pages;
+ return result;
+ }
+
// Chapter editor (Phase C.5). Reflow entries open a two-pane
// textarea + live preview instead of the canvas. The book stays
// in 'book' template mode; an `editingChapterId` field tracks
@@ -8990,7 +9224,14 @@
if (titleEl && titleEl !== document.activeElement) titleEl.value = entry.chapterTitle || '';
if (ta && ta !== document.activeElement) ta.value = entry.chapterMarkdown || '';
if (preview) preview.innerHTML = markdownToSafeHtml(entry.chapterMarkdown || '');
- if (pagesEl) pagesEl.textContent = 'Pagination preview lands in Phase C.5.2';
+ if (pagesEl) {
+ const pag = paginateChapter(entry, book);
+ const n = pag.pages.length;
+ const wordCount = (entry.chapterMarkdown || '').trim().split(/\s+/).filter(Boolean).length;
+ const warn = pag.warnings.length ? ` · ⚠ ${pag.warnings[0]}` : '';
+ pagesEl.textContent = `${wordCount} word${wordCount === 1 ? '' : 's'} · ${n} page${n === 1 ? '' : 's'}${warn}`;
+ pagesEl.title = pag.warnings.join('\n');
+ }
}
// Minimal HTML escape + marked render. Sanitizes by routing
// through marked.parse with `mangle:false, headerIds:false`
@@ -9021,10 +9262,20 @@
if (!entry) return;
pushHistoryDebounced(`book:chapter:${id}:md`, 'book: edit chapter', 600);
entry.chapterMarkdown = String(value || '');
- // Only re-render preview (cheap); thumb / pagination will
- // re-run in C.5.2 once the engine exists.
+ // Cheap path on every keystroke: just re-render the preview
+ // HTML + the paginated count readout. Full state render()
+ // catches up at the next debounced history snapshot.
const preview = document.getElementById('bookChapterEditorPreview');
if (preview) preview.innerHTML = markdownToSafeHtml(entry.chapterMarkdown);
+ const pagesEl = document.getElementById('bookChapterEditorPages');
+ if (pagesEl) {
+ const pag = paginateChapter(entry, book);
+ const n = pag.pages.length;
+ const wordCount = entry.chapterMarkdown.trim().split(/\s+/).filter(Boolean).length;
+ const warn = pag.warnings.length ? ` · ⚠ ${pag.warnings[0]}` : '';
+ pagesEl.textContent = `${wordCount} word${wordCount === 1 ? '' : 's'} · ${n} page${n === 1 ? '' : 's'}${warn}`;
+ pagesEl.title = pag.warnings.join('\n');
+ }
}
function updateBookChapterTitle(value) {
const book = state.book;