0e4d9e89d7
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>
363 lines
15 KiB
JavaScript
363 lines
15 KiB
JavaScript
// 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' }));
|
|
};
|