/** * 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', 'game-config', 'webgl-page-cache']; this.pageFormat = null; this.pagination = null; this.localization = null; this.gameConfig = null; this.pageCache = 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.revealBaseCanvases = null; this.revealPublishBlockIds = null; this.lastDrawSignature = null; this.lastDrawSkipLoggedAt = 0; this.pipelineTimings = []; this.imageCache = new Map(); this.pageContentVersions = new Map(); this.bindMethods([ 'initialize', 'markPipelineTiming', 'waitForTextureFonts', 'ensureTextureFontFace', 'createPageCanvases', 'drawSpread', 'getDrawSignature', 'cloneCanvas', 'drawPageBase', 'drawPageMeta', 'drawTitlePage', 'drawPageNumber', 'drawPageLines', 'drawImageRecord', 'resolveImageSource', 'getCachedImage', 'drawImageFitted', 'drawLine', 'drawWord', 'buildRevealRegions', 'shouldFlipAfterSideReveal', 'collectRevealRegionCandidates', 'createRevealRegionForLine', 'assignRevealTiming', 'getLineInkRect', 'getLineNaturalWidth', 'getLineWordCount', 'getImageRevealDurationMs', 'getInlineStyleState', 'updateInlineStyleState', 'getCanvasFont', 'applyTextStyle', 'getPageContent', 'buildLineSegments', 'prepareRevealBlock', 'preloadAdditionalRevealSpreads', 'spreadContainsBlock', 'createAnimationState', 'getDrawPhase', 'publishPreparedReveal', 'startPreparedRevealAnimation', 'fastForwardAnimations', 'stopAnimations', 'getBlockSides', 'getAnimatedSides', 'publishSpread', 'buildPageTextureRecords', 'cachePublishedPages', 'getPageCanvas', 'getHitMap', 'handlePageCountChanged' ]); } async initialize() { this.pageFormat = this.getModule('book-page-format'); this.pagination = this.getModule('book-pagination'); this.localization = this.getModule('localization'); this.gameConfig = this.getModule('game-config'); this.pageCache = this.getModule('webgl-page-cache'); window.BookTextureRendererDebug = { pipelineTimings: this.pipelineTimings }; this.reportProgress(10, 'Waiting for book fonts'); await this.waitForTextureFonts(); this.reportProgress(20, 'Preparing page texture canvases'); this.createPageCanvases(); 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) // drives every draw explicitly. See docs/webgl-3d-ui-spec.md "Single ownership". this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations); this.addEventListener(document, 'webgl-book:reveal-committed', (event) => { this.completeRevealBlockIds(event.detail?.blockIds || []); }); 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.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); this.reportProgress(100, 'Book texture renderer ready'); return true; } markPipelineTiming(name, detail = {}) { const entry = { name, at: performance.now(), detail }; this.pipelineTimings.push(entry); if (this.pipelineTimings.length > 120) this.pipelineTimings.splice(0, this.pipelineTimings.length - 120); document.documentElement.dataset.webglTexturePipeline = JSON.stringify(this.pipelineTimings); return entry; } async waitForTextureFonts() { if (!document.fonts) return; await Promise.all([ this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Regular.otf', { style: 'normal', weight: '400' }), this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Italic.otf', { style: 'italic', weight: '400' }), this.ensureTextureFontFace('EB Garamond 12', '/fonts/EBGaramond12/webfonts/EBGaramond-Regular.woff2'), this.ensureTextureFontFace('EB Garamond Initials', '/fonts/EB-Garamond-Initials/EBGaramond-0.016/otf/EBGaramond-Initials.otf') ]); await Promise.all([ document.fonts.load('24px "EB Garamond"'), document.fonts.load('italic 24px "EB Garamond"'), document.fonts.load('bold 24px "EB Garamond"'), document.fonts.load('italic bold 24px "EB Garamond"'), document.fonts.load('24px "EB Garamond 12"'), document.fonts.load('72px "EB Garamond Initials"') ]); await document.fonts.ready; } async ensureTextureFontFace(family, url, descriptors = {}) { if (!window.FontFace) return; const face = new FontFace(family, `url(${url})`, descriptors); const loadedFace = await face.load(); document.fonts.add(loadedFace); } 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'); }); } drawSpread(spread = null, sides = null, options = {}) { const previousSpread = this.currentSpread; this.currentSpread = spread || { left: [], right: [] }; const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0; const phase = this.getDrawPhase(options); const drawSignature = this.getDrawSignature(this.currentSpread, sidesToDraw); if (options.force !== true && phase !== 'prepare' && !hasReveal && drawSignature === this.lastDrawSignature) { const now = performance.now(); if (now - this.lastDrawSkipLoggedAt > 1000) { this.lastDrawSkipLoggedAt = now; this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw }); } if (phase === 'prepare') this.currentSpread = previousSpread; return null; } this.markPipelineTiming('drawSpread:start', { sides: sidesToDraw, revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : [], phase }); this.revealBaseCanvases = { left: null, right: null }; 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 published = this.publishSpread(sidesToDraw, options); this.markPipelineTiming('drawSpread:end', { sides: sidesToDraw, phase }); this.revealBaseCanvases = null; this.revealPublishBlockIds = null; if (phase !== 'prepare' && !hasReveal) this.lastDrawSignature = drawSignature; if (phase === 'prepare') this.currentSpread = previousSpread; return published; } getDrawPhase(options = {}) { if (options.phase === 'prepare' || options.phase === 'activate') return options.phase; return 'activate'; } getDrawSignature(spread = null, sides = []) { const source = spread || {}; return sides.map(side => { const lines = Array.isArray(source[side]) ? source[side] : []; const meta = source.pageMeta?.[side] || {}; const ids = lines.map(line => `${line.type || 'line'}:${line.blockId ?? ''}:${line.lineIndex ?? ''}:${line.pageLine ?? ''}:${line.lineCount ?? ''}:${line.line?.nodes?.length || 0}`).join(','); return `${side}:${meta.kind || ''}:${meta.pageIndex ?? ''}:${meta.pageNumber ?? ''}:${meta.omitPageNumber === true}[${ids}]`; }).join('|'); } cloneCanvas(canvas) { if (!canvas) return null; const clone = document.createElement('canvas'); clone.width = canvas.width; clone.height = canvas.height; const context = clone.getContext('2d'); if (context) context.drawImage(canvas, 0, 0); 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(' ({ ...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(); if (!candidates.length) return null; const byBlock = candidates.reduce((map, region) => { if (!map.has(region.blockId)) map.set(region.blockId, []); map.get(region.blockId).push(region); return map; }, new Map()); const regions = []; byBlock.forEach((blockRegions, blockId) => { const animation = this.activeAnimations.get(blockId); if (!animation || animation.completed) return; regions.push(...this.assignRevealTiming(blockRegions, animation)); }); const currentSpreadIndex = Math.max(0, Number(this.currentSpread?.index ?? this.pagination?.currentSpreadIndex ?? 0)); const sideRegions = regions.filter(region => region.side === side && Math.max(0, Number(region.spreadIndex || 0)) === currentSpreadIndex); if (!sideRegions.length) return null; const bounds = sideRegions.reduce((box, region) => ({ x: Math.min(box.x, region.pixelRect.x), y: Math.min(box.y, region.pixelRect.y), right: Math.max(box.right, region.pixelRect.right), bottom: Math.max(box.bottom, region.pixelRect.bottom) }), { x: this.metrics.width, y: this.metrics.height, right: 0, bottom: 0 }); return { blockIds: Array.from(byBlock.keys()), durationMs: sideRegions.reduce((maxDuration, region) => Math.max(maxDuration, region.timing.delay + region.timing.duration), 0), pageFlipAfterReveal: this.shouldFlipAfterSideReveal(side), baseCanvas: null, lineRects: sideRegions.map(region => ({ blockId: region.blockId, lineIndex: region.lineIndex, rect: region.rect, timing: region.timing, timingArea: region.timingArea || region.area || 0 })), bounds: { x: bounds.x / this.metrics.width, y: bounds.y / this.metrics.height, width: Math.max(0.001, (bounds.right - bounds.x) / this.metrics.width), height: Math.max(0.001, (bounds.bottom - bounds.y) / this.metrics.height) } }; } shouldFlipAfterSideReveal(side) { if (side !== 'right') return false; const meta = this.currentSpread?.pageMeta?.right || null; if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false; const rightLines = Array.isArray(this.currentSpread?.right) ? this.currentSpread.right : []; const maxLine = rightLines.reduce((max, line) => Math.max( max, Number(line?.pageLine || 0) + Math.max(1, Number(line?.lineCount || 1)) ), 0); const expectedLines = Math.max(1, Number(meta.linesPerPage || 25)); return maxLine >= expectedLines; } collectRevealRegionCandidates() { const candidates = []; const sourceSpreads = []; if (this.currentSpread) sourceSpreads.push(this.currentSpread); if (Array.isArray(this.pagination?.spreads)) { this.pagination.spreads.forEach((spread) => { if (!spread) return; if (this.currentSpread && Number(spread.index) === Number(this.currentSpread.index)) return; sourceSpreads.push(spread); }); } if (!sourceSpreads.length) sourceSpreads.push({ index: 0, left: [], right: [] }); sourceSpreads.forEach((spread) => { ['left', 'right'].forEach((side) => { const spreadLines = Array.isArray(spread?.[side]) ? spread[side] : []; spreadLines.forEach((lineRecord) => { const region = this.createRevealRegionForLine(side, lineRecord, spread?.index); if (region) candidates.push(region); }); }); }); return candidates; } assignRevealTiming(blockRegions = [], animation = {}) { const requestedTotalDuration = Math.max( Number(animation.totalDuration || 0), ...((Array.isArray(animation.wordTimings) ? animation.wordTimings : []).map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))) ); const sortedRegions = [...blockRegions].sort((a, b) => { const aSpread = Math.max(0, Number(a.spreadIndex || 0)); const bSpread = Math.max(0, Number(b.spreadIndex || 0)); if (aSpread !== bSpread) return aSpread - bSpread; const aLine = Math.max(0, Number(a.lineIndex || 0)); const bLine = Math.max(0, Number(b.lineIndex || 0)); return aLine - bLine; }); const timedRegions = []; const textRegions = sortedRegions.filter(region => !(region.fixedDurationMs > 0)); const fixedRegions = sortedRegions.filter(region => region.fixedDurationMs > 0); const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.timingArea || region.area), 0); const lineHeight = Math.max(1, Number(this.metrics?.typographyLineHeightPx || 1)); const estimatedTextWidth = totalArea / lineHeight; const baseDuration = requestedTotalDuration > 1 ? requestedTotalDuration : Math.max(800, estimatedTextWidth * 16); // Word-proportional scaling: these regions may cover only part of the block (the // rest is on another spread this reveal does not include). Reveal only this portion's // share of the block TTS, offset by the words before it, so the page reveals at // normal pace and flips when its words are spoken — the continuation then resumes on // the next spread instead of the page absorbing the whole TTS. When the regions cover // the whole block (unified plan or single-page block) this is a no-op. const totalBlockWords = Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0; const collectedWords = textRegions.reduce((sum, region) => sum + Math.max(0, Number(region.blockWordCount || 0)), 0); const wordsBefore = textRegions.reduce((min, region) => Math.min(min, Math.max(0, Number(region.blockWordStart || 0))), Number.POSITIVE_INFINITY); const useWordShare = totalBlockWords > 0 && collectedWords > 0 && collectedWords < totalBlockWords; const totalDuration = useWordShare ? baseDuration * (collectedWords / totalBlockWords) : baseDuration; let fallbackDelay = useWordShare && Number.isFinite(wordsBefore) ? baseDuration * (wordsBefore / totalBlockWords) : 0; textRegions.forEach((region) => { const duration = totalArea > 0 ? Math.max(1, totalDuration * (Math.max(1, region.timingArea || region.area) / totalArea)) : Math.max(1, totalDuration / Math.max(1, textRegions.length)); timedRegions.push({ ...region, timing: { delay: fallbackDelay, duration } }); fallbackDelay += duration; }); fixedRegions.forEach((region) => { timedRegions.push({ ...region, timing: { delay: fallbackDelay, duration: Math.max(1, region.fixedDurationMs) } }); fallbackDelay += Math.max(1, region.fixedDurationMs); }); return timedRegions.sort((a, b) => { const aDelay = Number(a.timing?.delay || 0); const bDelay = Number(b.timing?.delay || 0); if (aDelay !== bDelay) return aDelay - bDelay; return Number(a.lineIndex || 0) - Number(b.lineIndex || 0); }); } getLineTimingFromWords(region = {}, wordTimings = []) { const start = Math.max(0, Math.floor(Number(region.blockWordStart || 0))); const count = Math.max(1, Math.floor(Number(region.blockWordCount || 1))); const first = wordTimings[Math.min(start, wordTimings.length - 1)] || { delay: 0, duration: 1 }; const lastIndex = Math.min(wordTimings.length - 1, start + count - 1); const last = wordTimings[lastIndex] || first; const delay = Math.max(0, Number(first.delay || 0)); const end = Math.max( delay + 1, Number(last.delay || 0) + Math.max(1, Number(last.duration || 1)) ); return { delay, duration: Math.max(1, end - delay) }; } createRevealRegionForLine(side, lineRecord = {}, spreadIndex = null) { const blockId = String(lineRecord?.blockId ?? ''); if (!blockId || !this.revealPublishBlockIds.has(blockId)) return null; const animation = this.activeAnimations.get(blockId); if (!animation || animation.completed) return null; if (lineRecord.type === 'image' || lineRecord.kind === 'image') { const content = this.getPageContent(side); const rect = lineRecord.metadata?.imageLayout?.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)); return this.normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, this.getImageRevealDurationMs(lineRecord), spreadIndex); } const rect = this.getLineInkRect(side, lineRecord); if (!rect) return null; return this.normalizeRevealRegion(side, blockId, lineRecord, rect.x, rect.y, rect.width, rect.height, 0, spreadIndex); } normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, fixedDurationMs = 0, spreadIndex = null) { const padding = Math.max(2, Number(lineRecord.fontPx || 18) * 0.12); const left = Math.max(0, x - padding); const top = Math.max(0, y - padding); const right = Math.min(this.metrics.width, x + width + padding); const bottom = Math.min(this.metrics.height, y + height + padding); const rectWidth = Math.max(1, right - left); const rectHeight = Math.max(1, bottom - top); const timingWidth = Math.max(1, Number(lineRecord.timingWidthPx || width || rectWidth)); const timingHeight = Math.max(1, Number(lineRecord.timingHeightPx || height || rectHeight)); return { side, spreadIndex: Math.max(0, Number((spreadIndex ?? Math.floor(Number(lineRecord.pageIndex || 0) / 2)) || 0)), blockId, lineIndex: Number(lineRecord.lineIndex ?? lineRecord.pageLine ?? 0), blockWordStart: Number(lineRecord.blockWordStart ?? 0), blockWordCount: Number(lineRecord.lineWordCount ?? 0), fixedDurationMs, area: rectWidth * rectHeight, timingArea: timingWidth * timingHeight, pixelRect: { x: left, y: top, right, bottom }, rect: { x: left / this.metrics.width, y: top / this.metrics.height, width: Math.max(0.001, rectWidth / this.metrics.width), height: Math.max(0.001, rectHeight / this.metrics.height) } }; } getLineInkRect(side, lineRecord = {}) { const content = this.getPageContent(side); const fontPx = Math.max(1, Number(lineRecord.fontPx || 22)); const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || this.metrics.typographyLineHeightPx || 30)); const line = lineRecord.line || {}; const naturalWidth = this.getLineNaturalWidth(line); const centerOffset = line.align === 'center' ? Math.max(0, (content.width - naturalWidth) / 2) : Number(line.offset || 0); const measuredWidth = Number(line.measure || lineRecord.measure || 0); const isJustified = line.align !== 'center' && !line.isFinal; let x = content.x + centerOffset; let y = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx); let width = Math.max(1, Math.min(content.width - centerOffset, isJustified ? (measuredWidth || content.width - centerOffset) : (naturalWidth || measuredWidth || content.width - centerOffset))); let height = lineHeightPx; if (lineRecord.dropCapText) { const dropCapFontPx = Math.round(fontPx * 2.68); const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25); const dropCapWidth = fontPx * 2.9; const normalRight = x + width; x = Math.min(content.x, x); y = Math.min(y, dropCapY); width = Math.max(normalRight, content.x + dropCapWidth) - x; height = Math.max((content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx)) + lineHeightPx, dropCapY + (dropCapFontPx * 0.9)) - y; } return { x, y, width, height }; } getLineNaturalWidth(line = {}) { const nodes = Array.isArray(line.nodes) ? line.nodes : []; return nodes.reduce((sum, node) => { if (node?.type === 'box' || node?.type === 'glue') return sum + Number(node.width || 0); return sum; }, 0); } getLineWordCount(line = {}) { const nodes = Array.isArray(line.nodes) ? line.nodes : []; let count = 0; let previousWasGlue = true; nodes.forEach((node) => { if (!node) return; if (node.type === 'glue') { previousWasGlue = true; return; } if (node.type === 'penalty') return; if (node.type === 'box' && node.value) { if (previousWasGlue) count += 1; previousWasGlue = false; } }); return count; } getImageRevealDurationMs(lineRecord = {}) { const metadata = lineRecord.metadata || {}; const explicit = Number(metadata.animationMs || metadata.revealMs || metadata.imageRevealMs || 0); return Number.isFinite(explicit) && explicit > 0 ? explicit : 2000; } createAnimationState(blockId, wordTimings = [], detail = {}) { return { blockId, wordTimings, startedAt: null, totalDuration: Math.max( Number(detail.totalDuration || 0), ...wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0)) ), completed: false, prepared: true }; } prepareRevealBlock(detail = {}, options = {}) { const blockId = detail.blockId ?? detail.id ?? null; if (blockId == null || !Array.isArray(detail.wordTimings)) return; const id = String(blockId); const wordTimings = detail.wordTimings; const phase = detail.phase === 'prepare' || options.phase === 'prepare' ? 'prepare' : 'activate'; this.markPipelineTiming('prepareRevealBlock:start', { blockId: id, wordTimingCount: wordTimings.length, phase }); // forceRebuild: the cached plan was built before the block's continuation was // committed (it would be right-only). Discard it and redraw from current spreads. if (options.forceRebuild === true) this.pageCache?.takePreparedRevealPlan?.(id); if (phase === 'activate' && options.forceRebuild !== true && this.pageCache?.hasPreparedRevealPlan?.(id)) { const cached = this.pageCache.takePreparedRevealPlan(id); this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail)); this.publishPreparedReveal(cached, options); this.markPipelineTiming('prepareRevealBlock:end', { blockId: id, wordTimingCount: wordTimings.length, reusedPreparedCanvas: true }); return { ...cached, phase: 'activate', preparedFromCache: true }; } 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']; const published = this.drawSpread(spread, sides, { phase, publishEvent: options.publishEvent !== false }); this.preloadAdditionalRevealSpreads(id, spread); if (phase === 'prepare' && published) { this.pageCache?.rememberPreparedRevealPlan?.(id, { ...published, blockId, wordTimings, totalDuration: detail.totalDuration || 0 }); } this.markPipelineTiming('prepareRevealBlock:end', { blockId: id, wordTimingCount: wordTimings.length, phase }); return published ? { ...published, blockId, wordTimings, totalDuration: detail.totalDuration || 0 } : null; } 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' }); }); } spreadContainsBlock(spread = {}, blockId = '') { const id = String(blockId ?? ''); return ['left', 'right'].some((side) => { const lines = Array.isArray(spread?.[side]) ? spread[side] : []; return lines.some(line => String(line?.blockId ?? '') === id); }); } publishPreparedReveal(prepared, options = {}) { if (!prepared) return null; this.markPipelineTiming('publishPreparedReveal', { blockId: prepared.blockId, sides: prepared.sides || [], hasReveal: Boolean(prepared.reveal && Object.keys(prepared.reveal).length) }); const detail = { metrics: prepared.metrics, hitMaps: prepared.hitMaps || this.hitMaps, records: prepared.records || this.buildPageTextureRecords(prepared.sides || ['left', 'right'], prepared), reveal: prepared.reveal || {}, pageMeta: prepared.pageMeta || {}, phase: 'activate', preparedFromCache: true }; if (options.publishEvent !== false) { document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', { detail })); } return detail; } startPreparedRevealAnimation(blockId, options = {}) { const id = String(blockId ?? ''); const animation = this.activeAnimations.get(id); if (!animation) return false; this.markPipelineTiming('startPreparedRevealAnimation', { blockId: id, wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0 }); animation.startedAt = performance.now(); animation.prepared = false; animation.completed = false; if (options.publishEvent !== false) { document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', { detail: { blockId: animation.blockId } })); } return { blockId: animation.blockId, wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0 }; } fastForwardAnimations() { let changed = false; const blockIds = []; this.activeAnimations.forEach((animation) => { if (!animation.completed) { animation.completed = true; this.revealedBlockIds.add(String(animation.blockId ?? '')); blockIds.push(animation.blockId); changed = true; } }); if (changed) { document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', { detail: { blockIds } })); } } completeRevealBlockIds(blockIds = []) { const ids = Array.isArray(blockIds) ? blockIds : []; ids.forEach((blockId) => { const id = String(blockId ?? ''); if (!id) return; const animation = this.activeAnimations.get(id); if (animation) animation.completed = true; this.revealedBlockIds.add(id); }); } stopAnimations() { this.activeAnimations.clear(); this.revealedBlockIds.clear(); 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']; } publishSpread(sides = null, options = {}) { const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; const phase = this.getDrawPhase(options); const regionCounts = { left: 0, right: 0 }; const detail = { metrics: this.metrics, hitMaps: this.hitMaps, sides: sidesToPublish, pageMeta: this.buildPublishPageMeta(sidesToPublish), phase }; if (sidesToPublish.includes('left')) { detail.left = phase === 'prepare' ? this.cloneCanvas(this.canvases.left) : this.canvases.left; } if (sidesToPublish.includes('right')) { detail.right = phase === 'prepare' ? this.cloneCanvas(this.canvases.right) : this.canvases.right; } const reveal = {}; sidesToPublish.forEach((side) => { const sideReveal = this.buildRevealRegions(side); if (!sideReveal) return; sideReveal.baseCanvas = phase === 'prepare' ? this.cloneCanvas(this.revealBaseCanvases?.[side]) : this.revealBaseCanvases?.[side] || null; regionCounts[side] = sideReveal.lineRects.length; reveal[side] = sideReveal; }); if (Object.keys(reveal).length) detail.reveal = reveal; detail.records = this.buildPageTextureRecords(sidesToPublish, detail); this.cachePublishedPages(sidesToPublish, detail); this.markPipelineTiming('publishSpread', { sides: sidesToPublish, hasReveal: Object.keys(reveal).length > 0, regionCounts, phase }); if (options.publishEvent !== false) { document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', { detail })); } return detail; } buildPageTextureRecords(sides = [], detail = {}) { return sides.map((side) => ({ side, phase: detail.phase || 'activate', canvas: detail[side] || null, pageMeta: detail.pageMeta?.[side] || null, reveal: detail.reveal?.[side] || null, state: { canvasReady: Boolean(detail[side]), vramReady: detail.phase === 'prepare', visible: detail.phase !== 'prepare' } })); } buildPublishPageMeta(sides = []) { const baseMeta = this.currentSpread?.pageMeta || {}; const spreadIndex = Math.max(0, Math.round(Number(this.currentSpread?.index || 0))); return sides.reduce((meta, side) => { const pageIndex = side === 'left' ? spreadIndex * 2 : spreadIndex * 2 + 1; const source = baseMeta[side] || { kind: 'blank', section: pageIndex < 3 ? 'frontmatter' : 'body', pageIndex, pageNumber: null, omitPageNumber: true }; const lines = Array.isArray(this.currentSpread?.[side]) ? this.currentSpread[side] : []; const maxBlockId = lines.reduce((max, line) => Math.max(max, Number(line?.blockId || 0)), 0); const lineCount = lines.length; const normalizedPageIndex = Number(source.pageIndex); const key = Number.isFinite(normalizedPageIndex) ? normalizedPageIndex : side; const nextVersion = Math.max(1, Number(this.pageContentVersions.get(key) || 0) + 1); this.pageContentVersions.set(key, nextVersion); meta[side] = { ...source, pageIndex: Number.isFinite(normalizedPageIndex) ? normalizedPageIndex : pageIndex, contentVersion: nextVersion, completenessScore: (maxBlockId * 1000) + lineCount, maxBlockId, lineCount }; return meta; }, {}); } cachePublishedPages(sides = [], detail = {}) { if (!this.pageCache || typeof this.pageCache.storePageCanvas !== 'function') return; sides.forEach((side) => { const canvas = detail[side]; const pageMeta = detail.pageMeta?.[side] || null; if (!canvas || !pageMeta || !Number.isFinite(Number(pageMeta.pageIndex))) return; this.pageCache.storePageCanvas(pageMeta, canvas, { persist: true, resident: true }); }); } getPageCanvas(side) { return this.canvases[side] || null; } getHitMap(side) { return this.hitMaps[side] || []; } handlePageCountChanged(event) { this.pageFormat?.setPageCount?.(event.detail?.pageCount); this.createPageCanvases(); this.lastDrawSignature = null; this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); } } const bookTextureRenderer = new BookTextureRendererModule(); export { bookTextureRenderer as BookTextureRenderer }; if (window.moduleRegistry) { window.moduleRegistry.register(bookTextureRenderer); } window.BookTextureRenderer = bookTextureRenderer;