diff --git a/public/js/book-page-format-module.js b/public/js/book-page-format-module.js index 17cf11c..5456954 100644 --- a/public/js/book-page-format-module.js +++ b/public/js/book-page-format-module.js @@ -74,6 +74,7 @@ class BookPageFormatModule extends BaseModule { width: Math.max(1, width - margins.outer - margins.inner), height: Math.max(1, height - margins.top - margins.bottom) }, + typographyLineHeightPx: this.inchesToTexture(this.format.typography.lineHeightPt / 72, height), typography: this.format.typography }; } diff --git a/public/js/book-pagination-module.js b/public/js/book-pagination-module.js new file mode 100644 index 0000000..ab0904f --- /dev/null +++ b/public/js/book-pagination-module.js @@ -0,0 +1,216 @@ +/** + * Book Pagination Module + * Converts story blocks into texture-space page lines for the WebGL book. + */ +import { BaseModule } from './base-module.js'; + +class BookPaginationModule extends BaseModule { + constructor() { + super('book-pagination', 'Book Pagination'); + this.dependencies = ['book-page-format', 'paragraph-layout', 'story-history']; + this.pageFormat = null; + this.paragraphLayout = null; + this.storyHistory = null; + this.metrics = null; + this.spreads = []; + this.currentSpreadIndex = 0; + this.refreshToken = 0; + + this.bindMethods([ + 'initialize', + 'refreshFromHistory', + 'buildSpreads', + 'layoutTextBlock', + 'extractLines', + 'getLineGeometry', + 'getSpread', + 'getCurrentSpread', + 'setCurrentSpread', + 'publish' + ]); + } + + async initialize() { + this.pageFormat = this.getModule('book-page-format'); + this.paragraphLayout = this.getModule('paragraph-layout'); + this.storyHistory = this.getModule('story-history'); + this.metrics = this.pageFormat.getTextureMetrics(1280); + + this.reportProgress(35, 'Preparing book pagination metrics'); + this.addEventListener(document, 'story:history-updated', this.refreshFromHistory); + this.addEventListener(document, 'book-pagination:set-spread', (event) => { + this.setCurrentSpread(event.detail?.spreadIndex); + }); + this.reportProgress(100, 'Book pagination ready'); + return true; + } + + async refreshFromHistory(event = null) { + const token = ++this.refreshToken; + const detail = event?.detail || {}; + const gameId = detail.gameId || this.storyHistory?.currentGameId || null; + const latestBlockId = Math.max( + 0, + Number(detail.latestRenderedBlockId || detail.latestBlockId || this.storyHistory?.latestRenderedBlockId || (this.storyHistory?.nextBlockId || 1) - 1) + ); + if (!gameId || latestBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') { + this.spreads = []; + this.publish(); + return; + } + + const blocks = await this.storyHistory.getBlocksRange(gameId, 1, latestBlockId); + if (token !== this.refreshToken) return; + this.spreads = this.buildSpreads(blocks); + this.currentSpreadIndex = Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1))); + this.publish(); + } + + buildSpreads(blocks = []) { + const spreads = []; + let cursorLine = 0; + const source = Array.isArray(blocks) ? blocks : []; + + source.forEach((block) => { + const type = block?.kind || block?.type || 'paragraph'; + if (!['paragraph', 'heading'].includes(type)) return; + + const layout = this.layoutTextBlock(block, type); + if (!layout?.lines?.length) return; + + layout.lines.forEach((line) => { + const geometry = this.getLineGeometry(cursorLine); + if (!spreads[geometry.spreadIndex]) { + spreads[geometry.spreadIndex] = { index: geometry.spreadIndex, left: [], right: [] }; + } + spreads[geometry.spreadIndex][geometry.side].push({ + blockId: block.blockId ?? null, + turnId: block.turnId ?? block.metadata?.turnId ?? null, + role: layout.role, + text: block.text || '', + line, + lineIndex: cursorLine, + pageLine: geometry.pageLine, + fontPx: layout.fontPx, + lineHeightPx: layout.lineHeightPx + }); + cursorLine += 1; + }); + }); + + return spreads.filter(Boolean); + } + + layoutTextBlock(block = {}, type = 'paragraph') { + const text = String(block.layoutText || block.text || '').trim(); + if (!text || !this.paragraphLayout) return null; + + const typography = this.metrics.typography; + const role = block.role || block.metadata?.role || (type === 'heading' ? 'chapter-heading' : 'body'); + const isHeading = type === 'heading' || role === 'chapter-heading' || role === 'section-heading'; + const fontPx = Math.max(1, Math.round(this.pageFormat.inchesToTexture(typography.bodyFontSizePt / 72, this.metrics.height))); + const lineHeightPx = Math.max(fontPx + 2, Math.round(this.pageFormat.inchesToTexture(typography.lineHeightPt / 72, this.metrics.height))); + const indent = (isHeading || block.isFirstParagraphInChapter || block.metadata?.isFirstParagraphInChapter || block.addTopSpace) + ? 0 + : lineHeightPx * 1.5; + const measures = isHeading + ? [this.metrics.content.width] + : [Math.max(120, this.metrics.content.width - indent), this.metrics.content.width, this.metrics.content.width]; + const lineOffsets = isHeading ? [0] : [indent, 0, 0]; + + const layout = this.paragraphLayout.calculateLayout(text, { + measures, + fontSize: `${fontPx}px`, + fontFamily: typography.fontFamily, + lineHeightPx, + lineHeight: lineHeightPx / fontPx + }); + if (!layout) return null; + + return { + role, + fontPx, + lineHeightPx, + lines: this.extractLines(layout, { + measures, + lineOffsets, + align: isHeading ? 'center' : 'justify' + }) + }; + } + + extractLines(layout, options = {}) { + const lines = []; + const breaks = Array.isArray(layout.breaks) ? layout.breaks : []; + const nodes = Array.isArray(layout.nodes) ? layout.nodes : []; + for (let index = 1; index < breaks.length; index += 1) { + const start = breaks[index - 1].position; + const end = breaks[index].position; + const lineNodes = []; + for (let nodeIndex = start; nodeIndex <= end; nodeIndex += 1) { + const node = nodes[nodeIndex]; + if (!node) continue; + if (node.type === 'glue' && (nodeIndex === start || nodeIndex === end)) continue; + lineNodes.push({ ...node }); + } + const measure = options.measures[Math.min(index - 1, options.measures.length - 1)] || this.metrics.content.width; + const offset = options.lineOffsets[Math.min(index - 1, options.lineOffsets.length - 1)] || 0; + lines.push({ + nodes: lineNodes, + measure, + offset, + ratio: breaks[index].ratio || 0, + isFinal: index === breaks.length - 1, + align: options.align || 'justify' + }); + } + return lines; + } + + getLineGeometry(globalLine) { + const linesPerPage = Math.max(1, Math.floor(this.metrics.content.height / this.metrics.typographyLineHeightPx || 1)); + const spreadLineCount = linesPerPage * 2; + const spreadIndex = Math.floor(globalLine / spreadLineCount); + const spreadLine = globalLine % spreadLineCount; + const side = spreadLine < linesPerPage ? 'left' : 'right'; + return { + spreadIndex, + side, + pageLine: side === 'left' ? spreadLine : spreadLine - linesPerPage + }; + } + + getSpread(index = this.currentSpreadIndex) { + return this.spreads[Math.max(0, Number(index || 0))] || { index: 0, left: [], right: [] }; + } + + getCurrentSpread() { + return this.getSpread(this.currentSpreadIndex); + } + + setCurrentSpread(index = 0) { + this.currentSpreadIndex = Math.max(0, Math.min(Math.round(Number(index || 0)), Math.max(0, this.spreads.length - 1))); + this.publish(); + return this.currentSpreadIndex; + } + + publish() { + document.dispatchEvent(new CustomEvent('book-pagination:spread-updated', { + detail: { + spread: this.getCurrentSpread(), + spreadIndex: this.currentSpreadIndex, + spreadCount: this.spreads.length + } + })); + } +} + +const bookPagination = new BookPaginationModule(); + +export { bookPagination as BookPagination }; + +if (window.moduleRegistry) { + window.moduleRegistry.register(bookPagination); +} + +window.BookPagination = bookPagination; diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index f321fcb..3de42ae 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -7,8 +7,9 @@ import { BaseModule } from './base-module.js'; class BookTextureRendererModule extends BaseModule { constructor() { super('book-texture-renderer', 'Book Texture Renderer'); - this.dependencies = ['book-page-format', 'localization']; + this.dependencies = ['book-page-format', 'book-pagination', 'localization']; this.pageFormat = null; + this.pagination = null; this.localization = null; this.metrics = null; this.canvases = { @@ -28,8 +29,10 @@ class BookTextureRendererModule extends BaseModule { 'initialize', 'createPageCanvases', 'drawEmptySpread', + 'drawSpread', 'drawPageBase', - 'drawDebugText', + 'drawPageLines', + 'drawLine', 'publishSpread', 'getPageCanvas', 'getHitMap', @@ -39,11 +42,15 @@ class BookTextureRendererModule extends BaseModule { 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; } @@ -62,7 +69,14 @@ class BookTextureRendererModule extends BaseModule { drawEmptySpread() { this.drawPageBase('left'); this.drawPageBase('right'); - this.drawDebugText('right', 'Book canvas renderer ready'); + this.publishSpread(); + } + + drawSpread(spread = null) { + this.drawPageBase('left'); + this.drawPageBase('right'); + this.drawPageLines('left', spread?.left || []); + this.drawPageLines('right', spread?.right || []); this.publishSpread(); } @@ -91,19 +105,51 @@ class BookTextureRendererModule extends BaseModule { this.hitMaps[side] = []; } - drawDebugText(side, text) { + drawPageLines(side, lines = []) { const ctx = this.contexts[side]; - const metrics = this.metrics; - if (!ctx || !metrics) return; + if (!ctx || !this.metrics || !Array.isArray(lines)) return; ctx.save(); - ctx.fillStyle = 'rgba(31, 19, 10, 0.82)'; - ctx.font = `${Math.round(metrics.typography.bodyFontSizePt * 1.55)}px ${metrics.typography.fontFamily}`; + ctx.fillStyle = 'rgba(31, 19, 10, 0.86)'; ctx.textBaseline = 'alphabetic'; - ctx.fillText(String(text || ''), metrics.content.x, metrics.content.y + 44); + 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: { diff --git a/public/js/loader.js b/public/js/loader.js index 1b40822..98df9bc 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -24,7 +24,7 @@ const ModuleState = { ERROR: 'ERROR' }; -const MODULE_CACHE_BUSTER = '20260606-webgl-texture-renderer-foundation'; +const MODULE_CACHE_BUSTER = '20260606-webgl-pagination-foundation'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** @@ -114,6 +114,7 @@ const ModuleLoader = (function() { { id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 }, { id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module { id: 'book-page-format', script: '/js/book-page-format-module.js', weight: 4 }, + { id: 'book-pagination', script: '/js/book-pagination-module.js', weight: 8 }, { id: 'book-texture-renderer', script: '/js/book-texture-renderer-module.js', weight: 6 }, { id: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 }, { id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 },