/** * 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.pages = []; this.currentSpreadIndex = 0; this.refreshToken = 0; this.latestBlockId = 0; this.latestRenderedBlockId = 0; this.bindMethods([ 'initialize', 'refreshFromHistory', 'preparePendingBlock', 'buildSpreads', 'buildPages', 'buildSpreadsFromPages', 'createBlankPage', 'createTitlePage', 'ensurePage', 'nextContentPageNumber', 'advancePage', 'advanceToNextRightPage', 'shouldAdvanceBeforeTextLine', 'getLinesPerPage', 'layoutImageBlock', 'createImageRecord', 'persistPaginationMetrics', 'collectPaginationMetrics', 'layoutTextBlock', 'getDropCapText', 'extractDropCapText', 'measureDropCapReservation', 'measureNormalTextGap', 'calculateDropCapLayout', 'extractLayoutLine', 'extractRemainingLayoutText', 'extractLines', 'getActiveStyleTags', 'updateStyleTagStack', 'countLineWords', 'getLineGeometry', 'getSpread', 'getCurrentSpread', 'setCurrentSpread', 'handlePageCountChanged', '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(this.pageFormat.getTextureWidth?.()); this.reportProgress(35, 'Preparing book pagination metrics'); this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged); this.addEventListener(document, 'story:history-updated', this.refreshFromHistory); this.addEventListener(document, 'book-pagination:prepare-block', (event) => { this.preparePendingBlock(event.detail?.block || event.detail || {}); }); this.addEventListener(document, 'book-pagination:set-spread', (event) => { this.setCurrentSpread(event.detail?.spreadIndex); }); this.addEventListener(document, 'webgl-book:page-flip-near-end', (event) => { const direction = Math.sign(Number(event.detail?.direction || 0)); if (direction !== 0) this.setCurrentSpread(this.currentSpreadIndex + direction); }); this.reportProgress(100, 'Book pagination ready'); return true; } handlePageCountChanged(event) { this.pageFormat?.setPageCount?.(event.detail?.pageCount); this.metrics = this.pageFormat.getTextureMetrics(this.pageFormat.getTextureWidth?.()); this.refreshFromHistory(); } 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.pages = this.buildPages([]); this.spreads = this.buildSpreadsFromPages(this.pages); this.latestBlockId = 0; this.latestRenderedBlockId = 0; this.currentSpreadIndex = 0; this.publish(); return; } const blocks = await this.storyHistory.getBlocksRange(gameId, 1, latestBlockId); if (token !== this.refreshToken) return; this.latestBlockId = latestBlockId; this.latestRenderedBlockId = Math.max( 0, Number(detail.latestRenderedBlockId || this.storyHistory?.latestRenderedBlockId || 0) ); this.pages = this.buildPages(blocks); this.spreads = this.buildSpreadsFromPages(this.pages); this.persistPaginationMetrics(this.pages); this.currentSpreadIndex = Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1))); this.publish(); } async preparePendingBlock(block = {}, options = {}) { const token = options.activate === false ? this.refreshToken : ++this.refreshToken; const gameId = block.gameId || block.metadata?.gameId || this.storyHistory?.currentGameId || null; const latestRenderedBlockId = Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0)); const pendingBlockId = Math.max(0, Number(block.blockId || block.metadata?.blockId || 0)); if (!gameId || pendingBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') { return null; } const historyEndBlockId = options.includeUnrenderedHistory ? Math.max(0, pendingBlockId - 1) : latestRenderedBlockId; const historyBlocks = historyEndBlockId > 0 ? await this.storyHistory.getBlocksRange(gameId, 1, historyEndBlockId) : []; if (options.activate !== false && token !== this.refreshToken) return null; const normalizedBlock = { ...block, type: block.kind || block.type || 'paragraph', kind: block.kind || block.type || 'paragraph', blockId: pendingBlockId, gameId, metadata: { ...(block.metadata || {}), blockId: pendingBlockId, gameId } }; const preparedPages = this.buildPages([...historyBlocks, normalizedBlock]); const preparedSpreads = this.buildSpreadsFromPages(preparedPages); const targetSpread = preparedSpreads.find(spread => ['left', 'right'].some(side => { const lines = Array.isArray(spread?.[side]) ? spread[side] : []; return lines.some(line => Number(line?.blockId || 0) === pendingBlockId); })); if (options.activate !== false) { this.latestBlockId = pendingBlockId; this.latestRenderedBlockId = latestRenderedBlockId; this.pages = preparedPages; this.spreads = preparedSpreads; this.currentSpreadIndex = Math.max(0, Math.min(this.spreads.length - 1, this.currentSpreadIndex)); if (targetSpread) this.currentSpreadIndex = targetSpread.index; } if (options.publish !== false) this.publish(); document.dispatchEvent(new CustomEvent('book-pagination:block-prepared', { detail: { blockId: pendingBlockId, spread: targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread()), spreadIndex: targetSpread?.index ?? this.currentSpreadIndex, latestBlockId: pendingBlockId, latestRenderedBlockId, preloadOnly: options.activate === false } })); return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread()); } buildSpreads(blocks = []) { this.pages = this.buildPages(blocks); return this.buildSpreadsFromPages(this.pages); } buildPages(blocks = []) { const pages = [ this.createBlankPage(0, { section: 'frontmatter' }), this.createTitlePage(1), this.createBlankPage(2, { section: 'frontmatter' }) ]; let pageIndex = 3; let pageLine = 0; let contentPageNumber = 1; const source = Array.isArray(blocks) ? blocks : []; const linesPerPage = this.getLinesPerPage(); source.forEach((block) => { const type = block?.kind || block?.type || 'paragraph'; if (type === 'image') { ({ pageIndex, pageLine, contentPageNumber } = this.layoutImageBlock( pages, block, pageIndex, pageLine, contentPageNumber, linesPerPage )); return; } if (!['paragraph', 'heading'].includes(type)) return; const layout = this.layoutTextBlock(block, type); if (!layout?.lines?.length) return; let blockWordCursor = 0; const isHeading = type === 'heading' || layout.role === 'chapter-heading' || layout.role === 'section-heading'; if (isHeading) { ({ pageIndex, pageLine, contentPageNumber } = this.advanceToNextRightPage(pages, pageIndex, pageLine, contentPageNumber)); } else if (pageLine + layout.topSpaceLines >= linesPerPage) { ({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber)); } pageLine += layout.topSpaceLines; layout.lines.forEach((line, layoutLineIndex) => { if (this.shouldAdvanceBeforeTextLine({ line, layout, layoutLineIndex, pageIndex, pageLine, linesPerPage })) { ({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber)); } const page = this.ensurePage(pages, pageIndex, { pageNumber: contentPageNumber, section: 'body' }); const lineWordCount = this.countLineWords(line); page.lines.push({ blockId: block.blockId ?? null, turnId: block.turnId ?? block.metadata?.turnId ?? null, role: layout.role, text: block.text || '', line, lineIndex: page.index * linesPerPage + pageLine, pageIndex: page.index, pageNumber: page.pageNumber, pageLine, fontPx: layout.fontPx, lineHeightPx: layout.lineHeightPx, fontStyle: layout.fontStyle, blockWordStart: blockWordCursor, dropCapText: layoutLineIndex === 0 ? layout.dropCapText : '', smallCaps: Boolean(layout.dropCap && layoutLineIndex === 0) }); blockWordCursor += lineWordCount; pageLine += 1; }); pageLine += layout.bottomSpaceLines; if (pageLine >= linesPerPage) { ({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber)); } }); return pages; } buildSpreadsFromPages(pages = []) { const spreads = []; const linesPerPage = this.getLinesPerPage(); pages.forEach((page, pageIndex) => { const spreadIndex = Math.floor(pageIndex / 2); const side = pageIndex % 2 === 0 ? 'left' : 'right'; if (!spreads[spreadIndex]) { spreads[spreadIndex] = { index: spreadIndex, left: [], right: [], pageMeta: { left: null, right: null } }; } spreads[spreadIndex][side] = Array.isArray(page?.lines) ? page.lines : []; spreads[spreadIndex].pageMeta[side] = { kind: page?.kind || 'content', pageIndex, pageNumber: page?.pageNumber ?? null, section: page?.section || 'body', omitPageNumber: page?.omitPageNumber === true, linesPerPage }; }); return spreads.filter(Boolean); } createBlankPage(index = 0, options = {}) { return { index, kind: 'blank', section: options.section || 'body', pageNumber: options.pageNumber ?? null, omitPageNumber: true, lines: [] }; } createTitlePage(index = 1) { return { index, kind: 'title', section: 'frontmatter', pageNumber: null, omitPageNumber: true, lines: [] }; } ensurePage(pages, index, options = {}) { if (!pages[index]) { pages[index] = { index, kind: options.kind || 'content', section: options.section || 'body', pageNumber: options.pageNumber ?? null, omitPageNumber: options.omitPageNumber === true, lines: [] }; } else if (options.pageNumber != null && pages[index].pageNumber == null) { pages[index].pageNumber = options.pageNumber; pages[index].kind = pages[index].kind === 'blank' ? 'content' : pages[index].kind; pages[index].section = options.section || pages[index].section; } return pages[index]; } nextContentPageNumber(pages = []) { return pages.reduce((max, page) => Math.max(max, Number(page?.pageNumber || 0)), 0) + 1; } advancePage(pages, pageIndex, contentPageNumber) { const nextIndex = pageIndex + 1; const pageNumber = Math.max(Number(contentPageNumber || 1), this.nextContentPageNumber(pages)); this.ensurePage(pages, nextIndex, { kind: 'content', section: 'body', pageNumber, omitPageNumber: false }); return { pageIndex: nextIndex, pageLine: 0, contentPageNumber: pageNumber + 1 }; } advanceToNextRightPage(pages, pageIndex, pageLine, contentPageNumber) { let nextIndex = pageIndex; let nextLine = pageLine; let nextPageNumber = contentPageNumber; if (nextLine > 0) { const advanced = this.advancePage(pages, nextIndex, nextPageNumber); nextIndex = advanced.pageIndex; nextLine = advanced.pageLine; nextPageNumber = advanced.contentPageNumber; } if (nextIndex % 2 === 0) { const blankNumber = Math.max(Number(nextPageNumber || 1), this.nextContentPageNumber(pages)); this.ensurePage(pages, nextIndex, { kind: 'blank', section: 'body', pageNumber: blankNumber, omitPageNumber: true }); nextIndex += 1; nextPageNumber = blankNumber + 1; } this.ensurePage(pages, nextIndex, { kind: 'content', section: 'body', pageNumber: Math.max(Number(nextPageNumber || 1), this.nextContentPageNumber(pages)), omitPageNumber: false }); return { pageIndex: nextIndex, pageLine: 0, contentPageNumber: this.nextContentPageNumber(pages) }; } shouldAdvanceBeforeTextLine({ line, layout, layoutLineIndex, pageIndex, pageLine, linesPerPage }) { if (pageLine >= linesPerPage) return true; const remainingPageLines = linesPerPage - pageLine; const remainingBlockLines = Math.max(0, layout.lines.length - layoutLineIndex); if (remainingPageLines === 1 && remainingBlockLines > 1) return true; if (remainingBlockLines === 1 && pageLine === 0 && layout.lines.length > 1) return true; if (pageIndex % 2 === 1 && pageLine === linesPerPage - 1 && line?.hyphenated) return true; return false; } layoutImageBlock(pages, block, pageIndex, pageLine, contentPageNumber, linesPerPage) { const metrics = this.metrics; const content = metrics.contentBySide?.right || metrics.content || {}; const metadata = { ...(block?.metadata || {}), ...block }; const requestedSize = String(metadata.size || metadata.imageLayout?.size || 'landscape').toLowerCase(); const size = requestedSize === 'widescreen' ? 'landscape' : requestedSize; const lineHeightPx = Math.max(1, Number(metrics.typographyLineHeightPx || 1)); const textAreaWidth = Math.max(1, Number(content.width || metrics.content?.width || 1)); const textAreaHeight = Math.max(1, Number(content.height || linesPerPage * lineHeightPx)); let imageLineCount = Math.max(1, Math.ceil(linesPerPage * 0.5)); let rect = null; if (size === 'full') { if (pageLine > 0) { ({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber)); } rect = { x: 0, y: 0, width: metrics.width, height: metrics.height }; imageLineCount = linesPerPage; } else if (size === 'portrait') { const aspect = 9 / 16; const imageWidth = textAreaWidth * 0.5; const imageHeight = imageWidth / aspect; imageLineCount = Math.max(1, Math.ceil(imageHeight / lineHeightPx)); if (pageLine + imageLineCount > linesPerPage) { ({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber)); } rect = { x: textAreaWidth - imageWidth, y: pageLine * lineHeightPx, width: imageWidth, height: Math.min(textAreaHeight - pageLine * lineHeightPx, imageHeight) }; } else { const bottomHalfStart = Math.ceil(linesPerPage * 0.5); if (pageLine > bottomHalfStart) { ({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber)); } else { pageLine = Math.max(pageLine, bottomHalfStart); } imageLineCount = linesPerPage - pageLine; rect = { x: 0, y: pageLine * lineHeightPx, width: textAreaWidth, height: Math.max(lineHeightPx, imageLineCount * lineHeightPx) }; } const page = this.ensurePage(pages, pageIndex, { pageNumber: contentPageNumber, section: 'body' }); page.lines.push(this.createImageRecord(block, page, pageLine, imageLineCount, rect, size)); pageLine += imageLineCount; if (pageLine >= linesPerPage) { ({ pageIndex, pageLine, contentPageNumber } = this.advancePage(pages, pageIndex, contentPageNumber)); } return { pageIndex, pageLine, contentPageNumber }; } persistPaginationMetrics(pages = []) { if (!this.storyHistory || typeof this.storyHistory.updateBlockMetrics !== 'function') return; const metricsByBlock = this.collectPaginationMetrics(pages); metricsByBlock.forEach((metrics, blockId) => { this.storyHistory.updateBlockMetrics(blockId, metrics).catch(error => { console.warn('BookPagination: Failed to persist pagination metrics', error); }); }); } collectPaginationMetrics(pages = []) { const byBlock = new Map(); pages.forEach((page) => { const lines = Array.isArray(page?.lines) ? page.lines : []; lines.forEach((line) => { const blockId = Number(line?.blockId || 0); if (blockId <= 0) return; const pageLineStart = Math.max(0, Number(line.pageLine || 0)); const pageLineEnd = pageLineStart + Math.max(1, Number(line.lineCount || 1)) - 1; const lineStart = Math.max(0, Number(line.lineIndex || 0)); const lineCount = Math.max(1, Number(line.lineCount || 1)); const spreadIndex = Math.floor(Number(page.index || 0) / 2); const current = byBlock.get(blockId); if (!current) { byBlock.set(blockId, { lineStart, lineCount, pageStart: Number(page.index || 0), pageEnd: Number(page.index || 0), pageLineStart, pageLineEnd, spreadStart: spreadIndex, spreadEnd: spreadIndex, pagination: { pages: [{ pageIndex: Number(page.index || 0), pageNumber: page.pageNumber ?? null, firstLine: pageLineStart, lastLine: pageLineEnd }] } }); return; } current.lineStart = Math.min(current.lineStart, lineStart); current.lineCount = Math.max(current.lineStart + current.lineCount, lineStart + lineCount) - current.lineStart; current.pageStart = Math.min(current.pageStart, Number(page.index || 0)); current.pageEnd = Math.max(current.pageEnd, Number(page.index || 0)); current.pageLineStart = Math.min(current.pageLineStart, pageLineStart); current.pageLineEnd = Math.max(current.pageLineEnd, pageLineEnd); current.spreadStart = Math.min(current.spreadStart, spreadIndex); current.spreadEnd = Math.max(current.spreadEnd, spreadIndex); current.pagination.pages.push({ pageIndex: Number(page.index || 0), pageNumber: page.pageNumber ?? null, firstLine: pageLineStart, lastLine: pageLineEnd }); }); }); return byBlock; } createImageRecord(block, page, pageLine, lineCount, rect, size) { return { type: 'image', kind: 'image', blockId: block.blockId ?? null, turnId: block.turnId ?? block.metadata?.turnId ?? null, pageIndex: page.index, pageNumber: page.pageNumber, pageLine, lineIndex: page.index * this.getLinesPerPage() + pageLine, lineCount, metadata: { ...(block.metadata || {}), ...block, imageLayout: { ...(block.metadata?.imageLayout || {}), size, textureRect: rect, lineStart: pageLine, lineCount } } }; } getLinesPerPage() { return Math.max(1, Math.floor(this.metrics.content.height / this.metrics.typographyLineHeightPx || 1)); } layoutTextBlock(block = {}, type = 'paragraph') { const sourceText = String(block.layoutText || block.text || '').trim(); const dropCap = Boolean(block.dropCap || block.metadata?.dropCap); const dropCapText = dropCap ? this.getDropCapText(sourceText) : ''; const text = dropCap ? this.extractDropCapText(sourceText) : sourceText; 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 topSpaceLines = role === 'chapter-heading' ? 2 : role === 'section-heading' || block.addTopSpace || block.metadata?.addTopSpace ? 1 : 0; const bottomSpaceLines = role === 'chapter-heading' || role === 'section-heading' ? 1 : 0; const lineHeightPx = Math.max(1, Number(this.metrics.typographyLineHeightPx || 1)); const fontPx = Math.max(1, Number(this.metrics.bodyFontSizePx || lineHeightPx / 1.5)); const dropCapWidth = dropCap ? this.measureDropCapReservation(dropCapText, fontPx, lineHeightPx) : 0; const indent = (isHeading || block.isFirstParagraphInChapter || block.metadata?.isFirstParagraphInChapter || block.addTopSpace) ? 0 : lineHeightPx * 1.5; const measures = isHeading ? [this.metrics.content.width] : dropCap ? [Math.max(120, this.metrics.content.width - dropCapWidth), Math.max(120, this.metrics.content.width - dropCapWidth), this.metrics.content.width] : [Math.max(120, this.metrics.content.width - indent), this.metrics.content.width, this.metrics.content.width]; const lineOffsets = isHeading ? [0] : dropCap ? [dropCapWidth, dropCapWidth, 0] : [indent, 0, 0]; const layoutOptions = { measures, fontSize: `${fontPx}px`, fontFamily: typography.fontFamily, fontFeatureSettings: '"kern" on, "liga" on, "onum" on, "pnum" on, "dlig" on, "clig" on, "calt" on', lineHeightPx, lineHeight: lineHeightPx / fontPx }; const layout = dropCap ? this.calculateDropCapLayout(text, measures, lineOffsets, layoutOptions) : this.paragraphLayout.calculateLayout(text, layoutOptions); if (!layout) return null; return { role, fontPx, lineHeightPx, fontStyle: isHeading ? 'italic' : 'normal', topSpaceLines, bottomSpaceLines, dropCapText, dropCap, lines: this.extractLines(layout, { measures, lineOffsets, align: isHeading ? 'center' : 'justify' }) }; } getDropCapText(text) { return String(text || '').trimStart().match(/\S/u)?.[0] || ''; } extractDropCapText(text) { const dropCap = this.getDropCapText(text); if (!dropCap) return String(text || ''); return String(text || '').replace(dropCap, '').trimStart(); } measureDropCapReservation(dropCapText, fontPx, lineHeightPx) { if (!dropCapText) return lineHeightPx * 1.34; const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) return lineHeightPx * 1.34; const dropCapFontPx = Math.round(fontPx * 2.68); context.font = `${dropCapFontPx}px "EB Garamond Initials", ${this.metrics.typography.fontFamily}`; const metrics = context.measureText(dropCapText); const inkRight = Number.isFinite(metrics.actualBoundingBoxRight) && metrics.actualBoundingBoxRight > 0 ? metrics.actualBoundingBoxRight : (metrics.width || 0); const advanceWidth = metrics.width || 0; return Math.max(inkRight, advanceWidth, lineHeightPx * 1.08) + this.measureNormalTextGap(fontPx); } measureNormalTextGap(fontPx) { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) return fontPx * 0.75; context.font = `${fontPx}px ${this.metrics.typography.fontFamily}`; const gap = context.measureText('\u2002').width; return Number.isFinite(gap) && gap > 0 ? gap : fontPx * 0.75; } calculateDropCapLayout(text, measures, lineOffsets, layoutOptions) { const firstLineOptions = { ...layoutOptions, measures: [measures[0], Math.max(measures[0] * 20, 10000)], fontVariantCaps: 'all-small-caps', fontFeatureSettings: '"smcp" on, "c2sc" on, "kern" on, "liga" on, "onum" on, "pnum" on' }; const firstLayout = this.paragraphLayout.calculateLayout(text, firstLineOptions); if (!firstLayout?.breaks || firstLayout.breaks.length < 2) { return this.paragraphLayout.calculateLayout(text, layoutOptions); } const firstLine = this.extractLayoutLine(firstLayout, 0, { measure: measures[0], offset: lineOffsets[0], smallCaps: true }); const remainingText = this.extractRemainingLayoutText(firstLayout, firstLayout.breaks[1].position); const remainingLayout = this.paragraphLayout.calculateLayout(remainingText, { ...layoutOptions, measures: [measures[1], ...measures.slice(2)] }); const remainingLines = []; if (remainingLayout?.breaks?.length > 1) { for (let lineIndex = 0; lineIndex < remainingLayout.breaks.length - 1; lineIndex += 1) { remainingLines.push(this.extractLayoutLine(remainingLayout, lineIndex, { measure: measures[Math.min(lineIndex + 1, measures.length - 1)], offset: lineOffsets[Math.min(lineIndex + 1, lineOffsets.length - 1)] || 0, smallCaps: false })); } } return { lines: [firstLine, ...remainingLines].filter(Boolean), processedText: text, lineHeight: layoutOptions.lineHeight, lineHeightPx: layoutOptions.lineHeightPx, fontSize: layoutOptions.fontSize, fontFamily: layoutOptions.fontFamily }; } extractLayoutLine(layout, lineIndex, metadata = {}) { const startBreak = layout.breaks?.[lineIndex]; const endBreak = layout.breaks?.[lineIndex + 1]; if (!startBreak || !endBreak || !Array.isArray(layout.nodes)) return null; const nodes = []; for (let index = startBreak.position; index <= endBreak.position; index += 1) { const node = layout.nodes[index]; if (!node) continue; if (node.type === 'glue' && (index === startBreak.position || index === endBreak.position)) continue; const forcedBreak = window.linebreak?.infinity ? -window.linebreak.infinity : -100000; if (node.type === 'penalty' && node.penalty <= forcedBreak) continue; nodes.push({ ...node }); } const endNode = layout.nodes[endBreak.position]; return { nodes, measure: metadata.measure, offset: metadata.offset || 0, ratio: endBreak.ratio || 0, isFinal: lineIndex === layout.breaks.length - 2, smallCaps: Boolean(metadata.smallCaps), hyphenated: Boolean(endNode?.type === 'penalty' && endNode.penalty === 100), activeStyleTags: this.getActiveStyleTags(layout.nodes, startBreak.position), align: 'justify' }; } extractRemainingLayoutText(layout, breakPosition) { if (!Array.isArray(layout.nodes)) return ''; const fragments = []; for (let index = breakPosition + 1; index < layout.nodes.length; index += 1) { const node = layout.nodes[index]; if (!node) continue; if (node.type === 'box' || node.type === 'tag') { fragments.push(node.value || ''); } else if (node.type === 'glue' && node.width > 0) { fragments.push(' '); } } return fragments.join('').replace(/\s+/g, ' ').trimStart(); } extractLines(layout, options = {}) { if (Array.isArray(layout?.lines)) return layout.lines; 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, hyphenated: Boolean(lineNodes.at(-1)?.type === 'penalty' && lineNodes.at(-1)?.penalty === 100), activeStyleTags: this.getActiveStyleTags(nodes, start), align: options.align || 'justify' }); } return lines; } getActiveStyleTags(nodes = [], endPosition = 0) { const stack = []; for (let index = 0; index < endPosition; index += 1) { const node = nodes[index]; if (node?.type !== 'tag') continue; this.updateStyleTagStack(stack, node.value); } return stack.map(tag => ({ ...tag })); } updateStyleTagStack(stack = [], value = '') { const text = String(value || ''); if (!text.startsWith('<')) return stack; if (text.startsWith(' { 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; } 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, latestBlockId: this.latestBlockId, latestRenderedBlockId: this.latestRenderedBlockId } })); } } const bookPagination = new BookPaginationModule(); export { bookPagination as BookPagination }; if (window.moduleRegistry) { window.moduleRegistry.register(bookPagination); } window.BookPagination = bookPagination;