// 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 } // The reveal "base" layer is the plain paper background (drawPageBase) — identical for every // page of a side at a given size. Send its bitmap only once per side+size; the main thread // caches and reuses it, avoiding a large per-block ImageBitmap allocation (GC churn). const sentBaseKeys = new Set(); 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(' ({ ...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; const baseKey = `${side}:${width}x${height}`; if (job.hasReveal && !sentBaseKeys.has(baseKey)) { baseBitmap = await createImageBitmap(surface.canvas); sentBaseKeys.add(baseKey); } 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' })); };