feat(book): J4.b — variable-geometry thread pagination

Lets a thread have frames of different sizes and have text wrap
correctly in each. The chain {frame1: 6×4, frame2: 4×6, frame3:
6×6} now line-breaks at frame1's 6″ width, then at frame2's 4″
width, then at frame3's 6″ width — no clipping in the narrow
frame, no excess gutter in the wide one.

What changed:

- paginateChapter accepts opts.startTokenIndex and opts.maxPages.
  The token loop honors both: starts from the given index, stops
  after the page count is met, and returns result.endTokenIndex
  so the caller knows where to resume.
- _paginateChapterIntoThread walks the chain frame-by-frame,
  calling paginateChapter once per frame with that frame's
  geometry + the running token cursor. Each frame contributes its
  own one page to the chain.
- Overset detection switched from a page-count comparison to a
  token-cursor comparison (paginated.hasMoreText). More accurate
  with variable geometry where page count ≠ frame count.

Limitations still standing:
- PDF export, Reader Mode, and tile thumbnails still walk
  book.pages reflow entries the old way — J4.c.
- Tokens are re-lexed and re-measured per frame today. For long
  chains this is N × token-count work; cache by chain+chapter
  if it becomes a perf problem.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-06-04 20:31:57 -04:00
1 parent 2d7e757308
commit 282f5df4f3
1 file changed
+67 -29
+67 -29
View File
@@ -11013,6 +11013,13 @@
}
// Lay blocks out into pages.
// J4.b: optional opts.startTokenIndex / opts.maxPages let a
// caller paginate one frame at a time, resuming where the
// previous call left off. The returned result.endTokenIndex
// points at the next token to process; pass it back in as
// startTokenIndex for the next frame in the chain.
const startTokenIndex = Number.isInteger(opts.startTokenIndex) ? opts.startTokenIndex : 0;
const maxPages = Number.isInteger(opts.maxPages) && opts.maxPages > 0 ? opts.maxPages : Infinity;
let currentPage = { blocks: [] };
let yCursor = 0;
const pages = [currentPage];
@@ -11022,7 +11029,13 @@
pages.push(currentPage);
};
for (let i = 0; i < tokens.length; i++) {
let i;
for (i = startTokenIndex; i < tokens.length; i++) {
// Stop building when we've filled the requested page count.
// The last token successfully placed bumps `i` so the caller
// sees endTokenIndex = next token to process.
if (pages.length > maxPages) { i--; break; }
const block = blockForToken(tokens[i]);
if (!block) continue;
@@ -11035,6 +11048,10 @@
}
// Page break: doesn't fit on current page → push a new page.
if (yCursor + block.blockH > frameH && currentPage.blocks.length > 0) {
// J4.b: if we're capped at maxPages and pushing would
// exceed it, stop instead — leave this token for the next
// frame in the chain.
if (pages.length >= maxPages) break;
pushPage();
}
// keepWithNext: if this block is a heading and there's a
@@ -11045,6 +11062,7 @@
const next = (i + 1 < tokens.length) ? blockForToken(tokens[i + 1]) : null;
if (next && yCursor + block.blockH + next.blockH > frameH
&& currentPage.blocks.length > 0) {
if (pages.length >= maxPages) break;
pushPage();
}
}
@@ -11052,6 +11070,8 @@
currentPage.blocks.push(block);
yCursor += block.blockH;
}
result.endTokenIndex = i;
result.tokenCount = tokens.length;
// 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();
@@ -22764,34 +22784,55 @@
render();
}
// J4 — paginate a chapter's markdown into a thread's frame
// sequence. Builds a synthetic reflow entry from the head layer's
// sourceChapter and calls paginateChapter with the head's
// geometry as the override. Returns the pages array (one entry
// per frame, in chain order) plus any overflow.
//
// Limitation: v1 assumes every frame in the chain shares the head
// frame's geometry. Variable-geometry threads are J4.b territory
// (the engine would need to re-flow line breaks per frame, which
// means walking tokens frame-by-frame instead of all at once).
// sequence. Walks the chain frame-by-frame so each frame
// contributes its own width to line-breaking and its own height
// to the cap. Tokens carry over between frames via the
// endTokenIndex / startTokenIndex cursor added in J4.b.
function _paginateChapterIntoThread(headLayer, book) {
if (!headLayer?.sourceChapter || !book) return { pages: [], chain: [headLayer] };
const chain = _bookCollectThreadChain(headLayer);
if (!headLayer?.sourceChapter || !book) return { pages: [], chain };
const sourceEntry = (book.pages || []).find(p => p.id === headLayer.sourceChapter);
if (!sourceEntry || sourceEntry.type !== 'reflow') return { pages: [], chain: [headLayer] };
const padding = Number(headLayer.shape?.padding) || 0;
if (!sourceEntry || sourceEntry.type !== 'reflow') return { pages: [], chain };
const pages = [];
const warnings = [];
let overflow = [];
let cursor = 0;
let tokenCount = -1;
for (let i = 0; i < chain.length; i++) {
const frame = chain[i];
const padding = Number(frame.shape?.padding) || 0;
const result = paginateChapter(sourceEntry, book, {
frameOverride: {
x: Number(headLayer.x) || 0,
y: Number(headLayer.y) || 0,
w: Number(headLayer.w) || 0,
h: Number(headLayer.h) || 0,
x: Number(frame.x) || 0,
y: Number(frame.y) || 0,
w: Number(frame.w) || 0,
h: Number(frame.h) || 0,
padding,
},
startTokenIndex: cursor,
maxPages: 1,
});
pages.push(result.pages?.[0] || { blocks: [] });
cursor = result.endTokenIndex ?? cursor;
tokenCount = result.tokenCount ?? tokenCount;
if (i === 0) {
// Collect warnings once from the first pass so the user
// doesn't see the same warning N times.
warnings.push(...(result.warnings || []));
overflow = (result.overflow || []).slice();
}
// Done if all tokens consumed.
if (cursor >= (tokenCount ?? 0)) break;
}
return {
pages: result.pages || [],
chain: _bookCollectThreadChain(headLayer),
overflow: result.overflow || [],
warnings: result.warnings || [],
pages,
chain,
overflow,
warnings,
tokensConsumed: cursor,
totalTokens: tokenCount,
hasMoreText: tokenCount >= 0 && cursor < tokenCount,
};
}
@@ -23062,17 +23103,14 @@
if (pageForThisFrame && pageForThisFrame.blocks?.length) {
const padding = Number(layer.shape?.padding) || 0;
_paintFrameBlocks(el, pageForThisFrame, Number(layer.w) || 0, Number(layer.h) || 0, padding);
// Last frame in the chain with text still left over →
// mark it overset so the user knows to add more frames.
const isLast = myIndex === paginated.pages.length - 1
&& paginated.pages.length === chain.length;
const hasMore = paginated.pages.length > chain.length
|| (paginated.overflow && paginated.overflow.length);
if (myIndex === chain.length - 1 && hasMore) {
// J4.b — overset is now driven by the tokens-consumed
// cursor. If the last frame in the chain doesn't reach
// the end of the chapter's token stream, mark it.
if (myIndex === chain.length - 1 && paginated.hasMoreText) {
const overset = document.createElement('div');
overset.style.cssText = 'position:absolute;bottom:6px;right:6px;background:#fee2e2;color:#991b1b;border:1px solid #fca5a5;border-radius:3px;padding:1px 6px;font-size:.62rem;font-weight:700;pointer-events:none;z-index:9;letter-spacing:.05em';
overset.textContent = '⊞ OVERSET';
overset.title = `Text wants ${paginated.pages.length} frames but the thread has ${chain.length}. Add more frames + extend the thread.`;
overset.title = `Chapter has ${paginated.totalTokens} markdown blocks; ${paginated.tokensConsumed} were consumed by this chain. Add more frames + extend the thread to fit the rest.`;
el.appendChild(overset);
}
} else if (!pageForThisFrame) {