/** * Book Texture Renderer Module * Draws the virtual book pages directly into texture-space canvases. */ import { BaseModule } from './base-module.js'; class BookTextureRendererModule extends BaseModule { constructor() { super('book-texture-renderer', 'Book Texture Renderer'); this.dependencies = ['book-page-format', 'book-pagination', 'localization']; this.pageFormat = null; this.pagination = null; this.localization = null; this.metrics = null; this.canvases = { left: null, right: null }; this.contexts = { left: null, right: null }; this.hitMaps = { left: [], right: [] }; this.currentSpread = null; this.activeAnimations = new Map(); this.revealedBlockIds = new Set(); this.animationFrameId = null; this.lastAnimationFrameAt = 0; this.targetFrameDurationMs = 1000 / 30; this.bindMethods([ 'initialize', 'createPageCanvases', 'drawEmptySpread', 'drawSpread', 'drawPageBase', 'drawPageLines', 'drawLine', 'drawWord', 'getPageContent', 'buildLineSegments', 'startRevealAnimation', 'fastForwardAnimations', 'stopAnimations', 'getBlockSides', 'getAnimatedSides', 'markPendingReveal', 'requestAnimationFrame', 'tickAnimations', 'publishSpread', 'getPageCanvas', 'getHitMap', 'handlePageCountChanged', 'handleSceneReady' ]); } async initialize() { this.pageFormat = this.getModule('book-page-format'); this.pagination = this.getModule('book-pagination'); this.localization = this.getModule('localization'); this.reportProgress(20, 'Preparing page texture canvases'); this.createPageCanvases(); this.drawEmptySpread(); this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged); this.addEventListener(document, 'webgl-book:scene-ready', this.handleSceneReady); this.addEventListener(document, 'book-pagination:spread-updated', (event) => { const latestBlockId = event.detail?.latestBlockId; const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0)); if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) this.markPendingReveal(latestBlockId); this.drawSpread(event.detail?.spread || this.pagination?.getCurrentSpread?.()); }); this.addEventListener(document, 'book-texture:reveal-block', (event) => { this.startRevealAnimation(event.detail || {}); }); this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations); this.addEventListener(document, 'ui:command', (event) => { if (event.detail?.type === 'continue') this.fastForwardAnimations(); }); this.addEventListener(document, 'story:manual-scroll', this.fastForwardAnimations); this.addEventListener(document, 'story:history-restoring', this.stopAnimations); this.reportProgress(100, 'Book texture renderer ready'); return true; } createPageCanvases(textureWidth = this.pageFormat?.getTextureWidth?.() || 3072) { this.metrics = this.pageFormat.getTextureMetrics(textureWidth); ['left', 'right'].forEach((side) => { const canvas = document.createElement('canvas'); canvas.width = this.metrics.width; canvas.height = this.metrics.height; this.canvases[side] = canvas; this.contexts[side] = canvas.getContext('2d'); }); } drawEmptySpread() { this.drawPageBase('left'); this.drawPageBase('right'); this.publishSpread(); } drawSpread(spread = null, sides = null) { this.currentSpread = spread || { left: [], right: [] }; const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; sidesToDraw.forEach((side) => { if (!this.canvases[side]) return; this.drawPageBase(side); this.drawPageLines(side, this.currentSpread?.[side] || []); }); this.publishSpread(sidesToDraw); } 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 = '#fff7dc'; 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.10)'); shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)'); shade.addColorStop(1, 'rgba(82, 42, 14, 0.16)'); } else { shade.addColorStop(0, 'rgba(82, 42, 14, 0.16)'); shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)'); shade.addColorStop(1, 'rgba(255, 255, 255, 0.10)'); } ctx.fillStyle = shade; ctx.fillRect(0, 0, canvas.width, canvas.height); this.hitMaps[side] = []; } 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 => this.drawLine(ctx, line, side)); ctx.restore(); } 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 fontStyle = lineRecord.fontStyle === 'italic' ? 'italic ' : ''; 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; if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal'; if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px'; ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`; if (lineRecord.dropCapText) { ctx.save(); const alpha = this.getWordAlpha(lineRecord, 0); if (alpha <= 0) { ctx.restore(); } else { ctx.globalAlpha *= alpha; ctx.font = `${Math.round(fontPx * 2.68)}px "EB Garamond Initials", ${metrics.typography.fontFamily}`; ctx.textBaseline = 'top'; ctx.fillText( String(lineRecord.dropCapText), content.x, content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25) ); 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'; ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`; } this.buildLineSegments(ctx, nodes, line, ratio).forEach((segment) => { this.drawWord(ctx, segment.value, x + segment.x, baseY, lineRecord, segment.wordIndex); }); if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal'; if ('letterSpacing' in ctx) ctx.letterSpacing = previousLetterSpacing || '0px'; } getPageContent(side = 'left') { return this.metrics?.contentBySide?.[side] || this.metrics?.content || { x: 0, y: 0, width: this.metrics?.width || 1, height: this.metrics?.height || 1 }; } buildLineSegments(ctx, nodes = [], line = {}, ratio = 0) { const segments = []; let x = 0; let currentSegment = null; let previousWasGlue = true; 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); if (currentSegment && !previousWasGlue) { currentSegment.value += value; currentSegment.width += width; } else { currentSegment = { value, x, width, wordIndex: segments.length }; 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; } }); return segments; } drawWord(ctx, value, x, baseY, lineRecord, localWordIndex) { const alpha = this.getWordAlpha(lineRecord, localWordIndex); if (alpha <= 0) return; const previousAlpha = ctx.globalAlpha; ctx.globalAlpha = previousAlpha * alpha; ctx.fillText(value, x, baseY); ctx.globalAlpha = previousAlpha; } getWordAlpha(lineRecord, localWordIndex) { const animation = this.activeAnimations.get(String(lineRecord.blockId ?? '')); if (!animation) { return 1; } const globalWordIndex = Number(lineRecord.blockWordStart || 0) + localWordIndex; const timing = animation.wordTimings[globalWordIndex]; if (!timing) { return animation.completed ? 1 : 0; } const elapsed = animation.completed ? Number.POSITIVE_INFINITY : performance.now() - animation.startedAt; const duration = Math.max(1, Number(timing.duration || 1)); const progress = Math.max(0, Math.min(1, (elapsed - Number(timing.delay || 0)) / duration)); return progress; } startRevealAnimation(detail = {}) { const blockId = detail.blockId ?? detail.id ?? null; if (blockId == null || !Array.isArray(detail.wordTimings)) return; this.activeAnimations.set(String(blockId), { blockId, wordTimings: detail.wordTimings, startedAt: performance.now(), completed: false }); this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId)); this.requestAnimationFrame(); } fastForwardAnimations() { let changed = false; this.activeAnimations.forEach((animation) => { if (!animation.completed) { animation.completed = true; this.revealedBlockIds.add(String(animation.blockId ?? '')); changed = true; } }); if (changed) { this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getAnimatedSides(true)); } } stopAnimations() { this.activeAnimations.clear(); if (this.animationFrameId) { clearTimeout(this.animationFrameId); this.animationFrameId = null; } this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); } getBlockSides(blockId) { const id = String(blockId ?? ''); const spread = this.currentSpread || this.pagination?.getCurrentSpread?.() || { left: [], right: [] }; return ['left', 'right'].filter((side) => { const lines = Array.isArray(spread?.[side]) ? spread[side] : []; return lines.some(line => String(line?.blockId ?? '') === id); }); } getAnimatedSides(includeCompleted = false) { const spread = this.currentSpread || this.pagination?.getCurrentSpread?.() || { left: [], right: [] }; const activeBlockIds = new Set(); this.activeAnimations.forEach((animation, blockId) => { if (includeCompleted || !animation.completed) activeBlockIds.add(String(blockId)); }); const sides = ['left', 'right'].filter((side) => { const lines = Array.isArray(spread?.[side]) ? spread[side] : []; return lines.some(line => activeBlockIds.has(String(line?.blockId ?? ''))); }); return sides.length ? sides : ['left', 'right']; } markPendingReveal(blockId) { const id = String(blockId ?? ''); if (!id || this.activeAnimations.has(id) || this.revealedBlockIds.has(id)) return; this.activeAnimations.set(id, { blockId, wordTimings: [], startedAt: performance.now(), completed: false }); } requestAnimationFrame() { if (this.animationFrameId) return; this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs); } tickAnimations(now) { this.animationFrameId = null; if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) { this.requestAnimationFrame(); return; } this.lastAnimationFrameAt = now; let hasActive = false; const currentNow = performance.now(); this.activeAnimations.forEach((animation) => { if (animation.completed) return; if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return; const lastTiming = animation.wordTimings.at(-1); const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0); if (currentNow - animation.startedAt >= total + 50) { animation.completed = true; this.revealedBlockIds.add(String(animation.blockId ?? '')); } else { hasActive = true; } }); this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getAnimatedSides(true)); if (hasActive) this.requestAnimationFrame(); } publishSpread(sides = null) { const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; const detail = { metrics: this.metrics, hitMaps: this.hitMaps }; if (sidesToPublish.includes('left')) detail.left = this.canvases.left; if (sidesToPublish.includes('right')) detail.right = this.canvases.right; document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { detail })); } getPageCanvas(side) { return this.canvases[side] || null; } getHitMap(side) { return this.hitMaps[side] || []; } handlePageCountChanged(event) { this.pageFormat?.setPageCount?.(event.detail?.pageCount); this.createPageCanvases(); this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); } handleSceneReady() { this.publishSpread(); } } const bookTextureRenderer = new BookTextureRendererModule(); export { bookTextureRenderer as BookTextureRenderer }; if (window.moduleRegistry) { window.moduleRegistry.register(bookTextureRenderer); } window.BookTextureRenderer = bookTextureRenderer;