1018 lines
44 KiB
JavaScript
1018 lines
44 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.pages = [];
|
|
this.currentSpreadIndex = 0;
|
|
this.refreshToken = 0;
|
|
this.latestBlockId = 0;
|
|
this.latestRenderedBlockId = 0;
|
|
this.appliedPageReserveBlocks = new Set();
|
|
this.preparedBlockCache = new Map();
|
|
|
|
this.bindMethods([
|
|
'initialize',
|
|
'refreshFromHistory',
|
|
'preparePendingBlock',
|
|
'getPreparedBlockCacheKey',
|
|
'rememberPreparedBlock',
|
|
'takePreparedBlock',
|
|
'clearPreparedBlocks',
|
|
'buildSpreads',
|
|
'buildPages',
|
|
'buildSpreadsFromPages',
|
|
'applyPageReserveDirective',
|
|
'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',
|
|
'findSpreadIndexForBlock',
|
|
'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));
|
|
const targetSpread = Number(event.detail?.targetSpread);
|
|
if (Number.isFinite(targetSpread)) {
|
|
this.setCurrentSpread(targetSpread);
|
|
} else if (direction !== 0) {
|
|
this.setCurrentSpread(this.currentSpreadIndex + direction);
|
|
}
|
|
});
|
|
this.pages = this.buildPages([]);
|
|
this.spreads = this.buildSpreadsFromPages(this.pages);
|
|
this.currentSpreadIndex = 0;
|
|
this.publish({ reason: 'initial-title-spread', visibility: 'future-ready' });
|
|
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.clearPreparedBlocks();
|
|
this.refreshFromHistory();
|
|
}
|
|
|
|
async refreshFromHistory(event = null) {
|
|
const token = ++this.refreshToken;
|
|
this.clearPreparedBlocks();
|
|
const detail = event?.detail || {};
|
|
const gameId = detail.gameId || this.storyHistory?.currentGameId || null;
|
|
const latestRenderedBlockId = Math.max(
|
|
0,
|
|
Number(detail.latestRenderedBlockId || this.storyHistory?.latestRenderedBlockId || 0)
|
|
);
|
|
const latestBlockId = Math.max(
|
|
0,
|
|
Number(detail.latestBlockId || (this.storyHistory?.nextBlockId || 1) - 1 || latestRenderedBlockId)
|
|
);
|
|
const continuationBlockId = this.getContinuationBlockId(latestBlockId, latestRenderedBlockId);
|
|
const paginationEndBlockId = Math.max(latestRenderedBlockId, continuationBlockId);
|
|
if (!gameId || paginationEndBlockId <= 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.appliedPageReserveBlocks.clear();
|
|
this.publish({ reason: 'empty-history' });
|
|
return;
|
|
}
|
|
|
|
const blocks = await this.storyHistory.getBlocksRange(gameId, 1, paginationEndBlockId);
|
|
if (token !== this.refreshToken) return;
|
|
this.latestBlockId = latestBlockId;
|
|
this.latestRenderedBlockId = latestRenderedBlockId;
|
|
this.pages = this.buildPages(blocks);
|
|
this.spreads = this.buildSpreadsFromPages(this.pages);
|
|
this.persistPaginationMetrics(this.pages);
|
|
const continuationSpreadIndex = this.findSpreadIndexForBlock(continuationBlockId);
|
|
const renderedSpreadIndex = this.findSpreadIndexForBlock(latestRenderedBlockId);
|
|
this.currentSpreadIndex = continuationSpreadIndex >= 0
|
|
? continuationSpreadIndex
|
|
: renderedSpreadIndex >= 0
|
|
? renderedSpreadIndex
|
|
: Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
|
|
this.publish({ reason: 'history-refresh', visibility: 'future-ready' });
|
|
}
|
|
|
|
getContinuationBlockId(latestBlockId = 0, latestRenderedBlockId = 0) {
|
|
const latest = Math.max(0, Number(latestBlockId || 0));
|
|
const rendered = Math.max(0, Number(latestRenderedBlockId || 0));
|
|
if (latest <= 0) return 0;
|
|
if (rendered <= 0) return 1;
|
|
return rendered < latest ? rendered + 1 : latest;
|
|
}
|
|
|
|
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 cacheKey = this.getPreparedBlockCacheKey(gameId, pendingBlockId, historyEndBlockId, latestRenderedBlockId, options);
|
|
const cached = options.activate !== false ? this.takePreparedBlock(cacheKey) : null;
|
|
if (cached) {
|
|
this.latestBlockId = pendingBlockId;
|
|
this.latestRenderedBlockId = latestRenderedBlockId;
|
|
this.pages = cached.pages;
|
|
this.spreads = cached.spreads;
|
|
this.currentSpreadIndex = cached.targetSpread
|
|
? cached.targetSpread.index
|
|
: Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
|
|
if (options.publish !== false) this.publish({ reason: 'prepared-cache-activate' });
|
|
document.dispatchEvent(new CustomEvent('book-pagination:block-prepared', {
|
|
detail: {
|
|
blockId: pendingBlockId,
|
|
spread: cached.targetSpread || this.getCurrentSpread(),
|
|
spreadIndex: cached.targetSpread?.index ?? this.currentSpreadIndex,
|
|
latestBlockId: pendingBlockId,
|
|
latestRenderedBlockId,
|
|
phase: 'activate',
|
|
reusedPreparedPagination: true
|
|
}
|
|
}));
|
|
return cached.targetSpread || this.getCurrentSpread();
|
|
}
|
|
|
|
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.rememberPreparedBlock(cacheKey, {
|
|
pages: preparedPages,
|
|
spreads: preparedSpreads,
|
|
targetSpread: targetSpread || null
|
|
});
|
|
}
|
|
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({ reason: options.activate === false ? 'prepare-preload' : 'prepare-activate' });
|
|
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,
|
|
phase: options.activate === false ? 'prepare' : 'activate'
|
|
}
|
|
}));
|
|
return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread());
|
|
}
|
|
|
|
getPreparedBlockCacheKey(gameId, blockId, historyEndBlockId, latestRenderedBlockId, options = {}) {
|
|
const includeUnrendered = options.includeUnrenderedHistory === true ? 'unrendered' : 'rendered';
|
|
return [
|
|
gameId || 'game',
|
|
Math.max(0, Number(blockId || 0)),
|
|
Math.max(0, Number(historyEndBlockId || 0)),
|
|
Math.max(0, Number(latestRenderedBlockId || 0)),
|
|
includeUnrendered,
|
|
this.metrics?.width || 0,
|
|
this.metrics?.height || 0
|
|
].join(':');
|
|
}
|
|
|
|
rememberPreparedBlock(key, prepared) {
|
|
if (!key || !prepared?.pages || !prepared?.spreads) return;
|
|
this.preparedBlockCache.set(key, prepared);
|
|
while (this.preparedBlockCache.size > 12) {
|
|
const oldestKey = this.preparedBlockCache.keys().next().value;
|
|
this.preparedBlockCache.delete(oldestKey);
|
|
}
|
|
}
|
|
|
|
takePreparedBlock(key) {
|
|
if (!key || !this.preparedBlockCache.has(key)) return null;
|
|
const prepared = this.preparedBlockCache.get(key);
|
|
this.preparedBlockCache.delete(key);
|
|
return prepared;
|
|
}
|
|
|
|
clearPreparedBlocks() {
|
|
this.preparedBlockCache.clear();
|
|
}
|
|
|
|
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';
|
|
this.applyPageReserveDirective(block);
|
|
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,
|
|
lineWordCount,
|
|
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();
|
|
const normalizedPages = this.normalizePagesForSpreads(pages);
|
|
normalizedPages.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);
|
|
}
|
|
|
|
normalizePagesForSpreads(pages = []) {
|
|
const source = Array.isArray(pages) ? pages : [];
|
|
const lastPageIndex = source.reduce((max, page, index) => {
|
|
const explicitIndex = Number(page?.index);
|
|
return Math.max(max, Number.isFinite(explicitIndex) ? explicitIndex : index);
|
|
}, 1);
|
|
const lastSpreadRightIndex = Math.max(1, lastPageIndex % 2 === 0 ? lastPageIndex + 1 : lastPageIndex);
|
|
const normalized = [];
|
|
for (let index = 0; index <= lastSpreadRightIndex; index += 1) {
|
|
normalized[index] = source[index] || this.createBlankPage(index, {
|
|
section: index < 3 ? 'frontmatter' : 'body'
|
|
});
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
applyPageReserveDirective(block = {}) {
|
|
const directive = block?.metadata?.pageReserve || block?.pageReserve || null;
|
|
const blockId = Number(block?.blockId || block?.metadata?.blockId || 0);
|
|
const gameId = block?.gameId || block?.metadata?.gameId || this.storyHistory?.currentGameId || 'default';
|
|
const key = `${gameId}:${blockId}`;
|
|
if (!directive || blockId <= 0 || this.appliedPageReserveBlocks.has(key)) return;
|
|
const value = Number(directive.value);
|
|
if (!Number.isFinite(value)) return;
|
|
this.appliedPageReserveBlocks.add(key);
|
|
document.dispatchEvent(new CustomEvent('webgl-book:page-reserve-directive', {
|
|
detail: {
|
|
blockId,
|
|
value,
|
|
unit: directive.unit === 'percent' ? 'percent' : 'pages'
|
|
}
|
|
}));
|
|
}
|
|
|
|
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 (stack.length) stack.pop();
|
|
return stack;
|
|
}
|
|
const template = document.createElement('div');
|
|
template.innerHTML = text;
|
|
const element = template.firstElementChild;
|
|
if (!element) return stack;
|
|
const tagName = element.tagName.toLowerCase();
|
|
const style = String(element.getAttribute('style') || '').toLowerCase();
|
|
const className = String(element.getAttribute('class') || '').toLowerCase();
|
|
stack.push({
|
|
tagName,
|
|
bold: tagName === 'strong' || tagName === 'b' || /font-weight\s*:\s*(bold|[6-9]00)/.test(style) || className.includes('bold'),
|
|
italic: tagName === 'em' || tagName === 'i' || /font-style\s*:\s*italic/.test(style) || className.includes('italic')
|
|
});
|
|
return stack;
|
|
}
|
|
|
|
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: [] };
|
|
}
|
|
|
|
findSpreadIndexForBlock(blockId) {
|
|
const id = Math.max(0, Number(blockId || 0));
|
|
if (id <= 0) return -1;
|
|
const spread = this.spreads.find(entry => ['left', 'right'].some((side) => {
|
|
const lines = Array.isArray(entry?.[side]) ? entry[side] : [];
|
|
return lines.some(line => Number(line?.blockId || 0) === id);
|
|
}));
|
|
return Number.isFinite(Number(spread?.index)) ? Math.max(0, Math.round(Number(spread.index))) : -1;
|
|
}
|
|
|
|
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({ reason: 'set-current-spread', visibility: 'future-ready' });
|
|
return this.currentSpreadIndex;
|
|
}
|
|
|
|
publish(options = {}) {
|
|
const writtenPageLimit = Math.max(0, (Math.max(0, this.spreads.length - 1) * 2) - 1);
|
|
document.dispatchEvent(new CustomEvent('book-pagination:spread-updated', {
|
|
detail: {
|
|
spread: this.getCurrentSpread(),
|
|
spreadIndex: this.currentSpreadIndex,
|
|
spreadCount: this.spreads.length,
|
|
writtenPageLimit,
|
|
latestBlockId: this.latestBlockId,
|
|
latestRenderedBlockId: this.latestRenderedBlockId,
|
|
reason: options.reason || 'publish',
|
|
visibility: options.visibility || 'current'
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
const bookPagination = new BookPaginationModule();
|
|
|
|
export { bookPagination as BookPagination };
|
|
|
|
if (window.moduleRegistry) {
|
|
window.moduleRegistry.register(bookPagination);
|
|
}
|
|
|
|
window.BookPagination = bookPagination;
|