/** * 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.bindMethods([ 'initialize', 'createPageCanvases', 'drawEmptySpread', 'drawSpread', 'drawPageBase', 'drawPageLines', 'drawLine', 'publishSpread', 'getPageCanvas', 'getHitMap', '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:scene-ready', this.handleSceneReady); this.addEventListener(document, 'book-pagination:spread-updated', (event) => { this.drawSpread(event.detail?.spread || this.pagination?.getCurrentSpread?.()); }); this.reportProgress(100, 'Book texture renderer ready'); return true; } createPageCanvases(textureWidth = 1280) { 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) { this.drawPageBase('left'); this.drawPageBase('right'); this.drawPageLines('left', spread?.left || []); this.drawPageLines('right', spread?.right || []); this.publishSpread(); } 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'; lines.forEach(line => this.drawLine(ctx, line)); ctx.restore(); } drawLine(ctx, lineRecord = {}) { const metrics = this.metrics; 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 = metrics.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, (metrics.content.width - naturalWidth) / 2) : Number(line.offset || 0); let x = metrics.content.x + centerOffset; ctx.font = `${fontPx}px ${metrics.typography.fontFamily}`; nodes.forEach((node, index) => { if (!node) return; if (node.type === 'box' && node.value) { const nextNode = nodes[index + 1]; const value = `${node.value}${nextNode?.type === 'penalty' && nextNode.penalty === 100 ? '-' : ''}`; ctx.fillText(value, x, baseY); x += Number(node.width || ctx.measureText(value).width || 0); } 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; } }); } publishSpread() { document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { detail: { left: this.canvases.left, right: this.canvases.right, metrics: this.metrics, hitMaps: this.hitMaps } })); } getPageCanvas(side) { return this.canvases[side] || null; } getHitMap(side) { return this.hitMaps[side] || []; } handleSceneReady() { this.publishSpread(); } } const bookTextureRenderer = new BookTextureRendererModule(); export { bookTextureRenderer as BookTextureRenderer }; if (window.moduleRegistry) { window.moduleRegistry.register(bookTextureRenderer); } window.BookTextureRenderer = bookTextureRenderer;