Move page rasterization to an OffscreenCanvas worker

Page text drawing (the bulk of drawSpread cost: layout, fonts, fillText across ~25 lines
x 2 pages at 3072px) ran synchronously on the main thread during prepare/lookahead, tanking
FPS at load and at flips/word boundaries.

New public/js/book-texture-worker.js owns rasterization off-thread: it loads the EB Garamond
faces via FontFace, draws base + title + lines + page number into an OffscreenCanvas, and
returns a full-page ImageBitmap plus a background-only base ImageBitmap (for the reveal mask)
per side. The main thread blits those onto the existing page canvases with one drawImage, so
the texture/reveal/scene pipeline downstream is unchanged. The worker also owns image loading
(fetch + createImageBitmap) and a DOM-free inline-tag parser (no document in a worker); the
renderer marshals the DOM-sourced title data in.

drawSpread is now async and serialized through a promise chain so the shared render state
(currentSpread, revealPublishBlockIds, spread override, reveal base) stays consistent across
the worker round trip even with concurrent lookahead prepares; the reveal context is passed
per draw rather than left on the instance. prepareRevealBlock / prepareContinuationRevealPlan /
preloadAdditionalRevealSpreads and their timeline callers await accordingly. The old
main-thread drawing methods are deleted (single implementation now lives in the worker).

Verified live: pages render correctly via the worker (text + drop caps crisp), worker fonts
load (probe returns fonts-ready + drawn), idle ~66fps, playback median ~60fps. Remaining
non-rasterization main-thread costs (procedural texture generation in the loader; pagination
text layout; per-frame reflection/shadow on content change) are separate follow-ups. Suite 166.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 16:09:34 +02:00
parent 97f0b913be
commit 0e4d9e89d7
4 changed files with 513 additions and 397 deletions
+140 -389
View File
@@ -40,7 +40,6 @@ class BookTextureRendererModule extends BaseModule {
this.lastDrawSignature = null;
this.lastDrawSkipLoggedAt = 0;
this.pipelineTimings = [];
this.imageCache = new Map();
this.pageContentVersions = new Map();
this.bindMethods([
@@ -49,20 +48,12 @@ class BookTextureRendererModule extends BaseModule {
'waitForTextureFonts',
'ensureTextureFontFace',
'createPageCanvases',
'createRasterWorker',
'drawSpread',
'drawSpreadSerial',
'rasterizeSpread',
'getDrawSignature',
'cloneCanvas',
'drawPageBase',
'drawPageMeta',
'drawTitlePage',
'drawPageNumber',
'drawPageLines',
'drawImageRecord',
'resolveImageSource',
'getCachedImage',
'drawImageFitted',
'drawLine',
'drawWord',
'buildRevealRegions',
'shouldFlipAfterSideReveal',
'collectRevealRegionCandidates',
@@ -72,12 +63,7 @@ class BookTextureRendererModule extends BaseModule {
'getLineNaturalWidth',
'getLineWordCount',
'getImageRevealDurationMs',
'getInlineStyleState',
'updateInlineStyleState',
'getCanvasFont',
'applyTextStyle',
'getPageContent',
'buildLineSegments',
'prepareRevealBlock',
'prepareContinuationRevealPlan',
'takeContinuationRevealPlan',
@@ -113,6 +99,7 @@ class BookTextureRendererModule extends BaseModule {
await this.waitForTextureFonts();
this.reportProgress(20, 'Preparing page texture canvases');
this.createPageCanvases();
this.createRasterWorker();
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
// The renderer is a pure renderer. It does not react to pagination spread
// updates with draws or reveals — the playback owner (book-playback-timeline)
@@ -128,11 +115,94 @@ class BookTextureRendererModule extends BaseModule {
this.addEventListener(document, 'story:history-restoring', this.stopAnimations);
this.addEventListener(document, 'story:client-reset', this.stopAnimations);
this.currentSpread = this.pagination?.getCurrentSpread?.() || { index: 0, left: [], right: [], pageMeta: { left: null, right: null } };
this.drawSpread(this.currentSpread);
await this.drawSpread(this.currentSpread);
this.reportProgress(100, 'Book texture renderer ready');
return true;
}
createRasterWorker() {
const version = window.MODULE_CACHE_BUSTER ? `?v=${window.MODULE_CACHE_BUSTER}` : '';
this.rasterWorker = new Worker(`/js/book-texture-worker.js${version}`);
this.pendingRasterizations = new Map();
this.rasterRequestId = 0;
this.rasterChain = Promise.resolve();
this.rasterWorker.onmessage = (event) => {
const data = event.data || {};
if (data.type !== 'drawn') return;
const resolve = this.pendingRasterizations.get(data.requestId);
if (resolve) {
this.pendingRasterizations.delete(data.requestId);
resolve(data.results);
}
};
// Warm the worker's fonts immediately so the first real page render is not delayed.
this.rasterWorker.postMessage({ type: 'warm-fonts' });
}
// Plain, structured-cloneable subset of metrics the worker needs to draw a page.
buildWorkerMetrics() {
const m = this.metrics || {};
return {
width: m.width,
height: m.height,
content: m.content,
contentBySide: m.contentBySide,
typography: { fontFamily: m.typography?.fontFamily || 'serif' },
bodyFontSizePx: m.bodyFontSizePx,
typographyLineHeightPx: m.typographyLineHeightPx,
margins: { bottom: m.margins?.bottom || 0 }
};
}
// Title-page text lives in the DOM; read it here (the worker has no DOM) and pass it in.
buildTitleData() {
const metadata = this.gameConfig?.getMetadata?.() || {};
const t = this.localization?.t ? this.localization.t.bind(this.localization) : null;
return {
title: document.getElementById('game_title')?.textContent?.trim() || metadata.title || '',
author: document.getElementById('game_author')?.textContent?.trim()
|| (metadata.author && t ? t('title.byAuthor', { author: metadata.author }) : '') || '',
subtitle: document.getElementById('game_subtitle')?.textContent?.trim() || metadata.subtitle || '',
ornament: document.querySelector('#start_prompt .separator, #start_prompt .ornament, #start_prompt [class*="separator"]')?.textContent?.trim() || '',
legal: document.getElementById('game_legal_text')?.textContent?.trim() || [
metadata.version && t ? t('title.version', { version: metadata.version }) : '',
metadata.copyright || ''
].filter(Boolean).join(' | ')
};
}
rasterizeSpread(sidesToDraw, hasReveal) {
if (!this.rasterWorker || !this.metrics) return Promise.resolve(null);
const requestId = ++this.rasterRequestId;
const job = {
type: 'draw',
requestId,
width: this.metrics.width,
height: this.metrics.height,
sides: sidesToDraw,
hasReveal,
metrics: this.buildWorkerMetrics(),
pageMeta: this.currentSpread?.pageMeta || {},
titleData: this.buildTitleData(),
spreads: {
left: sidesToDraw.includes('left') ? (this.currentSpread?.left || []) : [],
right: sidesToDraw.includes('right') ? (this.currentSpread?.right || []) : []
}
};
return new Promise((resolve) => {
this.pendingRasterizations.set(requestId, resolve);
this.rasterWorker.postMessage(job);
});
}
canvasFromBitmap(bitmap) {
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d')?.drawImage(bitmap, 0, 0);
return canvas;
}
markPipelineTiming(name, detail = {}) {
const entry = {
name,
@@ -182,9 +252,23 @@ class BookTextureRendererModule extends BaseModule {
});
}
// Rasterization runs in a worker and is therefore async. Serialize draws through a chain so
// the shared render state (currentSpread, revealPublishBlockIds, revealSpreadSourceOverride,
// revealBaseCanvases) is never mutated by an overlapping draw — the critical section from
// setting that state to publishSpread stays atomic even across the worker round trip.
drawSpread(spread = null, sides = null, options = {}) {
const run = () => this.drawSpreadSerial(spread, sides, options);
this.rasterChain = (this.rasterChain || Promise.resolve()).then(run, run);
return this.rasterChain;
}
async drawSpreadSerial(spread = null, sides = null, options = {}) {
const previousSpread = this.currentSpread;
this.currentSpread = spread || { left: [], right: [] };
// Reveal context is passed per draw (not left on the instance by the caller) so it can be
// set inside this serialized section without racing concurrent lookahead prepares.
this.revealPublishBlockIds = options.revealPublishBlockIds || null;
this.revealSpreadSourceOverride = options.revealSpreadSourceOverride || null;
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0;
const phase = this.getDrawPhase(options);
@@ -195,7 +279,9 @@ class BookTextureRendererModule extends BaseModule {
this.lastDrawSkipLoggedAt = now;
this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw });
}
if (phase === 'prepare') this.currentSpread = previousSpread;
this.revealPublishBlockIds = null;
this.revealSpreadSourceOverride = null;
this.currentSpread = previousSpread;
return null;
}
this.markPipelineTiming('drawSpread:start', {
@@ -204,21 +290,24 @@ class BookTextureRendererModule extends BaseModule {
phase
});
this.revealBaseCanvases = { left: null, right: null };
const results = await this.rasterizeSpread(sidesToDraw, hasReveal);
sidesToDraw.forEach((side) => {
if (!this.canvases[side]) return;
this.drawPageBase(side);
if (hasReveal) this.revealBaseCanvases[side] = this.cloneCanvas(this.canvases[side]);
this.drawPageMeta(side, 'before-lines');
this.drawPageLines(side, this.currentSpread?.[side] || []);
this.drawPageMeta(side, 'after-lines');
const result = results?.[side];
if (!this.canvases[side] || !result) return;
const ctx = this.contexts[side];
ctx.clearRect(0, 0, this.canvases[side].width, this.canvases[side].height);
ctx.drawImage(result.pageBitmap, 0, 0);
result.pageBitmap.close?.();
if (hasReveal && result.baseBitmap) {
this.revealBaseCanvases[side] = this.canvasFromBitmap(result.baseBitmap);
}
result.baseBitmap?.close?.();
});
const published = this.publishSpread(sidesToDraw, options);
this.markPipelineTiming('drawSpread:end', {
sides: sidesToDraw,
phase
});
this.markPipelineTiming('drawSpread:end', { sides: sidesToDraw, phase });
this.revealBaseCanvases = null;
this.revealPublishBlockIds = null;
this.revealSpreadSourceOverride = null;
if (phase !== 'prepare' && !hasReveal) this.lastDrawSignature = drawSignature;
if (phase === 'prepare') this.currentSpread = previousSpread;
return published;
@@ -249,273 +338,6 @@ class BookTextureRendererModule extends BaseModule {
return clone;
}
drawPageBase(side) {
const canvas = this.canvases[side];
const ctx = this.contexts[side];
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f2ead0';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const shade = ctx.createLinearGradient(0, 0, canvas.width, 0);
if (side === 'left') {
shade.addColorStop(0, 'rgba(255, 255, 255, 0.06)');
shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(70, 48, 28, 0.08)');
} else {
shade.addColorStop(0, 'rgba(70, 48, 28, 0.08)');
shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(255, 255, 255, 0.06)');
}
ctx.fillStyle = shade;
ctx.fillRect(0, 0, canvas.width, canvas.height);
this.hitMaps[side] = [];
}
drawPageMeta(side, phase = 'after-lines') {
const meta = this.currentSpread?.pageMeta?.[side] || null;
if (!meta) return;
if (phase === 'before-lines' && meta.kind === 'title') this.drawTitlePage(side);
if (phase === 'after-lines') this.drawPageNumber(side, meta);
}
drawTitlePage(side) {
const ctx = this.contexts[side];
if (!ctx || !this.metrics) return;
const content = this.getPageContent(side);
const metadata = this.gameConfig?.getMetadata?.() || {};
const titleText = document.getElementById('game_title')?.textContent?.trim() || metadata.title || '';
const authorText = document.getElementById('game_author')?.textContent?.trim()
|| (metadata.author ? this.localization?.t?.('title.byAuthor', { author: metadata.author }) : '')
|| '';
const subtitleText = document.getElementById('game_subtitle')?.textContent?.trim() || metadata.subtitle || '';
const ornamentText = document.querySelector('#start_prompt .separator, #start_prompt .ornament, #start_prompt [class*="separator"]')?.textContent?.trim() || '';
const legalText = document.getElementById('game_legal_text')?.textContent?.trim() || [
metadata.version ? this.localization?.t?.('title.version', { version: metadata.version }) : '',
metadata.copyright || ''
].filter(Boolean).join(' | ');
const centerX = content.x + content.width * 0.5;
const font = this.metrics.typography.fontFamily;
ctx.save();
ctx.fillStyle = 'rgba(31, 19, 10, 0.9)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
if (authorText) {
ctx.font = `italic ${Math.round(this.metrics.bodyFontSizePx * 0.86)}px ${font}`;
ctx.fillText(authorText, centerX, content.y + content.height * 0.18);
}
if (titleText) {
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 1.55)}px ${font}`;
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'small-caps';
ctx.fillText(titleText, centerX, content.y + content.height * 0.28);
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
}
if (subtitleText) {
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.94)}px ${font}`;
ctx.fillText(subtitleText, centerX, content.y + content.height * 0.39);
}
if (ornamentText) {
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 1.3)}px ${font}`;
ctx.fillText(ornamentText, centerX, content.y + content.height * 0.52);
}
if (legalText) {
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.62)}px ${font}`;
ctx.fillText(legalText, centerX, content.y + content.height * 0.96);
}
ctx.restore();
}
drawPageNumber(side, meta = {}) {
if (meta.omitPageNumber || meta.pageNumber == null) return;
const ctx = this.contexts[side];
if (!ctx || !this.metrics) return;
const content = this.getPageContent(side);
ctx.save();
ctx.fillStyle = 'rgba(31, 19, 10, 0.74)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.68)}px ${this.metrics.typography.fontFamily}`;
ctx.fillText(String(meta.pageNumber), content.x + content.width * 0.5, content.y + content.height + this.metrics.margins.bottom * 0.48);
ctx.restore();
}
drawPageLines(side, lines = []) {
const ctx = this.contexts[side];
if (!ctx || !this.metrics || !Array.isArray(lines)) return;
ctx.save();
ctx.fillStyle = 'rgba(31, 19, 10, 0.86)';
ctx.textBaseline = 'alphabetic';
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
lines.forEach(line => {
if (line?.type === 'image' || line?.kind === 'image') this.drawImageRecord(ctx, line, side);
else this.drawLine(ctx, line, side);
});
ctx.restore();
}
drawImageRecord(ctx, lineRecord = {}, side = 'left') {
const content = this.getPageContent(side);
const layout = lineRecord.metadata?.imageLayout || {};
const rect = layout.textureRect || {};
const x = content.x + Number(rect.x || 0);
const y = content.y + Number(rect.y || 0);
const width = Math.max(1, Number(rect.width || content.width));
const height = Math.max(1, Number(rect.height || this.metrics.typographyLineHeightPx));
const src = this.resolveImageSource(lineRecord.metadata || {});
ctx.save();
if (src) {
const image = this.getCachedImage(src);
if (image?.complete && image.naturalWidth > 0) {
this.drawImageFitted(ctx, image, x, y, width, height);
}
}
ctx.restore();
}
resolveImageSource(metadata = {}) {
const explicit = String(metadata.url || metadata.src || '').trim();
if (explicit) return explicit;
const filename = String(metadata.filename || '').trim();
if (!filename) return '';
if (/^(https?:|data:|blob:|\/)/i.test(filename)) return filename;
return `/images/${filename.replace(/^images[\\/]/i, '').replace(/\\/g, '/')}`;
}
getCachedImage(src) {
if (!src) return null;
if (this.imageCache.has(src)) return this.imageCache.get(src);
const image = new Image();
image.decoding = 'async';
image.onload = () => this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
image.onerror = () => this.markPipelineTiming('image:load-error', { src });
image.src = src;
this.imageCache.set(src, image);
return image;
}
drawImageFitted(ctx, image, x, y, width, height) {
const sourceWidth = image.naturalWidth || image.width || 1;
const sourceHeight = image.naturalHeight || image.height || 1;
const sourceAspect = sourceWidth / sourceHeight;
const targetAspect = width / height;
let sx = 0;
let sy = 0;
let sw = sourceWidth;
let sh = sourceHeight;
if (sourceAspect > targetAspect) {
sw = sourceHeight * targetAspect;
sx = (sourceWidth - sw) * 0.5;
} else if (sourceAspect < targetAspect) {
sh = sourceWidth / targetAspect;
sy = (sourceHeight - sh) * 0.5;
}
ctx.drawImage(image, sx, sy, sw, sh, x, y, width, height);
}
drawLine(ctx, lineRecord = {}, side = 'left') {
const metrics = this.metrics;
const content = this.getPageContent(side);
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30));
const line = lineRecord.line || {};
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
const baseY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx;
const ratio = line.isFinal || line.align === 'center' ? 0 : Number(line.ratio || 0);
const naturalWidth = nodes.reduce((sum, node) => {
if (node.type === 'box' || node.type === 'glue') return sum + Number(node.width || 0);
return sum;
}, 0);
const centerOffset = line.align === 'center'
? Math.max(0, (content.width - naturalWidth) / 2)
: Number(line.offset || 0);
let x = content.x + centerOffset;
const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps);
const previousVariantCaps = 'fontVariantCaps' in ctx ? ctx.fontVariantCaps : null;
const previousLetterSpacing = 'letterSpacing' in ctx ? ctx.letterSpacing : null;
const baseStyle = this.getInlineStyleState(line.activeStyleTags || [], {
italic: lineRecord.fontStyle === 'italic'
});
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle);
if (lineRecord.dropCapText) {
ctx.save();
const dropCapFontPx = Math.round(fontPx * 2.68);
const dropCapX = content.x;
const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25);
ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
ctx.textBaseline = 'top';
ctx.fillText(String(lineRecord.dropCapText), dropCapX, dropCapY);
ctx.restore();
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle);
}
this.buildLineSegments(ctx, nodes, line, ratio, baseStyle).forEach((segment) => {
this.drawWord(ctx, segment, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx, smallCaps);
});
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = previousLetterSpacing || '0px';
}
getInlineStyleState(tags = [], base = {}) {
const state = {
bold: Boolean(base.bold),
italic: Boolean(base.italic)
};
tags.forEach(tag => {
if (tag?.bold) state.bold = true;
if (tag?.italic) state.italic = true;
});
return state;
}
updateInlineStyleState(stack = [], value = '') {
const text = String(value || '');
if (!text.startsWith('<')) return stack;
if (text.startsWith('</')) {
if (stack.length) stack.pop();
return stack;
}
const template = document.createElement('div');
template.innerHTML = text;
const element = template.firstElementChild;
if (!element) return stack;
const tagName = element.tagName.toLowerCase();
const style = String(element.getAttribute('style') || '').toLowerCase();
const className = String(element.getAttribute('class') || '').toLowerCase();
stack.push({
tagName,
bold: tagName === 'strong' || tagName === 'b' || /font-weight\s*:\s*(bold|[6-9]00)/.test(style) || className.includes('bold'),
italic: tagName === 'em' || tagName === 'i' || /font-style\s*:\s*italic/.test(style) || className.includes('italic')
});
return stack;
}
getCanvasFont(fontPx, smallCaps = false, style = {}) {
const metrics = this.metrics;
return [
style.italic ? 'italic' : '',
smallCaps ? 'small-caps' : '',
style.bold ? '700' : '',
`${fontPx}px`,
metrics.typography.fontFamily
].filter(Boolean).join(' ');
}
applyTextStyle(ctx, fontPx, smallCaps = false, style = {}) {
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
ctx.font = this.getCanvasFont(fontPx, smallCaps, style);
}
getPageContent(side = 'left') {
return this.metrics?.contentBySide?.[side] || this.metrics?.content || {
x: 0,
@@ -525,66 +347,6 @@ class BookTextureRendererModule extends BaseModule {
};
}
buildLineSegments(ctx, nodes = [], line = {}, ratio = 0, baseStyle = {}) {
const segments = [];
let x = 0;
let currentSegment = null;
let previousWasGlue = true;
let currentWordIndex = -1;
const styleStack = Array.isArray(line.activeStyleTags) ? line.activeStyleTags.map(tag => ({ ...tag })) : [];
nodes.forEach((node, index) => {
if (!node) return;
if (node.type === 'box' && node.value) {
const value = String(node.value);
const width = Number(node.width || ctx.measureText(value).width || 0);
const style = this.getInlineStyleState(styleStack, baseStyle);
if (currentSegment && !previousWasGlue && currentSegment.style.bold === style.bold && currentSegment.style.italic === style.italic) {
currentSegment.value += value;
currentSegment.width += width;
} else {
if (previousWasGlue) currentWordIndex += 1;
currentSegment = {
value,
x,
width,
wordIndex: Math.max(0, currentWordIndex),
style
};
segments.push(currentSegment);
}
x += width;
previousWasGlue = false;
} else if (node.type === 'glue' && node.width !== 0) {
let width = Number(node.width || 0);
if (ratio > 0) width += Number(node.stretch || 0) * ratio;
if (ratio < 0) width += Number(node.shrink || 0) * ratio;
x += width;
previousWasGlue = true;
currentSegment = null;
} else if (node.type === 'penalty' && node.penalty === 100) {
const isLineEndHyphen = Boolean(line.hyphenated && index === nodes.length - 1 && currentSegment);
if (isLineEndHyphen) {
const hyphenWidth = Number(node.width || ctx.measureText('-').width || 0);
currentSegment.value += '-';
currentSegment.width += hyphenWidth;
x += hyphenWidth;
}
previousWasGlue = false;
} else if (node.type === 'tag') {
this.updateInlineStyleState(styleStack, node.value);
}
});
return segments;
}
drawWord(ctx, segment, x, baseY, lineRecord, localWordIndex, side, fontPx, lineHeightPx, smallCaps = false) {
const value = segment?.value || '';
this.applyTextStyle(ctx, fontPx, smallCaps, segment?.style || {});
ctx.fillText(value, x, baseY);
}
buildRevealRegions(side) {
if (!this.revealPublishBlockIds || !this.metrics) return null;
const candidates = this.collectRevealRegionCandidates();
@@ -880,7 +642,7 @@ class BookTextureRendererModule extends BaseModule {
};
}
prepareRevealBlock(detail = {}, options = {}) {
async prepareRevealBlock(detail = {}, options = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const id = String(blockId);
@@ -912,25 +674,19 @@ class BookTextureRendererModule extends BaseModule {
}
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
this.revealPublishBlockIds = new Set([id]);
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
const sides = ['left', 'right'];
// When the caller supplies the (not-yet-committed) preview spreads for a spanning
// block, derive this spread's reveal timing across all of them so the cached plan
// already spans both pages, letting activate reuse it directly.
const spanningPreview = Array.isArray(detail.previewSpreads) && detail.previewSpreads.length > 1;
const previousOverride = this.revealSpreadSourceOverride;
if (spanningPreview) this.revealSpreadSourceOverride = detail.previewSpreads;
let published = null;
try {
published = this.drawSpread(spread, sides, {
phase,
publishEvent: options.publishEvent !== false
});
} finally {
this.revealSpreadSourceOverride = previousOverride;
}
if (!spanningPreview) this.preloadAdditionalRevealSpreads(id, spread);
const published = await this.drawSpread(spread, sides, {
phase,
publishEvent: options.publishEvent !== false,
revealPublishBlockIds: new Set([id]),
revealSpreadSourceOverride: spanningPreview ? detail.previewSpreads : null
});
if (!spanningPreview) await this.preloadAdditionalRevealSpreads(id, spread);
if (phase === 'prepare' && published) {
this.pageCache?.rememberPreparedRevealPlan?.(id, {
...published,
@@ -957,7 +713,7 @@ class BookTextureRendererModule extends BaseModule {
// computed across both spreads. revealContinuationSpread reuses this after the flip
// instead of redrawing the spread synchronously on the critical path. Returns the plan
// or null (caller falls back to the synchronous redraw).
prepareContinuationRevealPlan(detail = {}) {
async prepareContinuationRevealPlan(detail = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
const previewSpreads = Array.isArray(detail.previewSpreads) ? detail.previewSpreads : null;
const continuationSpread = detail.continuationSpread || null;
@@ -968,18 +724,12 @@ class BookTextureRendererModule extends BaseModule {
if (!existing || existing.completed) {
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
}
const previousOverride = this.revealSpreadSourceOverride;
const previousPublishIds = this.revealPublishBlockIds;
this.revealSpreadSourceOverride = previewSpreads;
this.revealPublishBlockIds = new Set([id]);
let published = null;
try {
published = this.drawSpread(continuationSpread, ['left', 'right'], { phase: 'prepare', publishEvent: false });
} finally {
// drawSpread nulls revealPublishBlockIds when it finishes; restore the caller's state.
this.revealSpreadSourceOverride = previousOverride;
this.revealPublishBlockIds = previousPublishIds;
}
const published = await this.drawSpread(continuationSpread, ['left', 'right'], {
phase: 'prepare',
publishEvent: false,
revealPublishBlockIds: new Set([id]),
revealSpreadSourceOverride: previewSpreads
});
if (!published || !published.reveal || !Object.keys(published.reveal).length) return null;
const plan = {
...published,
@@ -1020,15 +770,16 @@ class BookTextureRendererModule extends BaseModule {
return activated;
}
preloadAdditionalRevealSpreads(blockId, primarySpread = null) {
async preloadAdditionalRevealSpreads(blockId, primarySpread = null) {
const spreads = Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : [];
if (!spreads.length) return;
const primaryIndex = Number(primarySpread?.index);
spreads.forEach((spread) => {
if (!spread || Number(spread.index) === primaryIndex) return;
if (!this.spreadContainsBlock(spread, blockId)) return;
this.drawSpread(spread, ['left', 'right'], { phase: 'prepare' });
});
for (const spread of spreads) {
if (!spread || Number(spread.index) === primaryIndex) continue;
if (!this.spreadContainsBlock(spread, blockId)) continue;
// eslint-disable-next-line no-await-in-loop
await this.drawSpread(spread, ['left', 'right'], { phase: 'prepare' });
}
}
spreadContainsBlock(spread = {}, blockId = '') {