项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

664 行
19 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import type { ParsedBook } from '../data/types';
import { dimens } from '../res/dimens';
export interface ReaderLayoutKey {
width: number;
height: number;
fontSize: number;
lineHeightMultiplier: number;
marginPx: number;
firstLineIndent: boolean;
}
export interface ReaderPageBlock {
key: string;
text: string;
indent: boolean;
continuedFromPrevious: boolean;
continuedToNext: boolean;
}
export interface ReaderPaginationPage {
chapterIndex: number;
chapterTitle: string;
pageIndexInChapter: number;
globalPageIndex: number;
startCharOffset: number;
endCharOffset: number;
isChapterStart: boolean;
blocks: ReaderPageBlock[];
}
export interface ReaderPaginationResult {
pages: ReaderPaginationPage[];
pagesByChapter: ReaderPaginationPage[][];
totalPages: number;
totalChars: number;
}
type PreparedParagraph = {
text: string;
index: number;
startCharOffset: number;
endCharOffset: number;
};
type PreparedChapter = {
title: string;
chapterIndex: number;
paragraphs: PreparedParagraph[];
startCharOffset: number;
totalChars: number;
};
type PreparedBook = {
chapters: PreparedChapter[];
totalChars: number;
};
const PARAGRAPH_GAP_PX = 16;
const CHAPTER_TITLE_GAP_PX = 24;
const PAGE_LABEL_GAP_PX = 16;
const PAGE_INFO_GAP_PX = 8;
const NATURAL_BREAK_SCAN = 24;
const NATURAL_BREAK_CHARS = new Set([
'。',
'',
'?',
';',
'',
',',
'、',
'.',
'!',
'?',
';',
':',
',',
')',
'',
']',
'】',
'》',
'"',
'\'',
'”',
'’',
' ',
]);
const paginationCache = new Map<string, ReaderPaginationResult>();
const preparedBookCache = new WeakMap<ParsedBook, PreparedBook>();
type TextMetricsContext = {
ctx: CanvasRenderingContext2D;
fontFamily: string;
charWidthCache: Map<string, number>;
lineHeightPx: number;
bodyWidthPx: number;
firstLineIndentPx: number;
letterSpacingPx: number;
baseBodyHeightPx: number;
chapterTitleHeightPx: number;
chapterLabelHeightPx: number;
};
function normalizeReaderParagraph(text: string): string {
return text.replace(/^[\s\u3000]+/, '');
}
function prepareBook(parsedBook: ParsedBook): PreparedBook {
const cached = preparedBookCache.get(parsedBook);
if (cached) {
return cached;
}
const chapters: PreparedChapter[] = [];
let totalChars = 0;
parsedBook.chapters.forEach((chapter, chapterIndex) => {
let chapterOffset = 0;
const paragraphs = chapter.content
.split('\n')
.filter((line) => line.trim().length > 0)
.map(normalizeReaderParagraph)
.filter(Boolean)
.map((text, paragraphIndex) => {
const startCharOffset = totalChars + chapterOffset;
const next: PreparedParagraph = {
text,
index: paragraphIndex,
startCharOffset,
endCharOffset: startCharOffset + text.length,
};
chapterOffset += text.length;
return next;
});
const chapterChars = paragraphs.reduce((sum, paragraph) => sum + paragraph.text.length, 0);
chapters.push({
title: chapter.title,
chapterIndex,
paragraphs,
startCharOffset: totalChars,
totalChars: chapterChars,
});
totalChars += chapterChars;
});
const prepared = { chapters, totalChars };
preparedBookCache.set(parsedBook, prepared);
return prepared;
}
function clampRatio(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(1, value));
}
export function externalToInternalCharOffset(
externalCharOffset: number,
totalWords: number,
internalTotalChars: number,
): number {
if (internalTotalChars <= 0) return 0;
if (totalWords <= 0) {
return Math.max(0, Math.min(internalTotalChars, Math.round(externalCharOffset)));
}
const ratio = clampRatio(externalCharOffset / totalWords);
return Math.max(0, Math.min(internalTotalChars, Math.round(ratio * internalTotalChars)));
}
export function internalToExternalCharOffset(
internalCharOffset: number,
totalWords: number,
internalTotalChars: number,
): number {
if (totalWords <= 0) return 0;
if (internalTotalChars <= 0) {
return Math.max(0, Math.min(totalWords, Math.round(internalCharOffset)));
}
const ratio = clampRatio(internalCharOffset / internalTotalChars);
return Math.max(0, Math.min(totalWords, Math.round(ratio * totalWords)));
}
function preferNaturalBreak(text: string, length: number): number {
const capped = Math.max(1, Math.min(text.length, length));
const floor = Math.max(1, capped - NATURAL_BREAK_SCAN);
for (let i = capped; i >= floor; i -= 1) {
if (NATURAL_BREAK_CHARS.has(text[i - 1])) {
return i;
}
}
return capped;
}
function buildCacheKey(bookId: string, layout: ReaderLayoutKey): string {
return [
bookId,
layout.width,
layout.height,
layout.fontSize,
layout.lineHeightMultiplier,
layout.marginPx,
layout.firstLineIndent ? 1 : 0,
].join('|');
}
function locateOffset(
preparedBook: PreparedBook,
internalCharOffset: number,
): {
chapter: PreparedChapter;
paragraphIndex: number;
offsetInParagraph: number;
normalizedOffset: number;
} {
const normalizedOffset = Math.max(0, Math.min(preparedBook.totalChars, internalCharOffset));
for (let chapterIndex = 0; chapterIndex < preparedBook.chapters.length; chapterIndex += 1) {
const chapter = preparedBook.chapters[chapterIndex];
const chapterEnd = chapter.startCharOffset + chapter.totalChars;
if (normalizedOffset >= chapterEnd && chapterIndex < preparedBook.chapters.length - 1) {
continue;
}
if (chapter.paragraphs.length === 0) {
return {
chapter,
paragraphIndex: 0,
offsetInParagraph: 0,
normalizedOffset: chapter.startCharOffset,
};
}
if (normalizedOffset <= chapter.startCharOffset) {
return { chapter, paragraphIndex: 0, offsetInParagraph: 0, normalizedOffset: chapter.startCharOffset };
}
for (let paragraphIndex = 0; paragraphIndex < chapter.paragraphs.length; paragraphIndex += 1) {
const paragraph = chapter.paragraphs[paragraphIndex];
if (normalizedOffset < paragraph.endCharOffset) {
return {
chapter,
paragraphIndex,
offsetInParagraph: Math.max(0, normalizedOffset - paragraph.startCharOffset),
normalizedOffset,
};
}
}
const lastParagraphIndex = chapter.paragraphs.length - 1;
return {
chapter,
paragraphIndex: lastParagraphIndex,
offsetInParagraph: chapter.paragraphs[lastParagraphIndex].text.length,
normalizedOffset,
};
}
const lastChapter = preparedBook.chapters[preparedBook.chapters.length - 1];
const lastParagraphIndex = Math.max(0, lastChapter.paragraphs.length - 1);
return {
chapter: lastChapter,
paragraphIndex: lastParagraphIndex,
offsetInParagraph: lastChapter.paragraphs[lastParagraphIndex]?.text.length ?? 0,
normalizedOffset,
};
}
function createTextMetricsContext(layout: ReaderLayoutKey): TextMetricsContext {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas 2D context unavailable for reader pagination');
}
const fontSansVar = getComputedStyle(document.documentElement).getPropertyValue('--font-sans').trim();
const fontFamily = fontSansVar || getComputedStyle(document.body).fontFamily || 'sans-serif';
ctx.font = `${layout.fontSize}px ${fontFamily}`;
const lineHeightPx = layout.fontSize * layout.lineHeightMultiplier;
const bodyWidthPx = Math.max(1, layout.width - layout.marginPx * 2);
const footerHeightPx = dimens.reader_page_footer_size * 1.2 + PAGE_INFO_GAP_PX;
const baseBodyHeightPx = Math.max(
lineHeightPx,
layout.height - dimens.reader_content_padding_top - dimens.reader_content_padding - footerHeightPx,
);
return {
ctx,
fontFamily,
charWidthCache: new Map<string, number>(),
lineHeightPx,
bodyWidthPx,
firstLineIndentPx: layout.firstLineIndent ? layout.fontSize * 2 : 0,
letterSpacingPx: layout.fontSize * 0.025,
baseBodyHeightPx,
chapterTitleHeightPx: Math.round(layout.fontSize * 1.25 * 1.2) + CHAPTER_TITLE_GAP_PX,
chapterLabelHeightPx: Math.round(dimens.reader_page_header_size / 0.75) + PAGE_LABEL_GAP_PX,
};
}
function measureCharWidth(metrics: TextMetricsContext, char: string): number {
const cached = metrics.charWidthCache.get(char);
if (cached !== undefined) {
return cached;
}
const width = metrics.ctx.measureText(char).width;
metrics.charWidthCache.set(char, width);
return width;
}
function getBodyHeightForPage(metrics: TextMetricsContext, isChapterStart: boolean): number {
const reserved = isChapterStart ? metrics.chapterTitleHeightPx : metrics.chapterLabelHeightPx;
return Math.max(metrics.lineHeightPx, metrics.baseBodyHeightPx - reserved);
}
function fitLineCount(
metrics: TextMetricsContext,
text: string,
startIndex: number,
maxWidthPx: number,
): number {
let width = 0;
let endIndex = startIndex;
while (endIndex < text.length) {
const char = text[endIndex];
const charW = measureCharWidth(metrics, char);
const contentWidth = width + charW;
if (contentWidth > maxWidthPx + 0.5) {
break;
}
width = contentWidth + metrics.letterSpacingPx;
endIndex += 1;
}
if (endIndex === startIndex) {
return 1;
}
const rawCount = endIndex - startIndex;
if (rawCount <= 1) {
return rawCount;
}
return preferNaturalBreak(text.slice(startIndex, endIndex), rawCount);
}
function consumeParagraphSegment(
metrics: TextMetricsContext,
text: string,
indent: boolean,
remainingHeightPx: number,
): { text: string; consumedChars: number; usedHeightPx: number } | null {
if (remainingHeightPx < metrics.lineHeightPx - 0.5) {
return null;
}
let consumedChars = 0;
let usedHeightPx = 0;
let firstLine = true;
while (consumedChars < text.length) {
const nextLineHeight = usedHeightPx + metrics.lineHeightPx;
if (nextLineHeight > remainingHeightPx + 0.5) {
break;
}
const maxWidthPx = Math.max(
metrics.lineHeightPx,
metrics.bodyWidthPx - (firstLine && indent ? metrics.firstLineIndentPx : 0),
);
const lineCount = fitLineCount(metrics, text, consumedChars, maxWidthPx);
consumedChars += Math.max(1, lineCount);
usedHeightPx = nextLineHeight;
firstLine = false;
}
if (consumedChars <= 0) {
return null;
}
usedHeightPx += PARAGRAPH_GAP_PX;
return {
text: text.slice(0, consumedChars),
consumedChars,
usedHeightPx,
};
}
function paginatePreparedChapters(
chapters: PreparedChapter[],
totalChars: number,
metrics: TextMetricsContext,
): ReaderPaginationResult {
const pagesByChapter: ReaderPaginationPage[][] = [];
const pages: ReaderPaginationPage[] = [];
let globalPageIndex = 0;
chapters.forEach((chapter) => {
const chapterPages: ReaderPaginationPage[] = [];
let paragraphIndex = 0;
let offsetInParagraph = 0;
let currentCharOffset = chapter.startCharOffset;
if (chapter.paragraphs.length === 0) {
const emptyPage: ReaderPaginationPage = {
chapterIndex: chapter.chapterIndex,
chapterTitle: chapter.title,
pageIndexInChapter: 0,
globalPageIndex,
startCharOffset: currentCharOffset,
endCharOffset: currentCharOffset,
isChapterStart: true,
blocks: [],
};
chapterPages.push(emptyPage);
pages.push(emptyPage);
globalPageIndex += 1;
pagesByChapter.push(chapterPages);
return;
}
while (paragraphIndex < chapter.paragraphs.length) {
const isChapterStart = chapterPages.length === 0;
const pageStartOffset = currentCharOffset;
const blocks: ReaderPageBlock[] = [];
let remainingHeightPx = getBodyHeightForPage(metrics, isChapterStart);
while (paragraphIndex < chapter.paragraphs.length) {
const paragraph = chapter.paragraphs[paragraphIndex];
const remainingText = paragraph.text.slice(offsetInParagraph);
if (!remainingText) {
paragraphIndex += 1;
offsetInParagraph = 0;
continue;
}
const indent = offsetInParagraph === 0 && metrics.firstLineIndentPx > 0;
const segment = consumeParagraphSegment(metrics, remainingText, indent, remainingHeightPx);
if (!segment) {
if (blocks.length === 0) {
const forcedChars = Math.min(1, remainingText.length);
blocks.push({
key: `${chapter.chapterIndex}-${paragraph.index}-${offsetInParagraph}`,
text: remainingText.slice(0, forcedChars),
indent,
continuedFromPrevious: offsetInParagraph > 0,
continuedToNext: forcedChars < remainingText.length,
});
currentCharOffset += forcedChars;
remainingHeightPx = 0;
if (forcedChars < remainingText.length) {
offsetInParagraph += forcedChars;
} else {
paragraphIndex += 1;
offsetInParagraph = 0;
}
}
break;
}
if (segment.consumedChars >= remainingText.length) {
blocks.push({
key: `${chapter.chapterIndex}-${paragraph.index}-${offsetInParagraph}`,
text: remainingText,
indent,
continuedFromPrevious: offsetInParagraph > 0,
continuedToNext: false,
});
currentCharOffset += remainingText.length;
remainingHeightPx -= segment.usedHeightPx;
paragraphIndex += 1;
offsetInParagraph = 0;
continue;
}
blocks.push({
key: `${chapter.chapterIndex}-${paragraph.index}-${offsetInParagraph}`,
text: segment.text,
indent,
continuedFromPrevious: offsetInParagraph > 0,
continuedToNext: true,
});
currentCharOffset += segment.consumedChars;
remainingHeightPx -= segment.usedHeightPx;
offsetInParagraph += segment.consumedChars;
break;
}
const pageEndOffset = currentCharOffset;
const page: ReaderPaginationPage = {
chapterIndex: chapter.chapterIndex,
chapterTitle: chapter.title,
pageIndexInChapter: chapterPages.length,
globalPageIndex,
startCharOffset: pageStartOffset,
endCharOffset: pageEndOffset,
isChapterStart,
blocks,
};
chapterPages.push(page);
pages.push(page);
globalPageIndex += 1;
}
pagesByChapter.push(chapterPages);
});
return {
pages,
pagesByChapter,
totalPages: pages.length,
totalChars,
};
}
function buildPageFromLocation(
chapter: PreparedChapter,
paragraphIndex: number,
offsetInParagraph: number,
pageStartOffset: number,
metrics: TextMetricsContext,
): ReaderPaginationPage {
const isChapterStart = pageStartOffset <= chapter.startCharOffset;
const blocks: ReaderPageBlock[] = [];
let currentParagraphIndex = paragraphIndex;
let currentOffsetInParagraph = offsetInParagraph;
let currentCharOffset = pageStartOffset;
let remainingHeightPx = getBodyHeightForPage(metrics, isChapterStart);
while (currentParagraphIndex < chapter.paragraphs.length) {
const paragraph = chapter.paragraphs[currentParagraphIndex];
const remainingText = paragraph.text.slice(currentOffsetInParagraph);
if (!remainingText) {
currentParagraphIndex += 1;
currentOffsetInParagraph = 0;
continue;
}
const indent = currentOffsetInParagraph === 0 && metrics.firstLineIndentPx > 0;
const segment = consumeParagraphSegment(metrics, remainingText, indent, remainingHeightPx);
if (!segment) {
if (blocks.length === 0) {
const forcedChars = Math.min(1, remainingText.length);
blocks.push({
key: `${chapter.chapterIndex}-${paragraph.index}-${currentOffsetInParagraph}`,
text: remainingText.slice(0, forcedChars),
indent,
continuedFromPrevious: currentOffsetInParagraph > 0,
continuedToNext: forcedChars < remainingText.length,
});
currentCharOffset += forcedChars;
}
break;
}
blocks.push({
key: `${chapter.chapterIndex}-${paragraph.index}-${currentOffsetInParagraph}`,
text: segment.text,
indent,
continuedFromPrevious: currentOffsetInParagraph > 0,
continuedToNext: segment.consumedChars < remainingText.length,
});
currentCharOffset += segment.consumedChars;
remainingHeightPx -= segment.usedHeightPx;
if (segment.consumedChars < remainingText.length) {
currentOffsetInParagraph += segment.consumedChars;
break;
}
currentParagraphIndex += 1;
currentOffsetInParagraph = 0;
}
return {
chapterIndex: chapter.chapterIndex,
chapterTitle: chapter.title,
pageIndexInChapter: 0,
globalPageIndex: 0,
startCharOffset: pageStartOffset,
endCharOffset: currentCharOffset,
isChapterStart,
blocks,
};
}
export function paginateBook(
bookId: string,
parsedBook: ParsedBook,
layout: ReaderLayoutKey,
): ReaderPaginationResult {
const cacheKey = buildCacheKey(bookId, layout);
const cached = paginationCache.get(cacheKey);
if (cached) {
return cached;
}
const prepared = prepareBook(parsedBook);
const metrics = createTextMetricsContext(layout);
const result = paginatePreparedChapters(prepared.chapters, prepared.totalChars, metrics);
paginationCache.set(cacheKey, result);
return result;
}
export function findPageForCharOffset(
result: ReaderPaginationResult,
charOffset: number,
): ReaderPaginationPage | null {
if (result.pages.length === 0) return null;
if (charOffset <= 0) return result.pages[0];
if (charOffset >= result.totalChars) return result.pages[result.pages.length - 1];
let lo = 0;
let hi = result.pages.length - 1;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (result.pages[mid].endCharOffset <= charOffset) {
lo = mid + 1;
} else {
hi = mid;
}
}
return result.pages[lo];
}
export function getChapterFirstPage(
result: ReaderPaginationResult,
chapterIndex: number,
): ReaderPaginationPage | null {
return result.pagesByChapter[chapterIndex]?.[0] ?? null;
}
export function resolvePageAtOffset(
parsedBook: ParsedBook,
layout: ReaderLayoutKey,
internalCharOffset: number,
): ReaderPaginationPage | null {
const preparedBook = prepareBook(parsedBook);
if (preparedBook.chapters.length === 0) return null;
const location = locateOffset(preparedBook, internalCharOffset);
const metrics = createTextMetricsContext(layout);
return buildPageFromLocation(
location.chapter,
location.paragraphIndex,
location.offsetInParagraph,
location.normalizedOffset,
metrics,
);
}
export function getBookTotalChars(parsedBook: ParsedBook): number {
return prepareBook(parsedBook).totalChars;
}
export function getChapterStartOffset(parsedBook: ParsedBook, chapterIndex: number): number {
const prepared = prepareBook(parsedBook);
return prepared.chapters[chapterIndex]?.startCharOffset ?? 0;
}