/** * 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', 'webgl-page-cache']; this.pageFormat = null; this.pagination = null; this.localization = 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.pendingRevealBlockIds = new Set(); this.preparedRevealCache = new Map(); this.revealBaseCanvases = null; this.revealPublishBlockIds = null; this.lastDrawSignature = null; this.lastDrawSkipLoggedAt = 0; this.animationFrameId = null; this.lastAnimationFrameAt = 0; this.targetFrameDurationMs = 1000 / 60; this.pipelineTimings = []; this.imageCache = new Map(); this.pendingPageCacheWrites = 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', 'collectRevealRegionCandidates', 'createRevealRegionForLine', 'getLineInkRect', 'getLineNaturalWidth', 'getImageRevealDurationMs', 'getInlineStyleState', 'updateInlineStyleState', 'getCanvasFont', 'applyTextStyle', 'getPageContent', 'buildLineSegments', 'startRevealAnimation', 'prepareRevealBlock', 'hasPreparedRevealBlock', 'createAnimationState', 'publishPreparedReveal', 'startPreparedRevealAnimation', 'fastForwardAnimations', 'stopAnimations', 'getBlockSides', 'getAnimatedSides', 'markPendingReveal', 'requestAnimationFrame', 'tickAnimations', 'publishSpread', 'cachePublishedPages', 'getPageCacheWriteKey', 'isOlderPageMeta', 'schedulePageCacheWrite', 'getPageCanvas', 'getHitMap', 'handlePageCountChanged' ]); } async initialize() { this.pageFormat = this.getModule('book-page-format'); this.pagination = this.getModule('book-pagination'); this.localization = this.getModule('localization'); 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); this.addEventListener(document, 'book-pagination:spread-updated', (event) => { const spread = event.detail?.spread || this.pagination?.getCurrentSpread?.(); const latestBlockId = event.detail?.latestBlockId; const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0)); this.currentSpread = spread || { left: [], right: [] }; if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) { this.markPendingReveal(latestBlockId); return; } this.drawSpread(this.currentSpread); }); this.addEventListener(document, 'book-texture:reveal-block', (event) => { this.startRevealAnimation(event.detail || {}); }); this.addEventListener(document, 'book-texture:prepare-reveal-block', (event) => { this.prepareRevealBlock(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; } 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 drawSignature = this.getDrawSignature(this.currentSpread, sidesToDraw); if (!options.preloadOnly && !hasReveal && drawSignature === this.lastDrawSignature) { const now = performance.now(); if (now - this.lastDrawSkipLoggedAt > 1000) { this.lastDrawSkipLoggedAt = now; this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw }); } if (options.preloadOnly) this.currentSpread = previousSpread; return null; } this.markPipelineTiming('drawSpread:start', { sides: sidesToDraw, revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : [], preloadOnly: Boolean(options.preloadOnly) }); 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, preloadOnly: Boolean(options.preloadOnly) }); this.revealBaseCanvases = null; this.revealPublishBlockIds = null; if (!options.preloadOnly && !hasReveal) this.lastDrawSignature = drawSignature; if (options.preloadOnly) this.currentSpread = previousSpread; return published; } 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 titleText = document.getElementById('game_title')?.textContent?.trim() || ''; const authorText = document.getElementById('game_author')?.textContent?.trim() || ''; const subtitleText = document.getElementById('game_subtitle')?.textContent?.trim() || ''; 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() || ''; 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; const fixedRegions = blockRegions.filter(region => region.fixedDurationMs > 0); const textRegions = blockRegions.filter(region => !(region.fixedDurationMs > 0)); let delay = 0; const textDuration = Math.max(0, Number(animation.totalDuration || 0)); const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.area), 0); textRegions.forEach((region) => { const duration = totalArea > 0 ? Math.max(1, textDuration * (Math.max(1, region.area) / totalArea)) : Math.max(1, textDuration / Math.max(1, textRegions.length)); regions.push({ ...region, timing: { delay, duration } }); delay += duration; }); fixedRegions.forEach((region) => { regions.push({ ...region, timing: { delay, duration: Math.max(1, region.fixedDurationMs) } }); delay += Math.max(1, region.fixedDurationMs); }); }); const sideRegions = regions.filter(region => region.side === side); 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: regions.reduce((maxDuration, region) => Math.max(maxDuration, region.timing.delay + region.timing.duration), 0), baseCanvas: null, lineRects: sideRegions.map(region => ({ blockId: region.blockId, lineIndex: region.lineIndex, rect: region.rect, timing: region.timing })), 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) } }; } collectRevealRegionCandidates() { const candidates = []; ['left', 'right'].forEach((side) => { const spreadLines = Array.isArray(this.currentSpread?.[side]) ? this.currentSpread[side] : []; spreadLines.forEach((lineRecord) => { const region = this.createRevealRegionForLine(side, lineRecord); if (region) candidates.push(region); }); }); return candidates; } createRevealRegionForLine(side, lineRecord = {}) { 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)); } 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); } normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, fixedDurationMs = 0) { 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); return { side, blockId, lineIndex: Number(lineRecord.lineIndex ?? lineRecord.pageLine ?? 0), fixedDurationMs, area: rectWidth * rectHeight, 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); } getImageRevealDurationMs(lineRecord = {}) { const metadata = lineRecord.metadata || {}; const explicit = Number(metadata.animationMs || metadata.revealMs || metadata.imageRevealMs || 0); return Number.isFinite(explicit) && explicit > 0 ? explicit : 2000; } startRevealAnimation(detail = {}) { const blockId = detail.blockId ?? detail.id ?? null; if (blockId == null || !Array.isArray(detail.wordTimings)) return; const existing = this.activeAnimations.get(String(blockId)); if (existing && existing.prepared) { this.startPreparedRevealAnimation(blockId); return; } this.activeAnimations.set(String(blockId), { blockId, wordTimings: detail.wordTimings, startedAt: performance.now(), totalDuration: Math.max( Number(detail.totalDuration || 0), ...detail.wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0)) ), completed: false }); this.pendingRevealBlockIds.delete(String(blockId)); this.revealPublishBlockIds = new Set([String(blockId)]); this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), ['left', 'right']); document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', { detail: { blockId } })); this.requestAnimationFrame(); } 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 preloadOnly = Boolean(detail.preloadOnly || options.preloadOnly); this.markPipelineTiming('prepareRevealBlock:start', { blockId: id, wordTimingCount: wordTimings.length, preloadOnly }); if (!preloadOnly && this.preparedRevealCache.has(id)) { const cached = this.preparedRevealCache.get(id); this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail)); this.pendingRevealBlockIds.delete(id); this.publishPreparedReveal(cached); this.markPipelineTiming('prepareRevealBlock:end', { blockId: id, wordTimingCount: wordTimings.length, reusedPreparedCanvas: true }); return; } this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail)); this.pendingRevealBlockIds.delete(id); this.revealPublishBlockIds = new Set([id]); const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.(); const sides = ['left', 'right']; const published = this.drawSpread(spread, sides, { preloadOnly }); if (preloadOnly && published) { this.preparedRevealCache.set(id, { ...published, blockId, wordTimings, totalDuration: detail.totalDuration || 0 }); } this.markPipelineTiming('prepareRevealBlock:end', { blockId: id, wordTimingCount: wordTimings.length, preloadOnly }); } hasPreparedRevealBlock(blockId) { const id = String(blockId ?? ''); return Boolean(id && this.preparedRevealCache.has(id)); } publishPreparedReveal(prepared) { if (!prepared) return; this.markPipelineTiming('publishPreparedReveal', { blockId: prepared.blockId, sides: prepared.sides || [], hasReveal: Boolean(prepared.reveal && Object.keys(prepared.reveal).length) }); document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { detail: { metrics: prepared.metrics, hitMaps: prepared.hitMaps || this.hitMaps, left: prepared.left || null, right: prepared.right || null, reveal: prepared.reveal || {}, pageMeta: prepared.pageMeta || {}, preparedFromCache: true } })); } startPreparedRevealAnimation(blockId) { 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; document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', { detail: { blockId: animation.blockId } })); this.requestAnimationFrame(); return true; } 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) { this.pendingRevealBlockIds.clear(); document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', { detail: { blockIds } })); } } stopAnimations() { this.activeAnimations.clear(); this.pendingRevealBlockIds.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.pendingRevealBlockIds.add(id); } requestAnimationFrame() { if (this.animationFrameId) return; this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs); } isWebGLPageFlipActive() { return document.documentElement.dataset.webglPageFlipActive === 'true'; } tickAnimations(now) { this.animationFrameId = null; if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) { this.requestAnimationFrame(); return; } this.lastAnimationFrameAt = now; let hasActive = false; const currentNow = performance.now(); if (this.isWebGLPageFlipActive()) { this.activeAnimations.forEach((animation) => { if (animation.completed) return; if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return; hasActive = true; if (animation.startedAt != null) { animation.startedAt += this.targetFrameDurationMs; } }); if (hasActive) this.requestAnimationFrame(); return; } this.activeAnimations.forEach((animation) => { if (animation.completed) return; if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return; if (animation.startedAt == null) { hasActive = true; 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; } }); if (hasActive) this.requestAnimationFrame(); } publishSpread(sides = null, options = {}) { const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; const regionCounts = { left: 0, right: 0 }; const detail = { metrics: this.metrics, hitMaps: this.hitMaps, sides: sidesToPublish, pageMeta: this.buildPublishPageMeta(sidesToPublish) }; if (options.preloadOnly) detail.preloadOnly = true; if (sidesToPublish.includes('left')) { detail.left = options.preloadOnly ? this.cloneCanvas(this.canvases.left) : this.canvases.left; } if (sidesToPublish.includes('right')) { detail.right = options.preloadOnly ? this.cloneCanvas(this.canvases.right) : this.canvases.right; } const reveal = {}; sidesToPublish.forEach((side) => { const sideReveal = this.buildRevealRegions(side); if (!sideReveal) return; sideReveal.baseCanvas = options.preloadOnly ? 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; this.cachePublishedPages(sidesToPublish, detail); this.markPipelineTiming('publishSpread', { sides: sidesToPublish, hasReveal: Object.keys(reveal).length > 0, regionCounts, preloadOnly: Boolean(options.preloadOnly) }); document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { detail })); return detail; } buildPublishPageMeta(sides = []) { const baseMeta = this.currentSpread?.pageMeta || {}; return sides.reduce((meta, side) => { const source = baseMeta[side] || null; if (!source) { meta[side] = null; return meta; } 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 pageIndex = Number(source.pageIndex); const key = Number.isFinite(pageIndex) ? pageIndex : side; const nextVersion = Math.max(1, Number(this.pageContentVersions.get(key) || 0) + 1); this.pageContentVersions.set(key, nextVersion); meta[side] = { ...source, contentVersion: nextVersion, completenessScore: (maxBlockId * 1000) + lineCount, maxBlockId, lineCount }; return meta; }, { left: Object.prototype.hasOwnProperty.call(baseMeta, 'left') ? baseMeta.left : null, right: Object.prototype.hasOwnProperty.call(baseMeta, 'right') ? baseMeta.right : null }); } cachePublishedPages(sides = [], detail = {}) { if (!this.pageCache || typeof this.pageCache.cachePageCanvas !== '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.schedulePageCacheWrite(pageMeta, canvas); }); } schedulePageCacheWrite(pageMeta, canvas) { const frozenCanvas = this.cloneCanvas(canvas); const key = this.getPageCacheWriteKey(pageMeta, frozenCanvas); const pending = this.pendingPageCacheWrites.get(key); if (pending && this.isOlderPageMeta(pageMeta, pending.pageMeta)) return pending.promise; const previousWrite = pending?.promise || Promise.resolve(); const write = previousWrite.catch(() => false).then(() => this.pageCache?.cachePageCanvas?.(pageMeta, frozenCanvas)) .then((stored) => { if (!stored) { document.dispatchEvent(new CustomEvent('webgl-book:page-cache-problem', { detail: { type: 'db-write-failed', pageIndex: pageMeta?.pageIndex ?? null, key } })); } return stored; }) .catch((error) => { document.dispatchEvent(new CustomEvent('webgl-book:page-cache-problem', { detail: { type: 'db-write-error', pageIndex: pageMeta?.pageIndex ?? null, key, message: error?.message || String(error) } })); return false; }) .finally(() => { if (this.pendingPageCacheWrites.get(key)?.promise === write) { this.pendingPageCacheWrites.delete(key); } }); this.pendingPageCacheWrites.set(key, { promise: write, pageMeta: { ...(pageMeta || {}) } }); return write; } isOlderPageMeta(incoming = {}, existing = null) { if (!existing) return false; const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0)); const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0)); if (incomingCompleteness < existingCompleteness) return true; if (incomingCompleteness > existingCompleteness) return false; const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0)); const existingVersion = Math.max(0, Number(existing?.contentVersion || 0)); return incomingVersion > 0 && existingVersion > incomingVersion; } getPageCacheWriteKey(pageMeta = {}, canvas = null) { if (this.pageCache && typeof this.pageCache.makePageKey === 'function') { return this.pageCache.makePageKey({ ...pageMeta, width: canvas?.width ?? pageMeta.width, height: canvas?.height ?? pageMeta.height }); } return `${pageMeta.cacheKey || window.MODULE_CACHE_BUSTER || 'dev'}:page:${pageMeta.pageIndex}:${canvas?.width || pageMeta.width}x${canvas?.height || pageMeta.height}`; } 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;