Files
ai.interactive.fiction/public/js/book-pagination-module.js
T

403 lines
17 KiB
JavaScript

/**
* 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.latestBlockId = 0;
this.latestRenderedBlockId = 0;
this.bindMethods([
'initialize',
'refreshFromHistory',
'buildSpreads',
'layoutTextBlock',
'getDropCapText',
'extractDropCapText',
'measureDropCapReservation',
'measureNormalTextGap',
'calculateDropCapLayout',
'extractLayoutLine',
'extractRemainingLayoutText',
'extractLines',
'countLineWords',
'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.latestBlockId = 0;
this.latestRenderedBlockId = 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.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;
let blockWordCursor = 0;
cursorLine += layout.topSpaceLines;
layout.lines.forEach((line, layoutLineIndex) => {
const geometry = this.getLineGeometry(cursorLine);
const lineWordCount = this.countLineWords(line);
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,
fontStyle: layout.fontStyle,
blockWordStart: blockWordCursor,
dropCapText: layoutLineIndex === 0 ? layout.dropCapText : '',
smallCaps: Boolean(layout.dropCap && layoutLineIndex === 0)
});
blockWordCursor += lineWordCount;
cursorLine += 1;
});
cursorLine += layout.bottomSpaceLines;
});
return spreads.filter(Boolean);
}
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);
return Math.max(inkRight, 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),
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(' ');
} else if (node.type === 'penalty' && node.penalty === 100) {
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),
align: options.align || 'justify'
});
}
return lines;
}
countLineWords(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;
}
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;