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:
@@ -222,12 +222,12 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
: null;
|
||||
|
||||
const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare');
|
||||
const texturePlan = this.textureRenderer.prepareRevealBlock(
|
||||
const texturePlan = await this.textureRenderer.prepareRevealBlock(
|
||||
continuationSpread ? { ...revealDetail, previewSpreads } : revealDetail,
|
||||
{ phase: 'prepare', publishEvent: false }
|
||||
);
|
||||
if (continuationSpread) {
|
||||
this.textureRenderer.prepareContinuationRevealPlan({
|
||||
await this.textureRenderer.prepareContinuationRevealPlan({
|
||||
...revealDetail,
|
||||
previewSpreads,
|
||||
continuationSpread
|
||||
@@ -317,7 +317,7 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
|
||||
// Reuse the spanning-aware plan prepared during lookahead — its timing already spans
|
||||
// both pages. No synchronous redraw on the critical path.
|
||||
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
|
||||
const texturePlan = await this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
|
||||
segment.activeTexturePlan = texturePlan;
|
||||
this.applyTexturePlan(texturePlan, segment, 'activate');
|
||||
await this.assertSegmentReady(segment, 'activate');
|
||||
|
||||
@@ -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, {
|
||||
const published = await this.drawSpread(spread, sides, {
|
||||
phase,
|
||||
publishEvent: options.publishEvent !== false
|
||||
publishEvent: options.publishEvent !== false,
|
||||
revealPublishBlockIds: new Set([id]),
|
||||
revealSpreadSourceOverride: spanningPreview ? detail.previewSpreads : null
|
||||
});
|
||||
} finally {
|
||||
this.revealSpreadSourceOverride = previousOverride;
|
||||
}
|
||||
if (!spanningPreview) this.preloadAdditionalRevealSpreads(id, spread);
|
||||
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 = '') {
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
// OffscreenCanvas page rasterizer. Runs off the main thread so the heavy page text drawing
|
||||
// (the bulk of drawSpread cost) never blocks the render loop or UI. The main thread sends a
|
||||
// draw job (line records + metrics + page meta + title data + preloaded image bitmaps) and
|
||||
// receives back a full-page ImageBitmap and a background-only base ImageBitmap per side; the
|
||||
// main thread blits those onto its existing page canvases, leaving the texture/reveal pipeline
|
||||
// unchanged. This is the single rasterization implementation — the main thread no longer draws
|
||||
// page text itself.
|
||||
|
||||
let fontsReady = null;
|
||||
const imageCache = new Map(); // src -> ImageBitmap | null
|
||||
const surfaces = {}; // side -> { canvas, ctx }
|
||||
|
||||
function 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, '/')}`;
|
||||
}
|
||||
|
||||
async function ensureImages(srcs = []) {
|
||||
await Promise.all(srcs.map(async (src) => {
|
||||
if (!src || imageCache.has(src)) return;
|
||||
try {
|
||||
const response = await fetch(src);
|
||||
const blob = await response.blob();
|
||||
imageCache.set(src, await createImageBitmap(blob));
|
||||
} catch (error) {
|
||||
imageCache.set(src, null);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function ensureFonts() {
|
||||
if (fontsReady) return fontsReady;
|
||||
if (typeof FontFace === 'undefined' || !self.fonts) {
|
||||
fontsReady = Promise.resolve();
|
||||
return fontsReady;
|
||||
}
|
||||
const faces = [
|
||||
new FontFace('EB Garamond', 'url(/fonts/EBGaramond12-Regular.otf)', { style: 'normal', weight: '400' }),
|
||||
new FontFace('EB Garamond', 'url(/fonts/EBGaramond12-Italic.otf)', { style: 'italic', weight: '400' }),
|
||||
new FontFace('EB Garamond 12', 'url(/fonts/EBGaramond12/webfonts/EBGaramond-Regular.woff2)', {}),
|
||||
new FontFace('EB Garamond Initials', 'url(/fonts/EB-Garamond-Initials/EBGaramond-0.016/otf/EBGaramond-Initials.otf)', {})
|
||||
];
|
||||
fontsReady = Promise.all(faces.map(face => face.load()
|
||||
.then(loaded => { self.fonts.add(loaded); })
|
||||
.catch(() => {})));
|
||||
return fontsReady;
|
||||
}
|
||||
|
||||
function getSurface(width, height) {
|
||||
if (!surfaces.shared) {
|
||||
surfaces.shared = { canvas: new OffscreenCanvas(width, height) };
|
||||
surfaces.shared.ctx = surfaces.shared.canvas.getContext('2d');
|
||||
}
|
||||
const surface = surfaces.shared;
|
||||
if (surface.canvas.width !== width) surface.canvas.width = width;
|
||||
if (surface.canvas.height !== height) surface.canvas.height = height;
|
||||
return surface;
|
||||
}
|
||||
|
||||
function getPageContent(metrics, side) {
|
||||
return metrics?.contentBySide?.[side] || metrics?.content || {
|
||||
x: 0, y: 0, width: metrics?.width || 1, height: metrics?.height || 1
|
||||
};
|
||||
}
|
||||
|
||||
function 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;
|
||||
}
|
||||
|
||||
// DOM-free inline-tag parser (the main-thread renderer used document.createElement; a worker
|
||||
// has no DOM, so parse the tag string directly).
|
||||
function updateInlineStyleState(stack = [], value = '') {
|
||||
const text = String(value || '');
|
||||
if (!text.startsWith('<')) return stack;
|
||||
if (text.startsWith('</')) {
|
||||
if (stack.length) stack.pop();
|
||||
return stack;
|
||||
}
|
||||
const tagMatch = text.match(/^<\s*([a-zA-Z0-9]+)/);
|
||||
if (!tagMatch) return stack;
|
||||
const tagName = tagMatch[1].toLowerCase();
|
||||
const style = (text.match(/style\s*=\s*"([^"]*)"/i)?.[1] || '').toLowerCase();
|
||||
const className = (text.match(/class\s*=\s*"([^"]*)"/i)?.[1] || '').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;
|
||||
}
|
||||
|
||||
function getCanvasFont(metrics, fontPx, smallCaps, style) {
|
||||
return [
|
||||
style.italic ? 'italic' : '',
|
||||
smallCaps ? 'small-caps' : '',
|
||||
style.bold ? '700' : '',
|
||||
`${fontPx}px`,
|
||||
metrics.typography.fontFamily
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function applyTextStyle(ctx, metrics, fontPx, smallCaps, style) {
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
||||
ctx.font = getCanvasFont(metrics, fontPx, smallCaps, style);
|
||||
}
|
||||
|
||||
function buildLineSegments(ctx, nodes, line, ratio, 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 = 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') {
|
||||
updateInlineStyleState(styleStack, node.value);
|
||||
}
|
||||
});
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function drawLine(ctx, metrics, lineRecord, side) {
|
||||
const content = getPageContent(metrics, 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);
|
||||
const x = content.x + centerOffset;
|
||||
const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps);
|
||||
const baseStyle = 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';
|
||||
applyTextStyle(ctx, metrics, 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';
|
||||
applyTextStyle(ctx, metrics, fontPx, smallCaps, baseStyle);
|
||||
}
|
||||
buildLineSegments(ctx, nodes, line, ratio, baseStyle).forEach((segment) => {
|
||||
applyTextStyle(ctx, metrics, fontPx, smallCaps, segment.style || {});
|
||||
ctx.fillText(segment.value || '', x + segment.x, baseY);
|
||||
});
|
||||
}
|
||||
|
||||
function drawImageFitted(ctx, bitmap, x, y, width, height) {
|
||||
const sourceWidth = bitmap.width || 1;
|
||||
const sourceHeight = bitmap.height || 1;
|
||||
const sourceAspect = sourceWidth / sourceHeight;
|
||||
const targetAspect = width / height;
|
||||
let sx = 0, sy = 0, sw = sourceWidth, 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(bitmap, sx, sy, sw, sh, x, y, width, height);
|
||||
}
|
||||
|
||||
function drawImageRecord(ctx, metrics, lineRecord, side) {
|
||||
const content = getPageContent(metrics, 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 || metrics.typographyLineHeightPx));
|
||||
const bitmap = imageCache.get(resolveImageSource(lineRecord.metadata || {}));
|
||||
if (!bitmap) return;
|
||||
ctx.save();
|
||||
drawImageFitted(ctx, bitmap, x, y, width, height);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPageBase(ctx, side, width, height) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.fillStyle = '#f2ead0';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
const shade = ctx.createLinearGradient(0, 0, 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, width, height);
|
||||
}
|
||||
|
||||
function drawTitlePage(ctx, metrics, side, titleData) {
|
||||
if (!titleData) return;
|
||||
const content = getPageContent(metrics, side);
|
||||
const centerX = content.x + content.width * 0.5;
|
||||
const font = 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 (titleData.author) {
|
||||
ctx.font = `italic ${Math.round(metrics.bodyFontSizePx * 0.86)}px ${font}`;
|
||||
ctx.fillText(titleData.author, centerX, content.y + content.height * 0.18);
|
||||
}
|
||||
if (titleData.title) {
|
||||
ctx.font = `${Math.round(metrics.bodyFontSizePx * 1.55)}px ${font}`;
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'small-caps';
|
||||
ctx.fillText(titleData.title, centerX, content.y + content.height * 0.28);
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
||||
}
|
||||
if (titleData.subtitle) {
|
||||
ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.94)}px ${font}`;
|
||||
ctx.fillText(titleData.subtitle, centerX, content.y + content.height * 0.39);
|
||||
}
|
||||
if (titleData.ornament) {
|
||||
ctx.font = `${Math.round(metrics.bodyFontSizePx * 1.3)}px ${font}`;
|
||||
ctx.fillText(titleData.ornament, centerX, content.y + content.height * 0.52);
|
||||
}
|
||||
if (titleData.legal) {
|
||||
ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.62)}px ${font}`;
|
||||
ctx.fillText(titleData.legal, centerX, content.y + content.height * 0.96);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPageNumber(ctx, metrics, side, meta) {
|
||||
if (!meta || meta.omitPageNumber || meta.pageNumber == null) return;
|
||||
const content = getPageContent(metrics, side);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(31, 19, 10, 0.74)';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.68)}px ${metrics.typography.fontFamily}`;
|
||||
ctx.fillText(String(meta.pageNumber), content.x + content.width * 0.5, content.y + content.height + metrics.margins.bottom * 0.48);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPageLines(ctx, metrics, side, lines) {
|
||||
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';
|
||||
(Array.isArray(lines) ? lines : []).forEach(line => {
|
||||
if (line?.type === 'image' || line?.kind === 'image') drawImageRecord(ctx, metrics, line, side);
|
||||
else drawLine(ctx, metrics, line, side);
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
async function renderSide(job, side) {
|
||||
const { metrics, width, height } = job;
|
||||
const surface = getSurface(width, height);
|
||||
const ctx = surface.ctx;
|
||||
const meta = job.pageMeta?.[side] || null;
|
||||
|
||||
drawPageBase(ctx, side, width, height);
|
||||
let baseBitmap = null;
|
||||
if (job.hasReveal) baseBitmap = await createImageBitmap(surface.canvas);
|
||||
if (meta?.kind === 'title') drawTitlePage(ctx, metrics, side, job.titleData);
|
||||
drawPageLines(ctx, metrics, side, job.spreads?.[side] || []);
|
||||
drawPageNumber(ctx, metrics, side, meta);
|
||||
const pageBitmap = await createImageBitmap(surface.canvas);
|
||||
return { pageBitmap, baseBitmap };
|
||||
}
|
||||
|
||||
function collectImageSources(job) {
|
||||
const srcs = new Set();
|
||||
(job.sides || ['left', 'right']).forEach((side) => {
|
||||
(job.spreads?.[side] || []).forEach((line) => {
|
||||
if (line?.type === 'image' || line?.kind === 'image') {
|
||||
const src = resolveImageSource(line.metadata || {});
|
||||
if (src) srcs.add(src);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(srcs);
|
||||
}
|
||||
|
||||
async function handleDraw(job) {
|
||||
await ensureFonts();
|
||||
await ensureImages(collectImageSources(job));
|
||||
const results = {};
|
||||
const transfer = [];
|
||||
for (const side of (job.sides || ['left', 'right'])) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { pageBitmap, baseBitmap } = await renderSide(job, side);
|
||||
results[side] = { pageBitmap, baseBitmap };
|
||||
transfer.push(pageBitmap);
|
||||
if (baseBitmap) transfer.push(baseBitmap);
|
||||
}
|
||||
self.postMessage({ type: 'drawn', requestId: job.requestId, results }, transfer);
|
||||
}
|
||||
|
||||
self.onmessage = (event) => {
|
||||
const data = event.data || {};
|
||||
if (data.type === 'draw') handleDraw(data);
|
||||
else if (data.type === 'warm-fonts') ensureFonts().then(() => self.postMessage({ type: 'fonts-ready' }));
|
||||
};
|
||||
@@ -37,6 +37,8 @@ const bookPlaybackTimelinePath = path.join(__dirname, '..', 'public', 'js', 'boo
|
||||
const bookPlaybackTimelineSource = fs.readFileSync(bookPlaybackTimelinePath, 'utf8');
|
||||
const ttsFactoryPath = path.join(__dirname, '..', 'public', 'js', 'tts-factory-module.js');
|
||||
const ttsFactorySource = fs.readFileSync(ttsFactoryPath, 'utf8');
|
||||
const textureWorkerPath = path.join(__dirname, '..', 'public', 'js', 'book-texture-worker.js');
|
||||
const textureWorkerSource = fs.readFileSync(textureWorkerPath, 'utf8');
|
||||
|
||||
function dependencyList(source, moduleId) {
|
||||
const classStart = source.indexOf(`super('${moduleId}'`);
|
||||
@@ -160,7 +162,8 @@ const checks = [
|
||||
['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)],
|
||||
['sentence queue skips duplicate current-item 3D book presentation when reveal is cached', /isWebGLBookPresentationPrepared/.test(sentenceQueueSource) && /if \(!this\.isWebGLBookPresentationPrepared\(sentence\)\) \{\s*await this\.prefetchWebGLBookPresentation/.test(sentenceQueueSource) && /sentence\.webglBookPresentation = \{\s*prepared: true/.test(sentenceQueueSource)],
|
||||
['3D overflow reveal commits the spread then requests a timeline flip via event before activating', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.commitSegmentSpread\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /dispatchEvent\(new CustomEvent\('webgl-book:request-page-flip'/.test(bookPlaybackTimelineSource) && /addEventListener\('webgl-book:request-page-flip'/.test(source) && /startPageFlip\(direction, \{/.test(source)],
|
||||
['texture renderer paints inline bold and italic styles', /getInlineStyleState/.test(textureRendererSource) && /updateInlineStyleState/.test(textureRendererSource) && /getCanvasFont/.test(textureRendererSource) && /segment\?\.style/.test(textureRendererSource)],
|
||||
['texture worker paints inline bold and italic styles off the main thread', /getInlineStyleState/.test(textureWorkerSource) && /updateInlineStyleState/.test(textureWorkerSource) && /getCanvasFont/.test(textureWorkerSource) && /segment\.style/.test(textureWorkerSource) && !/drawLine\(ctx/.test(textureRendererSource)],
|
||||
['texture renderer delegates page rasterization to an OffscreenCanvas worker and blits the result', /book-texture-worker\.js/.test(textureRendererSource) && /rasterizeSpread/.test(textureRendererSource) && /ctx\.drawImage\(result\.pageBitmap, 0, 0\)/.test(textureRendererSource) && /OffscreenCanvas/.test(textureWorkerSource) && /createImageBitmap/.test(textureWorkerSource)],
|
||||
['webgl lab can preload page textures without swapping visible page material through texture store', /preparePageTexture\(side = 'left'/.test(webglPageCacheSource) && /takePreparedPageTexture\(side = 'left'/.test(webglPageCacheSource) && /renderer\.initTexture\(texture\)/.test(webglPageCacheSource) && /takePreparedPageTexture/.test(source) && !/const preparedPageTextures/.test(source)],
|
||||
['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
|
||||
['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)],
|
||||
@@ -181,12 +184,12 @@ const checks = [
|
||||
['pagination normalizes every spread to explicit left and right page records', /normalizePagesForSpreads/.test(bookPaginationSource) && /const lastSpreadRightIndex/.test(bookPaginationSource) && /this\.createBlankPage\(index/.test(bookPaginationSource) && /normalizedPages\.forEach/.test(bookPaginationSource)],
|
||||
['texture renderer adopts initial pagination spread so title page is painted after loader order', /this\.currentSpread = this\.pagination\?\.getCurrentSpread\?\.\(\) \|\| \{ index: 0/.test(textureRendererSource) && /this\.drawSpread\(this\.currentSpread\);/.test(textureRendererSource)],
|
||||
['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)],
|
||||
['texture renderer draws title page and page numbers from versioned page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)],
|
||||
['texture renderer uses plural page margin metrics for page numbers', /this\.metrics\.margins\.bottom/.test(textureRendererSource) && !/this\.metrics\.margin\.bottom/.test(textureRendererSource)],
|
||||
['texture worker draws title page and page numbers; renderer marshals title data and versioned page metadata', /drawTitlePage/.test(textureWorkerSource) && /drawPageNumber/.test(textureWorkerSource) && /game_title/.test(textureRendererSource) && /buildTitleData/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)],
|
||||
['texture worker uses plural page margin metrics for page numbers', /metrics\.margins\.bottom/.test(textureWorkerSource) && !/metrics\.margin\.bottom/.test(textureWorkerSource)],
|
||||
['webgl flip assigns explicit source and back page textures before animation starts', /resolveCurrentFlipSourceTexture\(sourceSide\)/.test(source) && /const targetBackSide = flip\.direction > 0 \? 'left' : 'right'/.test(source) && /const targetBackPageMeta = getPaginationPageMeta\(targetBackPageIndex\) \|\| makeBlankPageMeta\(targetBackPageIndex\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source)],
|
||||
['webgl flip never falls back to the opposite visible stack for target back texture', /function resolveFlipBackTexture\(pageMeta = null, prewarmedTexture = null\)/.test(source) && source.includes('return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);') && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
|
||||
['webgl page texture record metadata normalizes omitted or null sides into explicit blank pages', /function normalizePageMetaPair/.test(source) && /function makeBlankPageMeta/.test(source) && /applyExplicitBlankPageTexture/.test(source) && /normalizePageTextureRecordDetail/.test(source) && !/hasLeftMeta/.test(methodBody(source, 'handlePageTextureRecords'))],
|
||||
['texture renderer publishes both spread sides for reveal preparation', /const sides = \['left', 'right'\]/.test(textureRendererSource) && /published = this\.drawSpread\(spread, sides/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)],
|
||||
['texture renderer publishes both spread sides for reveal preparation', /const sides = \['left', 'right'\]/.test(textureRendererSource) && /published = await this\.drawSpread\(spread, sides/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)],
|
||||
['texture renderer marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)],
|
||||
['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(webglPageCacheSource)],
|
||||
['webgl texture store resident cache reuses newest page version for older readiness requests', /isOlderPageMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta/.test(webglPageCacheSource) && /if \(!resident\) return null/.test(webglPageCacheSource) && !/if \(!resident \|\| this\.isOlderPageMeta\(pageMeta, resident\.pageMeta\)\) return null/.test(webglPageCacheSource)],
|
||||
@@ -226,7 +229,7 @@ const checks = [
|
||||
['texture renderer derives reveal regions from the just-drawn preview spread before committed pagination', /if \(this\.currentSpread\) sourceSpreads\.push\(this\.currentSpread\)/.test(textureRendererSource) && /paginationSpreads\.forEach/.test(textureRendererSource) && /Number\(spread\.index\) === Number\(this\.currentSpread\.index\)/.test(textureRendererSource)],
|
||||
['texture renderer prepares a spanning block continuation spread in the background and reuses it (no synchronous redraw on the critical path)', /revealSpreadSourceOverride/.test(textureRendererSource) && /prepareContinuationRevealPlan/.test(textureRendererSource) && /takeContinuationRevealPlan/.test(textureRendererSource) && /`\$\{id\}:cont`/.test(textureRendererSource) && /prepareContinuationRevealPlan/.test(bookPlaybackTimelineSource) && /takeContinuationRevealPlan/.test(bookPlaybackTimelineSource) && /previewSpreads/.test(bookPaginationSource)],
|
||||
['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource)],
|
||||
['book playback timeline has one prepare path: spanning-aware plan reused at activate and continuation, no fallback', /this\.revealSpreadSourceOverride = detail\.previewSpreads/.test(textureRendererSource) && !/forceRebuild/.test(textureRendererSource) && !/forceRebuild/.test(bookPlaybackTimelineSource) && !/spanningPlanPrepared/.test(bookPlaybackTimelineSource) && /const texturePlan = this\.textureRenderer\.takeContinuationRevealPlan\(segment\.blockId, spread\.index\)/.test(bookPlaybackTimelineSource)],
|
||||
['book playback timeline has one prepare path: spanning-aware plan reused at activate and continuation, no fallback', /revealSpreadSourceOverride: spanningPreview \? detail\.previewSpreads : null/.test(textureRendererSource) && /this\.revealSpreadSourceOverride = options\.revealSpreadSourceOverride/.test(textureRendererSource) && !/forceRebuild/.test(textureRendererSource) && !/forceRebuild/.test(bookPlaybackTimelineSource) && !/spanningPlanPrepared/.test(bookPlaybackTimelineSource) && /const texturePlan = this\.textureRenderer\.takeContinuationRevealPlan\(segment\.blockId, spread\.index\)/.test(bookPlaybackTimelineSource)],
|
||||
['webgl visible spread is owned by scene flips, not pagination publishes', /spreadUpdate:state-only/.test(source) && /webglBookPlaybackActive/.test(source) && /spreadUpdate:jump/.test(source) && /window\.BookTextureRenderer\?\.drawSpread\?\.\(spread, \['left', 'right'\], \{ force: true \}\)/.test(source)],
|
||||
['3D overflow reveal preloads target spread before forced page flip', /createRevealDetail\(sentence, previewSpread, 'prepare'\)/.test(bookPlaybackTimelineSource) && /this\.textureRenderer\.prepareRevealBlock\(\s*[\s\S]*revealDetail[\s\S]*phase: 'prepare'[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /this\.assertSegmentReady\(segment, 'prepare'\)/.test(bookPlaybackTimelineSource) && /reason: 'timeline-preplay-spread-transition'/.test(bookPlaybackTimelineSource)],
|
||||
['book playback timeline is loaded through module infrastructure', /book-playback-timeline-module\.js/.test(loaderSource) && /super\('book-playback-timeline'/.test(bookPlaybackTimelineSource) && /reportProgress\(100, 'Book playback timeline ready'\)/.test(bookPlaybackTimelineSource)],
|
||||
|
||||
Reference in New Issue
Block a user