mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
chore(web): remove the reading engine and helpers nothing runs
An import graph from main.tsx shows a previous reader still in the tree:
render/{reader,paged,pdf,text,container,theme}.ts with lib/{events,locator}.ts,
ui/settings.ts and bridge.ts — superseded by ReaderScreen/ReaderView and
never imported. Also unreachable: render/paginator.ts, render/window.ts and
ui/shelf-order.ts (only their own tests used them; the shelf sorts on the
server), formats/remote-zip.ts (re-exported, never called; chapters are
fetched per section), ui/source-capabilities.tsx and the sources pages barrel.
Inside live modules: PrefetchCache (only shouldPrefetch is used),
textToParagraphs / paragraphElement / endsAtParagraphBreak (the streaming
TXT reader's carry), txt.ts's textToHtml alias, and ChunkCursor. Their tests
go with them; the node:test list, the Vitest excludes and the docs that
named these files are updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
12fbe0cbcd
commit
d8b3491e95
33 files changed
+18
-3362
No files matched your search
@@ -827,8 +827,8 @@ Android WebView 是真实会发生的)才推进,不是为了给朗读计时
|
||||
这是 `zip-reader.ts` 只解析中央目录、`openStream` 按偏移读单个条目的原因;
|
||||
整包 `loadAsync` 会让「翻到下一章」变成「重新解压整本书」。
|
||||
|
||||
客户端的对等物是 `formats/remote-zip.ts`:同样的中央目录解析,走 HTTP Range。
|
||||
它存在的意义是让**浏览器端**也能不下整本书——否则「服务端分章」只对原生端成立。
|
||||
客户端不解析压缩包:分章的书按章向服务端要正文(`formats/windowed.ts` 的 `loadSection`),
|
||||
所以浏览器端同样不必下整本书。
|
||||
|
||||
(注意:`check-shell.sh` 里新增的渲染模式词表校验,以及 `colors.xml` 里
|
||||
`--` 在 XML 注释中非法、Kotlin 侧 `Uint8Array` 不存在这两处,
|
||||
|
||||
@@ -79,8 +79,8 @@ cd server && npx tsx --test test/media-api.test.ts
|
||||
|
||||
# Most Web tests use Vitest
|
||||
cd web && npx vitest run test/foo.test.ts
|
||||
# The few files written for node:test (api, asset-url, paginator, window, windowed-toc)
|
||||
cd web && npx tsx --test test/paginator.test.ts
|
||||
# The few files written for node:test (api, asset-url, windowed-toc)
|
||||
cd web && npx tsx --test test/windowed-toc.test.ts
|
||||
```
|
||||
|
||||
The Web package has two test runners: `npm test` runs Vitest, then runs the node:test files above with `tsx --test`; those files are excluded from Vitest in `web/vite.config.ts`. When adding a node:test file, add it to both the `test` script and the Vitest `exclude` list. The Web suite imports server code, so run `npm ci` for both packages first.
|
||||
|
||||
@@ -79,8 +79,8 @@ cd server && npx tsx --test test/media-api.test.ts
|
||||
|
||||
# Web 大多数测试使用 Vitest
|
||||
cd web && npx vitest run test/foo.test.ts
|
||||
# 少数为 node:test 编写的文件(api、asset-url、paginator、window、windowed-toc)
|
||||
cd web && npx tsx --test test/paginator.test.ts
|
||||
# 少数为 node:test 编写的文件(api、asset-url、windowed-toc)
|
||||
cd web && npx tsx --test test/windowed-toc.test.ts
|
||||
```
|
||||
|
||||
Web 有两套测试运行器:`npm test` 先跑 Vitest,再用 `tsx --test` 跑上述 node:test 文件;这些文件在 `web/vite.config.ts` 中被 Vitest 排除。新增 node:test 文件时,要同时加入 `test` 脚本和 Vitest 的 `exclude` 列表。Web 测试会引用服务端代码,运行前两端都需要 `npm ci`。
|
||||
|
||||
+1
-3
@@ -815,11 +815,9 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
| `web/src/features/shelf/pages/ShelfPage.tsx` | 书架;排序、分页器与书库入口 |
|
||||
| `web/src/features/library/pages/LibraryPage.tsx` | 书库浏览、书籍列表和文件管理路由;搜索、面包屑、分页器、上传后重读 |
|
||||
| `web/src/ui/shelf-membership.ts` | 两个方向的措辞与批量报告那句话的唯一来源(§3.5.2) |
|
||||
| `web/src/ui/shelf-order.ts` | 排序的**唯一**定义:方向、客户端/服务端的键怎么翻译、阅读时间怎么排 |
|
||||
| `web/src/ui/sheet.tsx` / `web/src/styles/sheet.css` | 唯一的弹层 `Sheet` 与 `ConfirmSheet`(§3.7) |
|
||||
| `web/src/ui/dialog.tsx` | 提问(文本框/确认/选择),基于 `Sheet`,书架与文件管理共用 |
|
||||
| `web/src/ui/pager.tsx` | 翻页器组件(§3.5);书架和书库目前各自画「上一页 / 下一页」,没有用它 |
|
||||
| `web/test/shelf-order.test.ts` | 排序的方向、客户端/服务端的键、阅读时间排序与并列时的稳定 |
|
||||
| `web/src/ui/pager.tsx` | 翻页器组件(§3.5),书架、书库浏览和文件管理共用 |
|
||||
| `web/test/react-router-routes.test.tsx` | React Router 的页面、影音嵌套路由和权限边界 |
|
||||
| `web/test/media-library-list.test.tsx` | 媒体库列表、分页和空状态 |
|
||||
| `web/test/media-library-editor.test.tsx` | 媒体库编辑、权限和保存后的重读 |
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run && tsx --test test/api.test.ts test/asset-url.test.ts test/paginator.test.ts test/window.test.ts test/windowed-toc.test.ts",
|
||||
"test": "vitest run && tsx --test test/api.test.ts test/asset-url.test.ts test/windowed-toc.test.ts",
|
||||
"test:watch": "vitest",
|
||||
"ui:review": "npm run build && node tools/ui-review/react-app.mjs",
|
||||
"ui:review:only": "node tools/ui-review/react-app.mjs",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* The Android shell's interface.
|
||||
*
|
||||
* The bridge is a plain object on `window`, written by the shell before this
|
||||
* bundle runs. No JavaScript is evaluated from the native side and no message is
|
||||
* passed as a string to be re-parsed: the shell is same-process, so a plain
|
||||
* object is both simpler and harder to get wrong than a postMessage protocol.
|
||||
*
|
||||
* What the shell owns, and why it is not done in JavaScript:
|
||||
*
|
||||
* - **Gestures on paged content.** The shell renders comic pages in a native
|
||||
* pager because a WebView's per-page memory is what kills a 300MB volume on a
|
||||
* mid-range phone. A native pager handles its own swipes; the H5 layer is not
|
||||
* involved.
|
||||
* - **The offline cache.** A book downloaded for offline reading lives in the
|
||||
* shell's file cache, which is what lets it be read with the server unreachable.
|
||||
* - **The account.** Tokens live in the shell's encrypted storage, so the H5
|
||||
* layer never writes a refresh token into a WebView's localStorage where any
|
||||
* injected script could read it.
|
||||
*
|
||||
* What the H5 layer owns: reflowable rendering, the shelf, and settings. Those
|
||||
* are the parts that benefit from being the same code in both builds.
|
||||
*/
|
||||
|
||||
export interface ReaderBridge {
|
||||
/** Server origin, e.g. `http://192.168.1.10:8080`. */
|
||||
serverUrl?: string;
|
||||
platform: 'android';
|
||||
/** Device name recorded with each progress update. */
|
||||
deviceName?: string;
|
||||
/** Called when a book is opened, so the shell can take over input. */
|
||||
onBookOpened?(bookId: string): void;
|
||||
/** The shell hands back a page-turn function for hardware keys and gestures. */
|
||||
onTurnPage?(turn: (pages: number) => void): void;
|
||||
/** Ask the shell to cache a book for offline reading. */
|
||||
cacheBook?(bookId: string): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
readerBridge?: ReaderBridge;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the bridge, or undefined when running as a plain web page.
|
||||
*
|
||||
* A missing bridge is the normal case for the H5 deployment, not an error.
|
||||
*/
|
||||
export function installBridge(): ReaderBridge | undefined {
|
||||
const bridge = window.readerBridge;
|
||||
if (!bridge) return undefined;
|
||||
return { ...bridge, platform: 'android' };
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { SourcesPage } from './SourcesPage.tsx';
|
||||
export { SourceCatalogPage } from './SourceCatalogPage.tsx';
|
||||
export { SourceCredentialsPage } from './SourceCredentialsPage.tsx';
|
||||
export { SourceEditorPage } from './SourceEditorPage.tsx';
|
||||
export { SourceExtensionPage } from './SourceExtensionPage.tsx';
|
||||
@@ -1,356 +0,0 @@
|
||||
/**
|
||||
* Read-only ZIP access over HTTP byte ranges.
|
||||
*
|
||||
* The whole point of windowed loading is that a reader of a 1200-chapter omnibus
|
||||
* downloads one chapter, not a 50MB archive. That only holds if the client can
|
||||
* read the archive without having all of it, so this is the client-side half of
|
||||
* the server's `Accept-Ranges`.
|
||||
*
|
||||
* Two decisions carry the design:
|
||||
*
|
||||
* 1. **The central directory is read once, then reused.** A ZIP's index is at the
|
||||
* end of the file; every entry lookup after that is arithmetic on buffers
|
||||
* already held. Re-reading the directory per chapter would turn one range
|
||||
* request into two per turn.
|
||||
* 2. **`deflate` entries are inflated per read, `stored` entries are sliced.**
|
||||
* A `stored` page can be handed to the browser as a subrange (which is why
|
||||
* `resourceUrl` reports the offset rather than the bytes); a `deflate` entry
|
||||
* has to be inflated in JavaScript before anything can look at it, and the
|
||||
* server says honestly which is which so we never guess.
|
||||
*
|
||||
* The fallback matters as much as the fast path: a server (or a proxy) that
|
||||
* ignores `Range` must not break the reader, so every read verifies it got the
|
||||
* bytes it asked for and falls back to a whole-file download when it did not.
|
||||
*/
|
||||
|
||||
const EOCD_SIGNATURE = 0x06054b50;
|
||||
const EOCD64_LOCATOR_SIGNATURE = 0x07064b50;
|
||||
const EOCD64_SIGNATURE = 0x06064b50;
|
||||
const CENTRAL_SIGNATURE = 0x02014b50;
|
||||
const LOCAL_SIGNATURE = 0x04034b50;
|
||||
const STORED = 0;
|
||||
const DEFLATED = 8;
|
||||
|
||||
/** How much of the tail to search for the end-of-central-directory record. */
|
||||
const EOCD_SEARCH_LIMIT = 66_000;
|
||||
|
||||
export interface ZipEntry {
|
||||
path: string;
|
||||
/** Compression method; only `store` (0) and `deflate` (8) are understood. */
|
||||
method: number;
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
/**
|
||||
* Offset of the entry's *local header*, not its data.
|
||||
*
|
||||
* The data offset is only knowable after reading that header (it carries its
|
||||
* own name/extra lengths, which the central directory does not repeat), so it
|
||||
* is resolved lazily in `dataRange`. Storing a guessed offset here was the
|
||||
* first version of this file, and it silently served the wrong bytes on any
|
||||
* archive whose local extra fields differed from its central ones.
|
||||
*/
|
||||
headerOffset: number;
|
||||
}
|
||||
|
||||
/** Bytes fetched from a URL, with the ability to ask for subranges. */
|
||||
export interface RangeSource {
|
||||
size(): Promise<number>;
|
||||
/** Reads `[start, end]` inclusive. Rejects when the server cannot serve it. */
|
||||
read(start: number, end: number): Promise<Uint8Array>;
|
||||
/** Reads the whole file. Used only when ranges are unavailable. */
|
||||
whole(): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
export class HttpRangeSource implements RangeSource {
|
||||
private knownSize: number | null = null;
|
||||
private wholeFile: Uint8Array | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly fetchBytes: (range?: { start: number; end: number }) => Promise<{
|
||||
bytes: Uint8Array;
|
||||
total?: number;
|
||||
ranged: boolean;
|
||||
}>,
|
||||
) {}
|
||||
|
||||
async size(): Promise<number> {
|
||||
if (this.knownSize !== null) return this.knownSize;
|
||||
if (this.wholeFile) return this.wholeFile.byteLength;
|
||||
const probe = await this.fetchBytes({ start: 0, end: 0 });
|
||||
if (!probe.ranged || probe.total === undefined) {
|
||||
// No range support: the only honest answer is to hold the file.
|
||||
await this.whole();
|
||||
return this.knownSize ?? 0;
|
||||
}
|
||||
this.knownSize = probe.total;
|
||||
return probe.total;
|
||||
}
|
||||
|
||||
async read(start: number, end: number): Promise<Uint8Array> {
|
||||
if (end < start) return new Uint8Array();
|
||||
if (this.wholeFile) return this.wholeFile.subarray(start, end + 1);
|
||||
const result = await this.fetchBytes({ start, end });
|
||||
if (!result.ranged) {
|
||||
// A server that answered a range request with the whole body. Correct, if
|
||||
// wasteful; keep it and satisfy every later read from memory.
|
||||
this.wholeFile = result.bytes;
|
||||
this.knownSize = result.bytes.byteLength;
|
||||
return result.bytes.subarray(start, end + 1);
|
||||
}
|
||||
if (result.total !== undefined) this.knownSize = result.total;
|
||||
// A short read is not something to paper over: it means the range was
|
||||
// ignored, and returning fewer bytes than asked would corrupt the inflate.
|
||||
if (result.bytes.byteLength !== end - start + 1) {
|
||||
throw new Error(`range ${start}-${end} returned ${result.bytes.byteLength} bytes`);
|
||||
}
|
||||
return result.bytes;
|
||||
}
|
||||
|
||||
async whole(): Promise<Uint8Array> {
|
||||
if (this.wholeFile) return this.wholeFile;
|
||||
const result = await this.fetchBytes();
|
||||
this.wholeFile = result.bytes;
|
||||
this.knownSize = result.bytes.byteLength;
|
||||
return result.bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A ZIP archive read over a `RangeSource`.
|
||||
*
|
||||
* Entries are addressable by path and by the order they appear in the central
|
||||
* directory (which is the archive's own order, not sorted).
|
||||
*/
|
||||
export class RemoteZip {
|
||||
/** Local headers already parsed, so a chapter read does not repeat the work. */
|
||||
private readonly dataOffsets = new Map<string, number>();
|
||||
|
||||
private constructor(
|
||||
private readonly source: RangeSource,
|
||||
private readonly entries: Map<string, ZipEntry>,
|
||||
private readonly order: ZipEntry[],
|
||||
) {}
|
||||
|
||||
static async open(source: RangeSource): Promise<RemoteZip> {
|
||||
const size = await source.size();
|
||||
if (size === 0) throw new Error('empty archive');
|
||||
const tailLength = Math.min(size, EOCD_SEARCH_LIMIT);
|
||||
const tail = await source.read(size - tailLength, size - 1);
|
||||
const eocdOffset = findEocd(tail);
|
||||
if (eocdOffset < 0) throw new Error('not a zip archive: no end-of-central-directory record');
|
||||
|
||||
const view = new DataView(tail.buffer, tail.byteOffset, tail.byteLength);
|
||||
let centralSize = view.getUint32(eocdOffset + 12, true);
|
||||
let centralOffset = view.getUint32(eocdOffset + 16, true);
|
||||
|
||||
// ZIP64 stores sizes that do not fit in 32 bits in a separate record; the
|
||||
// locator in front of the EOCD points at it. A self-hosted comic library is
|
||||
// exactly where a >4GB archive turns up.
|
||||
const locatorOffset = eocdOffset - 20;
|
||||
if (locatorOffset >= 0 && view.getUint32(locatorOffset, true) === EOCD64_LOCATOR_SIGNATURE) {
|
||||
const eocd64 = await source.read(
|
||||
Number(view.getBigUint64(locatorOffset + 8, true)),
|
||||
Number(view.getBigUint64(locatorOffset + 8, true)) + 55,
|
||||
);
|
||||
const view64 = new DataView(eocd64.buffer, eocd64.byteOffset, eocd64.byteLength);
|
||||
if (view64.getUint32(0, true) === EOCD64_SIGNATURE) {
|
||||
centralSize = Number(view64.getBigUint64(40, true));
|
||||
centralOffset = Number(view64.getBigUint64(48, true));
|
||||
}
|
||||
}
|
||||
|
||||
const directory = await source.read(centralOffset, centralOffset + centralSize - 1);
|
||||
const entries = parseCentralDirectory(directory);
|
||||
if (entries.length === 0) throw new Error('zip central directory is empty');
|
||||
return new RemoteZip(
|
||||
source,
|
||||
new Map(entries.map((entry) => [entry.path, entry])),
|
||||
entries,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute file offset of an entry's data.
|
||||
*
|
||||
* Reads the 30-byte local header to learn its variable-length fields. Cached
|
||||
* per path: a page turn should not cost a round trip to recompute an offset
|
||||
* that cannot change while the file is open.
|
||||
*/
|
||||
async dataOffset(path: string): Promise<number | null> {
|
||||
const cached = this.dataOffsets.get(path);
|
||||
if (cached !== undefined) return cached;
|
||||
const entry = this.entry(path);
|
||||
if (!entry) return null;
|
||||
const header = await this.source.read(entry.headerOffset, entry.headerOffset + 29);
|
||||
const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
|
||||
if (view.getUint32(0, true) !== LOCAL_SIGNATURE) {
|
||||
throw new Error(`bad local header for ${path}`);
|
||||
}
|
||||
const nameLength = view.getUint16(26, true);
|
||||
const extraLength = view.getUint16(28, true);
|
||||
const offset = entry.headerOffset + 30 + nameLength + extraLength;
|
||||
this.dataOffsets.set(path, offset);
|
||||
return offset;
|
||||
}
|
||||
|
||||
/** Entry paths in archive order. */
|
||||
names(): string[] {
|
||||
return this.order.map((entry) => entry.path);
|
||||
}
|
||||
|
||||
entry(path: string): ZipEntry | undefined {
|
||||
return this.entries.get(path) ?? this.entries.get(normaliseZipPath(path));
|
||||
}
|
||||
|
||||
/** Whether the entry's bytes are byte-addressable inside the file. */
|
||||
isSeekable(path: string): boolean {
|
||||
return this.entry(path)?.method === STORED;
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw byte range of a `stored` entry.
|
||||
*
|
||||
* This is what lets a native host or a plain `<img>` fetch one page with a
|
||||
* range request instead of having the whole page copied through JavaScript.
|
||||
*/
|
||||
async resourceUrl(path: string, url: string): Promise<string | null> {
|
||||
const entry = this.entry(path);
|
||||
if (!entry || entry.method !== STORED) return null;
|
||||
const start = await this.dataOffset(path);
|
||||
if (start === null) return null;
|
||||
return `${url}#bytes=${start}-${start + entry.compressedSize - 1}`;
|
||||
}
|
||||
|
||||
/** Entry contents, inflating when the entry is compressed. */
|
||||
async bytes(path: string, limit = Number.POSITIVE_INFINITY): Promise<Uint8Array | null> {
|
||||
const entry = this.entry(path);
|
||||
if (!entry) return null;
|
||||
if (entry.uncompressedSize > limit) {
|
||||
throw new Error(`entry ${path} exceeds the read limit`);
|
||||
}
|
||||
const start = await this.dataOffset(path);
|
||||
if (start === null) return null;
|
||||
const compressed = await this.source.read(start, start + entry.compressedSize - 1);
|
||||
if (entry.method === STORED) return compressed;
|
||||
if (entry.method !== DEFLATED) throw new Error(`unsupported zip compression method ${entry.method}`);
|
||||
return inflateRaw(compressed, entry.uncompressedSize);
|
||||
}
|
||||
|
||||
async text(path: string): Promise<string | null> {
|
||||
const bytes = await this.bytes(path);
|
||||
return bytes ? new TextDecoder('utf-8').decode(bytes) : null;
|
||||
}
|
||||
}
|
||||
|
||||
function findEocd(tail: Uint8Array): number {
|
||||
const view = new DataView(tail.buffer, tail.byteOffset, tail.byteLength);
|
||||
for (let offset = tail.byteLength - 22; offset >= 0; offset -= 1) {
|
||||
if (view.getUint32(offset, true) === EOCD_SIGNATURE) return offset;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseCentralDirectory(directory: Uint8Array): ZipEntry[] {
|
||||
const view = new DataView(directory.buffer, directory.byteOffset, directory.byteLength);
|
||||
const entries: ZipEntry[] = [];
|
||||
let offset = 0;
|
||||
while (offset + 46 <= directory.byteLength) {
|
||||
if (view.getUint32(offset, true) !== CENTRAL_SIGNATURE) break;
|
||||
const method = view.getUint16(offset + 10, true);
|
||||
let compressedSize = view.getUint32(offset + 20, true);
|
||||
let uncompressedSize = view.getUint32(offset + 24, true);
|
||||
const nameLength = view.getUint16(offset + 28, true);
|
||||
const extraLength = view.getUint16(offset + 30, true);
|
||||
const commentLength = view.getUint16(offset + 32, true);
|
||||
let localOffset = view.getUint32(offset + 42, true);
|
||||
|
||||
const nameBytes = directory.subarray(offset + 46, offset + 46 + nameLength);
|
||||
const path = new TextDecoder('utf-8').decode(nameBytes);
|
||||
|
||||
// ZIP64 escape values: the real numbers live in the extra field.
|
||||
if (
|
||||
compressedSize === 0xffffffff ||
|
||||
uncompressedSize === 0xffffffff ||
|
||||
localOffset === 0xffffffff
|
||||
) {
|
||||
const extraStart = offset + 46 + nameLength;
|
||||
const extra = directory.subarray(extraStart, extraStart + extraLength);
|
||||
const zip64 = readZip64Extra(extra);
|
||||
compressedSize = zip64.compressedSize ?? compressedSize;
|
||||
uncompressedSize = zip64.uncompressedSize ?? uncompressedSize;
|
||||
localOffset = zip64.localOffset ?? localOffset;
|
||||
}
|
||||
|
||||
// The data offset needs the local header's own name/extra lengths, which are
|
||||
// only in the local header — so this is stored as the header offset and
|
||||
// resolved on read by `dataOffset`, computed here from the local header we
|
||||
// are allowed to read lazily. Reading it eagerly would cost one request per
|
||||
// entry; a local header is read only for the entries actually opened.
|
||||
entries.push({
|
||||
path,
|
||||
method,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
headerOffset: localOffset,
|
||||
});
|
||||
offset += 46 + nameLength + extraLength + commentLength;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readZip64Extra(extra: Uint8Array): {
|
||||
compressedSize?: number;
|
||||
uncompressedSize?: number;
|
||||
localOffset?: number;
|
||||
} {
|
||||
const view = new DataView(extra.buffer, extra.byteOffset, extra.byteLength);
|
||||
let offset = 0;
|
||||
while (offset + 4 <= extra.byteLength) {
|
||||
const id = view.getUint16(offset, true);
|
||||
const size = view.getUint16(offset + 2, true);
|
||||
if (id === 0x0001) {
|
||||
let cursor = offset + 4;
|
||||
const result: { uncompressedSize?: number; compressedSize?: number; localOffset?: number } = {};
|
||||
if (cursor + 8 <= offset + 4 + size - 4) {
|
||||
result.uncompressedSize = Number(view.getBigUint64(cursor, true));
|
||||
cursor += 8;
|
||||
}
|
||||
if (cursor + 8 <= offset + 4 + size - 2) {
|
||||
result.compressedSize = Number(view.getBigUint64(cursor, true));
|
||||
cursor += 8;
|
||||
}
|
||||
if (cursor + 8 <= offset + 4 + size) {
|
||||
result.localOffset = Number(view.getBigUint64(cursor, true));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
offset += 4 + size;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Inflates a raw-deflate stream with the platform's own decompressor. */
|
||||
async function inflateRaw(data: Uint8Array, expectedSize: number): Promise<Uint8Array> {
|
||||
const format = 'deflate-raw';
|
||||
const stream = new Blob([toArrayBuffer(data)]).stream().pipeThrough(
|
||||
new DecompressionStream(format as CompressionFormat),
|
||||
);
|
||||
const buffer = await new Response(stream).arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
if (expectedSize > 0 && bytes.byteLength !== expectedSize) {
|
||||
throw new Error(`inflated ${bytes.byteLength} bytes, expected ${expectedSize}`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
const copy = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(copy).set(bytes);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function normaliseZipPath(path: string): string {
|
||||
return decodeURIComponent(path).replace(/^\.?\//, '');
|
||||
}
|
||||
|
||||
export { STORED, DEFLATED, LOCAL_SIGNATURE };
|
||||
@@ -239,41 +239,6 @@ function toParagraph(block: string): TextParagraph {
|
||||
return { text: withoutLeading.trimEnd(), leading };
|
||||
}
|
||||
|
||||
/**
|
||||
* One paragraph as a `<p>`.
|
||||
*
|
||||
* `textContent`, never `innerHTML`: a TXT is user data, and a novel containing a
|
||||
* `&` or a `<` must not be able to produce markup. The absence of `white-space:
|
||||
* pre-wrap` is deliberate as well — the paragraph is now a paragraph, and the
|
||||
* lines inside it were soft wraps, so the *browser* is what decides where a line
|
||||
* ends. That decision is a function of the reader's font size and the column
|
||||
* width, which is the whole reason the flow is reflowable.
|
||||
*/
|
||||
export function paragraphElement(paragraph: TextParagraph): HTMLParagraphElement {
|
||||
const element = document.createElement('p');
|
||||
element.textContent = paragraph.text;
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chapter body as a fragment of `<p>` elements.
|
||||
*
|
||||
* The wrapper is a `<div class="txt-body">` rather than bare paragraphs, because
|
||||
* that class is what scopes the plain-text typesheet: an EPUB's chapter must not
|
||||
* inherit an indent the reader set for a different book, and the two meet in the
|
||||
* same shadow root.
|
||||
*/
|
||||
export function textToParagraphs(text: string): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'txt-body';
|
||||
for (const paragraph of splitTextParagraphs(text)) {
|
||||
wrapper.append(paragraphElement(paragraph));
|
||||
}
|
||||
fragment.append(wrapper);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rendition, as an HTML string.
|
||||
*
|
||||
@@ -364,16 +329,3 @@ export function textToChapterHtml(text: string): string {
|
||||
const promoted = heading ? `<h3>${escapeHtml(heading)}</h3>\n` : '';
|
||||
return `<div class="txt-body">\n${promoted}${textToParagraphHtml(body)}\n</div>\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a text ends at a paragraph break.
|
||||
*
|
||||
* Exported because the streaming reader has to decide, per chunk, whether the
|
||||
* paragraph it is looking at is finished — and the decision is a *rule about
|
||||
* paragraphs*, which is what this module owns. Duplicating the regex at the call
|
||||
* site would let the two drift, and the failure would be one extra indent somewhere
|
||||
* in the middle of a novel: invisible in any test and obvious on the page.
|
||||
*/
|
||||
export function endsAtParagraphBreak(text: string): boolean {
|
||||
return /\n\s*$/.test(cleanTextMarkup(text));
|
||||
}
|
||||
@@ -197,14 +197,3 @@ export function chunkByLength(text: string, maxLength: number): Chunk[] {
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chapter body as the reader's own paragraph markup.
|
||||
*
|
||||
* Kept as a named export because the loader has always had one and the name is
|
||||
* what the tests look for; the work itself moved to `segments.ts`, which is the
|
||||
* single definition of what a paragraph is for a TXT — shared with the server's
|
||||
* `chapter-full:` rendition's consumer and with the text reader, so a book cannot
|
||||
* be typeset one way locally and another way when it arrives windowed.
|
||||
*/
|
||||
export const textToHtml = textToChapterHtml;
|
||||
@@ -23,14 +23,11 @@
|
||||
*
|
||||
* ### 3. A window is fetched when the reader asks for something outside it
|
||||
*
|
||||
* Windows are not a cache to be filled in the background; they are on-demand.
|
||||
* `RemoteZip` is the exception: for an EPUB, reading one chapter means reading
|
||||
* the archive's central directory once and then one entry, which is what keeps
|
||||
* the open cost at one chapter rather than one archive.
|
||||
* Windows are not a cache to be filled in the background; they are on-demand
|
||||
* (apart from the one section ahead that `ReaderView.open` prefetches).
|
||||
*/
|
||||
|
||||
import type { BookContent, ContentItem } from '../net/api.ts';
|
||||
import { RemoteZip, type RangeSource } from './remote-zip.ts';
|
||||
import type { BookDoc, Section, StagedBook, TocEntry } from './types.ts';
|
||||
|
||||
/**
|
||||
@@ -312,5 +309,3 @@ export function isStagedKind(kind: BookContent['kind'] | undefined): boolean {
|
||||
return kind === 'reflowable' || kind === 'paged' || kind === 'text';
|
||||
}
|
||||
|
||||
export { RemoteZip };
|
||||
export type { RangeSource };
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* A tiny typed emitter.
|
||||
*
|
||||
* The reader emits state (chapter changed, page turned, position updated) and
|
||||
* the UI subscribes. A framework's reactive layer would do the same job, but the
|
||||
* reader's state changes faster than its DOM should: a page turn must not
|
||||
* re-render the chapter, so the two are kept explicitly apart.
|
||||
*/
|
||||
export type Listener<T> = (value: T) => void;
|
||||
|
||||
export class Emitter<T> {
|
||||
private listeners = new Set<Listener<T>>();
|
||||
|
||||
on(listener: Listener<T>): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
emit(value: T): void {
|
||||
for (const listener of [...this.listeners]) listener(value);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* Reading positions.
|
||||
*
|
||||
* A position must survive three things a reader does constantly: changing the
|
||||
* font size, rotating the phone, and reading the same book on another device.
|
||||
* That rules out a page index (changes with the font) and a pixel offset
|
||||
* (changes with everything).
|
||||
*
|
||||
* What is stored instead is a small record:
|
||||
*
|
||||
* { chapter: <archive path>, spine: <index>, block: <n>, ratio: <0..1>, percent }
|
||||
*
|
||||
* - `chapter` is the server's own identity for a chapter (its archive path). It
|
||||
* is stable across repagination and across devices, which is what makes the
|
||||
* sync endpoint's `locator` field useful. It is a plain string, not an EPUB
|
||||
* CFI: the server never has to parse it, and a CFI would only work for EPUB
|
||||
* anyway — a comic's position is a page index, a PDF's is a page number.
|
||||
* - `block`/`ratio` place the reader inside the chapter, tolerantly: a missing
|
||||
* block falls back to the start of the chapter rather than to the start of
|
||||
* the book.
|
||||
* - `percent` is for the progress indicator, computed against the spine the
|
||||
* client knows exactly (chapter index / chapter count), never against a
|
||||
* character count nobody measured.
|
||||
*/
|
||||
|
||||
export interface Locator {
|
||||
/** Format-private chapter identity; the manifest's `href`. */
|
||||
chapter: string;
|
||||
/** Index of the chapter in the spine, when the book has one. */
|
||||
spine: number;
|
||||
/** Top-level block index inside the chapter. */
|
||||
block: number;
|
||||
/** Fraction into that block. */
|
||||
ratio: number;
|
||||
/** Whole-book progress in [0, 1]. */
|
||||
percent: number;
|
||||
}
|
||||
|
||||
/** Parse a stored locator, tolerating anything the server or an older client wrote. */
|
||||
export function parseLocator(value: string): Locator | null {
|
||||
if (!value) return null;
|
||||
|
||||
// Older builds wrote a bare chapter reference. Accepting it costs three lines
|
||||
// and saves every reader who had a position stored under that format — but only
|
||||
// when the value actually looks like a reference. A truncated or corrupted
|
||||
// value must fall through to null, because treating it as a chapter href would
|
||||
// send the reader to wherever that string happens to sort in the manifest.
|
||||
if (!value.startsWith('{')) {
|
||||
return /^[\w-]+:/.test(value) ? { chapter: value, spine: 0, block: 0, ratio: 0, percent: 0 } : null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<Locator>;
|
||||
if (typeof parsed.chapter !== 'string') return null;
|
||||
return {
|
||||
chapter: parsed.chapter,
|
||||
spine: numberOr(parsed.spine, 0),
|
||||
block: numberOr(parsed.block, 0),
|
||||
ratio: clampRatio(parsed.ratio),
|
||||
percent: clampRatio(parsed.percent),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeLocator(locator: Locator): string {
|
||||
return JSON.stringify(locator);
|
||||
}
|
||||
|
||||
function numberOr(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function clampRatio(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-book progress from a spine position.
|
||||
*
|
||||
* Within-chapter progress is weighted by the chapter's share of the spine, so a
|
||||
* reader at the middle of chapter 50 of 100 reads 50% and not 0%. Weighting by
|
||||
* character count would be more accurate and is not available — the server
|
||||
* reports a chapter count and honestly reports no length, so this uses what
|
||||
* exists rather than inventing the rest.
|
||||
*/
|
||||
export function bookPercent(spine: number, spineTotal: number, fractionInChapter: number): number {
|
||||
if (spineTotal <= 0) return 0;
|
||||
return Math.max(0, Math.min(1, (spine + fractionInChapter) / spineTotal));
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
/**
|
||||
* Where a chapter document lives inside the reader.
|
||||
*
|
||||
* ============================================================================
|
||||
* The iframe vs Shadow DOM decision
|
||||
* ============================================================================
|
||||
*
|
||||
* The user asked whether a pure-H5 build needs an iframe or a Shadow DOM for
|
||||
* strict epub resources. The short answer is **Shadow DOM for the outer
|
||||
* document, and one `srcdoc` iframe per chapter for the book itself.** That is
|
||||
* not a hedge; the two problems are genuinely different and each primitive
|
||||
* solves exactly one of them.
|
||||
*
|
||||
* ## What Shadow DOM does and does not do
|
||||
*
|
||||
* It is NOT isolation from the publisher's stylesheet, and anyone who reaches for
|
||||
* it expecting that has misread the primitive. The publisher's CSS is not "the
|
||||
* document's rules" — it is a chapter asset that has to end up inside the root to
|
||||
* do anything at all. Inside, it styles the book exactly as it would in an
|
||||
* iframe. The book must style itself; that is the product.
|
||||
*
|
||||
* What it *does* provide, and the reason it is the outer container:
|
||||
*
|
||||
* - The client's own UI rules cannot leak in and restyle a footnote or a
|
||||
* blockquote. `all: unset` and `!important` in the shell's CSS are contained,
|
||||
* which is precisely the failure mode this product exists to avoid.
|
||||
* - Per-chapter resources are discarded with the chapter, so a book whose
|
||||
* chapters each link their own stylesheet cannot corrupt later chapters.
|
||||
* - The shell keeps its own DOM: dialogs, gestures, overlays and selection
|
||||
* handling all live outside the root and cannot be matched by a book's `*`
|
||||
* selector.
|
||||
*
|
||||
* ## Why the book itself is an iframe
|
||||
*
|
||||
* This is the part that the first version of this module got wrong, and the
|
||||
* browser test is what caught it. A chapter document is not a fragment: it has a
|
||||
* `<head>` with `<link rel=stylesheet>` and a `<body>` with `<img src>`. Those
|
||||
* are **sub-resource requests**, and they are issued by the browser, not by our
|
||||
* code. Two consequences:
|
||||
*
|
||||
* 1. **They cannot carry an `Authorization` header.** There is no API to attach
|
||||
* a request header to an `<img>` or a `<link>`. Injecting the chapter's CSS
|
||||
* and images into the host document therefore breaks every one of them with a
|
||||
* 401 — the book renders as unstyled text with no pictures, which is exactly
|
||||
* what happened before this file was rewritten.
|
||||
* 2. **They resolve against the document's base URL.** The chapter's `src` is
|
||||
* already rewritten to an absolute asset URL by the server, so this is
|
||||
* satisfied — but only because it is a real document with a real base.
|
||||
*
|
||||
* A shadow root solves neither. An `iframe` solves both: it is a real browsing
|
||||
* context, so its `src`/`href` requests are ordinary navigations that can carry
|
||||
* whatever URL (including a token query parameter) they were written with, and
|
||||
* its base URL is its own.
|
||||
*
|
||||
* `srcdoc` rather than a Blob URL: a Blob URL's origin is opaque, so the frame's
|
||||
* requests would be cross-origin and the asset endpoint would refuse them. A
|
||||
* `srcdoc` frame inherits the parent's origin, which is what makes the rewritten
|
||||
* absolute URLs work without CORS.
|
||||
*
|
||||
* ## What an iframe costs, and why it is worth paying
|
||||
*
|
||||
* A frame is a nested browsing context with its own layout and lifecycle. That
|
||||
* cost is only paid per chapter — one frame is reused and its `srcdoc` replaced,
|
||||
* not one frame per chapter — and it buys the guarantee that a chapter is a
|
||||
* *document*, rendered by an engine that has been rendering documents for thirty
|
||||
* years, rather than a fragment we hope is equivalent.
|
||||
*
|
||||
* The paginator therefore drives the inner document's scroller. That is the same
|
||||
* amount of work as driving a local one, and it is the only arrangement in which
|
||||
* `document.styleSheets`, `@page`, `@font-face` and `:root` in a book's CSS mean
|
||||
* what the publisher intended — `:root` inside a shadow root refers to the shadow
|
||||
* root itself, not to an element the client can set variables on, which is the
|
||||
* second bug the browser test caught.
|
||||
*/
|
||||
|
||||
/** A mounted chapter. */
|
||||
export interface MountedChapter {
|
||||
/** Document the chapter was loaded into, once it has loaded. */
|
||||
document(): Document | null;
|
||||
/** The element the paginator scrolls. */
|
||||
scroller(): HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reading frame.
|
||||
*
|
||||
* One `iframe`, reused across chapters. `srcdoc` is replaced rather than the
|
||||
* frame being recreated: tearing down and rebuilding a browsing context per
|
||||
* chapter is measurable on a 200-chapter book, and a reused frame keeps the
|
||||
* same compositing layer.
|
||||
*/
|
||||
export class ChapterContainer {
|
||||
readonly host: HTMLElement;
|
||||
private frame: HTMLIFrameElement;
|
||||
private ready: Promise<void> | null = null;
|
||||
|
||||
constructor(host: HTMLElement) {
|
||||
this.host = host;
|
||||
this.frame = document.createElement('iframe');
|
||||
this.frame.className = 'reader-frame';
|
||||
// Set inline as well as in the stylesheet: this element's default sizing
|
||||
// (`display: inline`, 2px border) is wrong in a way that breaks pagination
|
||||
// rather than merely looking off, and a stylesheet that fails to load must
|
||||
// not be able to reintroduce it.
|
||||
this.frame.style.display = 'block';
|
||||
this.frame.style.width = '100%';
|
||||
this.frame.style.height = '100%';
|
||||
this.frame.style.border = '0';
|
||||
// Same-origin is required for the client to read the chapter's layout for
|
||||
// pagination and text selection. There is no script in a chapter document
|
||||
// that could exploit it: `allow-scripts` is deliberately absent, so a book's
|
||||
// own `<script>` cannot run even if it has one.
|
||||
this.frame.setAttribute('sandbox', 'allow-same-origin');
|
||||
this.frame.setAttribute('title', '正文');
|
||||
host.replaceChildren(this.frame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a chapter document.
|
||||
*
|
||||
* The server has already made the document self-contained: relative resource
|
||||
* references are rewritten to absolute asset URLs, so the frame needs nothing
|
||||
* from us beyond the markup and the token the URLs carry.
|
||||
*/
|
||||
async mount(html: string, settings: ReaderStyle): Promise<MountedChapter> {
|
||||
const doc = this.frame.contentDocument;
|
||||
if (!doc) throw new Error('chapter frame has no document');
|
||||
|
||||
const styled = injectReaderStyle(html, settings, this.width());
|
||||
this.ready = waitForLoad(this.frame);
|
||||
this.frame.srcdoc = styled;
|
||||
await this.ready;
|
||||
|
||||
const chapter = this.frame.contentDocument;
|
||||
if (!chapter) throw new Error('chapter frame lost its document');
|
||||
return {
|
||||
document: () => this.frame.contentDocument,
|
||||
scroller: () => (chapter.scrollingElement ?? chapter.documentElement) as HTMLElement,
|
||||
};
|
||||
}
|
||||
|
||||
/** The element the paginator scrolls: the chapter's own scrolling element. */
|
||||
scroller(): HTMLElement | null {
|
||||
const doc = this.frame.contentDocument;
|
||||
if (!doc?.body) return null;
|
||||
return (doc.scrollingElement ?? doc.documentElement) as HTMLElement;
|
||||
}
|
||||
|
||||
/** The chapter's document, once it has loaded. */
|
||||
document(): Document | null {
|
||||
return this.frame.contentDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
* Width of one page.
|
||||
*
|
||||
* Read from the frame rather than from the host: the frame is the element the
|
||||
* document actually lays out in, and a host with padding or a scrollbar would
|
||||
* give a different answer than the page geometry the CSS computed.
|
||||
*/
|
||||
width(): number {
|
||||
// `clientWidth` is 0 before the frame has been laid out, which happens on the
|
||||
// first mount. Falling back to the host keeps the first chapter from being
|
||||
// paginated against a zero-width page.
|
||||
return this.frame.clientWidth || this.host.clientWidth || 360;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.host.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
/** The subset of reader settings a chapter document needs. */
|
||||
export interface ReaderStyle {
|
||||
fontSize: number;
|
||||
pageWidth: number;
|
||||
columnGap: number;
|
||||
pagePadding: number;
|
||||
background: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete chapter document.
|
||||
*
|
||||
* Everything the document needs is inlined, because an `srcdoc` frame has no
|
||||
* base URL of its own to resolve a client stylesheet against — and because a
|
||||
* chapter that loaded its reader CSS from the network would be unstyled for one
|
||||
* frame, which is visible on every chapter turn.
|
||||
*
|
||||
* The styles appended last are the reader's own, and they are deliberately
|
||||
* minimal: page geometry, and a font size the book may override. See
|
||||
* `render/theme.ts` for why the list stops there.
|
||||
*/
|
||||
function injectReaderStyle(html: string, settings: ReaderStyle, frameWidth: number): string {
|
||||
const style = `<style id="reader-page">${pageCss(settings, frameWidth)}</style>`;
|
||||
const withViewport = /<head[^>]*>/i.test(html)
|
||||
? html.replace(/<head([^>]*)>/i, `<head$1>${viewportMeta()}`)
|
||||
: html;
|
||||
if (/<\/head>/i.test(withViewport)) return withViewport.replace(/<\/head>/i, `${style}</head>`);
|
||||
return `${style}${withViewport}`;
|
||||
}
|
||||
|
||||
/** A phone-width viewport, so a chapter authored for a desktop window reflows. */
|
||||
function viewportMeta(): string {
|
||||
return '<meta name="viewport" content="width=device-width, initial-scale=1">';
|
||||
}
|
||||
|
||||
/**
|
||||
* Page geometry for a chapter.
|
||||
*
|
||||
* `column-width` is what creates pages; the frame's scroller then moves between
|
||||
* them by `scrollLeft`, exactly as it would for a horizontal list. Nothing here
|
||||
* selects a content element, so nothing here can flatten a publisher's layout.
|
||||
*
|
||||
* ## The height that makes columns work
|
||||
*
|
||||
* `column-fill: auto` only fragments content when the container has a *definite*
|
||||
* height. Left alone, `html` and `body` size to their content, the computed
|
||||
* height becomes the content's own height, and the whole chapter lays out in a
|
||||
* single column as tall as the text — `scrollWidth` equals `clientWidth` and
|
||||
* every "page" is the entire chapter. That is what this file did before the
|
||||
* browser test looked at `scrollWidth`, and it is not a subtle failure: the
|
||||
* reader shows one page per chapter and the page-turn gesture does nothing.
|
||||
*
|
||||
* So the document is pinned to the frame's viewport height: 100vh inside an
|
||||
* iframe is the frame's own viewport, which is exactly the page height wanted.
|
||||
* The body deliberately does NOT clip its overflow — clipping it also clips the
|
||||
* columns it generated, so content past the first page never becomes scrollable
|
||||
* and the chapter reports a single page.
|
||||
*
|
||||
* The one rule that does touch content is `font-size` on `html`, and it is
|
||||
* expressed so a book can win: a book that sets an absolute size keeps it, and a
|
||||
* book that uses relative sizes scales with the reader's choice. That is the only
|
||||
* way a font-size control can work without overriding the book.
|
||||
*/
|
||||
function pageCss(settings: ReaderStyle, frameWidth: number): string {
|
||||
// `pageWidth` is a CEILING on how wide a page may be, not the width itself: on
|
||||
// a 390px phone a 720px column would lay the whole chapter out in one column
|
||||
// wider than the screen, and every page turn would scroll nowhere. The column
|
||||
// takes the smaller of the reader's preference and the space that exists.
|
||||
const width = Math.max(80, Math.min(settings.pageWidth, frameWidth));
|
||||
const usable = Math.max(80, width - settings.pagePadding * 2);
|
||||
return `
|
||||
html {
|
||||
font-size: ${settings.fontSize}px;
|
||||
background: ${settings.background};
|
||||
color: ${settings.text};
|
||||
/* The document is the scroll container; the frame is sized to one page. */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
/* No scroll-snap here. Snapping to the columns sounds right and is not: the
|
||||
snap target is each column's own edge, so a programmatic scrollLeft lands
|
||||
wherever the browser decides the nearest snap point is — and a snap point
|
||||
inside a column is not a page boundary. The paginator sets scrollLeft
|
||||
explicitly, and a gesture handler keeps user swipes on the same grid. */
|
||||
}
|
||||
html::-webkit-scrollbar { display: none; }
|
||||
body {
|
||||
/* Columns are the pagination: one column is exactly one page. The column
|
||||
width is set below in pixels rather than left to the publisher's rules, and
|
||||
nothing else about the body typography is touched. */
|
||||
column-width: ${usable}px;
|
||||
column-gap: ${settings.columnGap}px;
|
||||
column-fill: auto;
|
||||
/* A definite height is what lets the content fragment into columns at all. */
|
||||
height: 100vh;
|
||||
/* No overflow: hidden here. Clipping the body also clips the columns it
|
||||
generated, so content past the first page never becomes scrollable and the
|
||||
chapter reports a single page. The document's own scroller is what has to
|
||||
see the overflow. */
|
||||
margin: 0;
|
||||
padding: 0 ${settings.pagePadding}px;
|
||||
box-sizing: border-box;
|
||||
/* No forced font-family and no line-height: the publisher's body rules decide
|
||||
the typography, as they should. */
|
||||
}
|
||||
img, svg {
|
||||
/* An image that overflows its column breaks the geometry for the whole
|
||||
chapter, not just for itself. Clamping is the one place where fidelity has
|
||||
to give way, and it is stated here rather than hidden in a reset. */
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
break-inside: avoid;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Resolve when a frame finishes loading its `srcdoc`. */
|
||||
function waitForLoad(frame: HTMLIFrameElement): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const done = (): void => {
|
||||
frame.removeEventListener('load', done);
|
||||
// One animation frame after `load`, so the first layout has happened and
|
||||
// the paginator's `scrollWidth` is not the pre-layout value.
|
||||
requestAnimationFrame(() => resolve());
|
||||
};
|
||||
frame.addEventListener('load', done);
|
||||
});
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
/**
|
||||
* The paged reader: comics, image directories, single images.
|
||||
*
|
||||
* Deliberately much simpler than the reflowable reader, and for a reason the
|
||||
* user's own message named: **a comic is not text, so it should not go through a
|
||||
* WebView at all.** A native `ImageView` decodes a JPEG with hardware support and
|
||||
* recycles the bitmap; an `<img>` inside a page on a low-end Android device is
|
||||
* several times the memory and CPU for the same screen. The Android shell
|
||||
* therefore renders this content natively and only uses this module for the H5
|
||||
* build.
|
||||
*
|
||||
* What both implementations share is the addressing: `items[].href` from the
|
||||
* manifest, fetched one page at a time. That is what makes "download the whole
|
||||
* volume" a client decision rather than a requirement — a reader who flips
|
||||
* through three pages of a 300MB volume has downloaded three pages.
|
||||
*
|
||||
* Three details that are easy to get wrong:
|
||||
*
|
||||
* - **Page order comes from the manifest, never from the file name.** The server
|
||||
* already applied natural ordering (`page10` after `page2`), and re-sorting on
|
||||
* the client would disagree with it for archives with a directory layout.
|
||||
* - **Object URLs are revoked.** Each page is a Blob; leaking one per page turn
|
||||
* is a few hundred megabytes over a volume.
|
||||
* - **The next page is decoded before it is shown.** Swapping `img.src` and
|
||||
* letting the browser paint on arrival shows a blank frame for a moment, which
|
||||
* reads as a stutter on every single turn.
|
||||
*/
|
||||
|
||||
import { ApiClient, type BookDto } from '../net/api.ts';
|
||||
import { Emitter } from '../lib/events.ts';
|
||||
import { PrefetchCache } from './prefetch.ts';
|
||||
import { toWindow, type Window } from './window.ts';
|
||||
import { parseLocator, serializeLocator, type Locator } from '../lib/locator.ts';
|
||||
|
||||
export interface PagedState {
|
||||
book: BookDto;
|
||||
index: number;
|
||||
total: number;
|
||||
/** Set when the current page is still being fetched. */
|
||||
loading: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface PagedOptions {
|
||||
device?: string;
|
||||
progressDebounceMs?: number;
|
||||
/**
|
||||
* Host platform. Android renders pages natively and never constructs this
|
||||
* reader; the option exists so the H5 build can declare itself explicitly
|
||||
* rather than relying on which module imported it.
|
||||
*/
|
||||
platform?: 'web' | 'android';
|
||||
}
|
||||
|
||||
export class PagedReader {
|
||||
readonly state = new Emitter<PagedState>();
|
||||
private readonly cache = new PrefetchCache({ ahead: 2, maxBytes: 24 * 1024 * 1024 });
|
||||
private window: Window | null = null;
|
||||
private index = 0;
|
||||
private objectUrl: string | null = null;
|
||||
private progressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private cancelPrefetch: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly host: HTMLElement,
|
||||
private readonly api: ApiClient,
|
||||
private readonly book: BookDto,
|
||||
private readonly options: PagedOptions = {},
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Open at a stored page.
|
||||
*
|
||||
* A comic's position is a page index, which is a stable locator only because it
|
||||
* is resolved against the manifest: a volume added in the middle of a series
|
||||
* moves every later page, and the server's group offsets are what let the
|
||||
* client detect that and land on the same *volume* rather than the same offset.
|
||||
*/
|
||||
async open(bookId: string, group = 0): Promise<PagedState> {
|
||||
const manifest = await this.api.manifest(bookId, group);
|
||||
this.window = toWindow(manifest.content ?? null, {
|
||||
...(manifest.kind !== undefined ? { kind: manifest.kind } : {}),
|
||||
...(manifest.total !== undefined ? { total: manifest.total } : {}),
|
||||
...(manifest.groups !== undefined ? { groups: manifest.groups } : {}),
|
||||
...(manifest.items !== undefined ? { items: manifest.items } : {}),
|
||||
});
|
||||
|
||||
const saved = parseLocator(
|
||||
(await this.api.progress(bookId).catch(() => ({ progress: null }))).progress?.locator ?? '',
|
||||
);
|
||||
const local = saved ? this.window.items.findIndex((item) => item.href === saved.chapter) : -1;
|
||||
await this.show(local >= 0 ? local : 0);
|
||||
return this.currentState();
|
||||
}
|
||||
|
||||
get total(): number {
|
||||
return this.window?.items.length ?? 0;
|
||||
}
|
||||
|
||||
/** Move by a number of pages. Stops at the ends rather than wrapping. */
|
||||
async turn(pages: number): Promise<void> {
|
||||
await this.show(this.index + pages);
|
||||
}
|
||||
|
||||
async goTo(index: number): Promise<void> {
|
||||
await this.show(index);
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.progressTimer) {
|
||||
clearTimeout(this.progressTimer);
|
||||
this.progressTimer = null;
|
||||
}
|
||||
await this.writeProgress();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.cancelPrefetch?.();
|
||||
this.cache.clear();
|
||||
this.releaseUrl();
|
||||
this.state.clear();
|
||||
}
|
||||
|
||||
private async show(index: number): Promise<void> {
|
||||
const window = this.window;
|
||||
if (!window || window.items.length === 0) return;
|
||||
const clamped = Math.max(0, Math.min(window.items.length - 1, index));
|
||||
const item = window.items[clamped];
|
||||
if (!item) return;
|
||||
|
||||
this.index = clamped;
|
||||
this.emit(true);
|
||||
|
||||
const blob = await this.cache.get(item.href, (key) => this.api.asset(this.book.id, key));
|
||||
// A page the reader has already flipped past must not replace the current
|
||||
// one: a slow request racing a fast thumb would otherwise show an old page.
|
||||
if (this.index !== clamped) return;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const previous = this.objectUrl;
|
||||
this.objectUrl = url;
|
||||
|
||||
const image = document.createElement('img');
|
||||
image.className = 'reader-page';
|
||||
image.alt = item.title;
|
||||
image.decoding = 'async';
|
||||
// Decode before mounting: without this the container is empty for as long as
|
||||
// the browser takes to decode, which is visible on every turn for a large scan.
|
||||
await image.decode().catch(() => undefined);
|
||||
this.host.replaceChildren(image);
|
||||
image.src = url;
|
||||
if (previous) URL.revokeObjectURL(previous);
|
||||
|
||||
this.emit(false);
|
||||
this.emitProgress();
|
||||
this.prefetch(clamped);
|
||||
}
|
||||
|
||||
private prefetch(index: number): void {
|
||||
const window = this.window;
|
||||
if (!window) return;
|
||||
this.cancelPrefetch?.();
|
||||
const keys = window.items.map((item) => item.href);
|
||||
this.cancelPrefetch = this.cache.warm(keys, index, (key) => this.api.asset(this.book.id, key), {
|
||||
sizeOf: (key) => window.items.find((item) => item.href === key)?.size,
|
||||
});
|
||||
}
|
||||
|
||||
private emitProgress(): void {
|
||||
const delay = this.options.progressDebounceMs ?? 2000;
|
||||
if (delay === 0) {
|
||||
void this.writeProgress();
|
||||
return;
|
||||
}
|
||||
if (this.progressTimer) clearTimeout(this.progressTimer);
|
||||
this.progressTimer = setTimeout(() => {
|
||||
this.progressTimer = null;
|
||||
void this.writeProgress();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private async writeProgress(): Promise<void> {
|
||||
const window = this.window;
|
||||
if (!window) return;
|
||||
const item = window.items[this.index];
|
||||
if (!item) return;
|
||||
// For a paged book the locator is the page's own identity, which the server
|
||||
// handed us. Rebuilding it from an index would break the moment a volume is
|
||||
// re-packed with an extra page.
|
||||
const locator: Locator = {
|
||||
chapter: item.href,
|
||||
spine: (window.group?.offset ?? 0) + this.index,
|
||||
block: 0,
|
||||
ratio: 0,
|
||||
percent: this.index / Math.max(1, window.items.length),
|
||||
};
|
||||
await this.api
|
||||
.putProgress(this.book.id, {
|
||||
locator: serializeLocator(locator),
|
||||
percentage: locator.percent,
|
||||
chapterTitle: item.title,
|
||||
device: this.options.device ?? 'web',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
private emit(loading: boolean): void {
|
||||
this.state.emit({
|
||||
book: this.book,
|
||||
index: this.index,
|
||||
total: this.total,
|
||||
loading,
|
||||
warnings: [],
|
||||
});
|
||||
}
|
||||
|
||||
private currentState(): PagedState {
|
||||
return {
|
||||
book: this.book,
|
||||
index: this.index,
|
||||
total: this.total,
|
||||
loading: false,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
private releaseUrl(): void {
|
||||
if (this.objectUrl) {
|
||||
URL.revokeObjectURL(this.objectUrl);
|
||||
this.objectUrl = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* Column-based pagination for reflowable content.
|
||||
*
|
||||
* ## Why not `scroll` mode
|
||||
*
|
||||
* Continuous scrolling is what every web reader does, and it is the wrong model
|
||||
* for a book. A reader's position is measured in pages: the progress indicator,
|
||||
* turning back to re-read a paragraph, "3 pages left in this chapter" — all of it
|
||||
* assumes a fixed page. A scroll offset is a pixel number that changes meaning
|
||||
* when the font size, viewport or column width changes, which is exactly the set
|
||||
* of things a reader adjusts.
|
||||
*
|
||||
* ## Why CSS multi-column instead of measuring by hand
|
||||
*
|
||||
* The alternative is to wrap every word in a span and binary-search the page
|
||||
* break. That rewrites the publisher's markup, which this product refuses to do,
|
||||
* and it is slow enough to feel on a chapter with images. Multi-column layout is
|
||||
* the browser's own implementation of the same thing: the content is laid out
|
||||
* once, columns are created, and a page is a horizontal scroll offset.
|
||||
*
|
||||
* The cost is that page boundaries land wherever the layout engine puts them.
|
||||
* That is acceptable — and it is what every commercial reader does — as long as a
|
||||
* *position* survives repagination, which is what the anchor functions below are
|
||||
* for.
|
||||
*
|
||||
* ## Why the scroller is a parameter
|
||||
*
|
||||
* The chapter lives in an iframe (see `container.ts`), so the element being
|
||||
* scrolled belongs to another document. Taking it as a parameter rather than
|
||||
* reaching for a global keeps this class testable and keeps the frame's existence
|
||||
* out of the pagination arithmetic.
|
||||
*/
|
||||
|
||||
/** A position inside a chapter, anchored to a DOM node so it survives repagination. */
|
||||
export interface ChapterPosition {
|
||||
/** Index of the top-level block the reader was in. */
|
||||
blockIndex: number;
|
||||
/** How far into that block, as a fraction of its height. */
|
||||
offsetRatio: number;
|
||||
}
|
||||
|
||||
export interface PaginatorOptions {
|
||||
/** Gap between columns, in CSS pixels. Must match the injected stylesheet. */
|
||||
columnGap?: number;
|
||||
/** Page padding, in CSS pixels, on each side. Must match the stylesheet. */
|
||||
pagePadding?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a chapter's scrolling element.
|
||||
*/
|
||||
export class Paginator {
|
||||
private readonly columnGap: number;
|
||||
/** Column stride: the exact distance between two page origins. */
|
||||
private stride = 0;
|
||||
private pageCount = 1;
|
||||
private current = 0;
|
||||
|
||||
constructor(private scroller: HTMLElement | null, options: PaginatorOptions = {}) {
|
||||
this.columnGap = options.columnGap ?? 32;
|
||||
void options.pagePadding;
|
||||
}
|
||||
|
||||
/** Point the paginator at a (new) chapter document. */
|
||||
attach(scroller: HTMLElement | null): void {
|
||||
this.scroller = scroller;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the column geometry.
|
||||
*
|
||||
* `scrollWidth` is the layout engine's answer to "how much content is there",
|
||||
* and dividing it by the stride is the only way to learn the page count without
|
||||
* duplicating the engine's work. The gap is added back because `scrollWidth`
|
||||
* omits the trailing gap that separates the last column from the edge.
|
||||
*/
|
||||
layout(pageWidth?: number): { pageCount: number; stride: number } {
|
||||
const scroller = this.scroller;
|
||||
if (!scroller) {
|
||||
this.stride = 0;
|
||||
this.pageCount = 1;
|
||||
return { pageCount: 1, stride: 0 };
|
||||
}
|
||||
const usable = pageWidth ?? scroller.clientWidth;
|
||||
// The stride is the distance between two page origins. Deriving it from the
|
||||
// *viewport* rather than from the columns keeps it independent of
|
||||
// `column-width`, which the publisher's own CSS could influence.
|
||||
this.stride = Math.max(1, usable + this.columnGap);
|
||||
|
||||
// The page count comes from the content's real width rather than from a
|
||||
// division: `scrollWidth` includes the leading page padding and the column
|
||||
// gutters, so dividing it by the stride loses the remainder and drops the
|
||||
// last page of a chapter whose content does not fill it exactly. Rounding the
|
||||
// division up against the *padded* width is what keeps that page reachable.
|
||||
const padded = usable + this.columnGap;
|
||||
this.pageCount = Math.max(1, Math.ceil((scroller.scrollWidth - this.columnGap) / padded));
|
||||
return { pageCount: this.pageCount, stride: this.stride };
|
||||
}
|
||||
|
||||
get total(): number {
|
||||
return this.pageCount;
|
||||
}
|
||||
|
||||
get index(): number {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** Move to a page, clamped to the chapter. Returns true if the page changed. */
|
||||
goTo(page: number): boolean {
|
||||
const next = Math.max(0, Math.min(this.pageCount - 1, Math.floor(page)));
|
||||
if (next === this.current) return false;
|
||||
this.current = next;
|
||||
if (this.scroller) this.scroller.scrollLeft = next * this.stride;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move by a relative number of pages.
|
||||
*
|
||||
* `spill` is the number of pages that did not fit; the caller turns it into a
|
||||
* chapter turn. Returning it rather than swallowing it is what makes "the next
|
||||
* page past the end of this chapter" a chapter turn instead of a dead gesture.
|
||||
*/
|
||||
turn(pages: number): { changed: boolean; spill: number } {
|
||||
const target = this.current + pages;
|
||||
if (target < 0) return { changed: this.goTo(0), spill: target };
|
||||
if (target > this.pageCount - 1) {
|
||||
const changed = this.goTo(this.pageCount - 1);
|
||||
return { changed, spill: target - (this.pageCount - 1) };
|
||||
}
|
||||
return { changed: this.goTo(target), spill: 0 };
|
||||
}
|
||||
|
||||
/** Forget the current page; used when a different chapter is loaded. */
|
||||
reset(): void {
|
||||
this.current = 0;
|
||||
this.pageCount = 1;
|
||||
if (this.scroller) this.scroller.scrollLeft = 0;
|
||||
}
|
||||
|
||||
/** Page index for a horizontal scroll offset (e.g. after a user swipe). */
|
||||
pageAt(offset: number): number {
|
||||
if (this.stride <= 0) return 0;
|
||||
return Math.max(0, Math.min(this.pageCount - 1, Math.round(offset / this.stride)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fraction of the chapter read, from a page index.
|
||||
*
|
||||
* Reported as a fraction of the chapter rather than of the book: the server
|
||||
* cannot know a chapter's length in characters for most books, and inventing one
|
||||
* would put a wrong percentage under the reader's thumb. The reader combines this
|
||||
* with the chapter's position in the spine, which it does know exactly.
|
||||
*/
|
||||
export function pageFraction(page: number, pageCount: number): number {
|
||||
if (pageCount <= 1) return 0;
|
||||
return Math.max(0, Math.min(1, page / (pageCount - 1)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a position that survives repagination.
|
||||
*
|
||||
* The page index does not: the same index means a different part of the text
|
||||
* after a font-size change. Anchoring to the top-level block at the page's
|
||||
* leading edge, plus how far into that block, means a reader who resizes the
|
||||
* text stays on the sentence they were reading — which is the entire reason
|
||||
* readers tolerate changing the font size at all.
|
||||
*/
|
||||
export function capturePosition(scroller: HTMLElement | null, page: number, stride: number): ChapterPosition {
|
||||
const blocks = topLevelBlocks(scroller);
|
||||
if (blocks.length === 0) return { blockIndex: 0, offsetRatio: 0 };
|
||||
|
||||
const origin = page * stride;
|
||||
let index = 0;
|
||||
for (let i = 0; i < blocks.length; i += 1) {
|
||||
const block = blocks[i]!;
|
||||
// `offsetLeft` is relative to the offsetParent, which for a chapter's blocks
|
||||
// is the nearest positioned ancestor. That is stable within a document and
|
||||
// the only thing that matters here, since capture and restore both happen
|
||||
// against the same document.
|
||||
if (block.offsetLeft <= origin + 1) index = i;
|
||||
else break;
|
||||
}
|
||||
|
||||
const block = blocks[index]!;
|
||||
const height = block.offsetHeight || 1;
|
||||
const ratio = Math.max(0, Math.min(1, (origin - block.offsetLeft) / height));
|
||||
return { blockIndex: index, offsetRatio: ratio };
|
||||
}
|
||||
|
||||
/** Restore a captured position. Returns the page to land on. */
|
||||
export function restorePosition(scroller: HTMLElement | null, position: ChapterPosition, stride: number): number {
|
||||
const blocks = topLevelBlocks(scroller);
|
||||
if (blocks.length === 0) return 0;
|
||||
const block = blocks[Math.min(position.blockIndex, blocks.length - 1)];
|
||||
if (!block) return 0;
|
||||
const left = block.offsetLeft + position.offsetRatio * (block.offsetHeight || 0);
|
||||
return stride > 0 ? Math.max(0, Math.round(left / stride)) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The blocks a position can anchor to.
|
||||
*
|
||||
* Only the body's direct children are considered. Going deeper would anchor to a
|
||||
* `<span>` whose offset changes the moment the publisher's CSS nests differently,
|
||||
* and anchors have to be stable across the client's own style tweaks — those
|
||||
* changes must not move the reader's place in the book.
|
||||
*/
|
||||
function topLevelBlocks(scroller: HTMLElement | null): HTMLElement[] {
|
||||
const body = scroller?.ownerDocument?.body;
|
||||
if (!body) return [];
|
||||
return Array.from(body.children).filter((node): node is HTMLElement => node instanceof HTMLElement);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* The PDF surface.
|
||||
*
|
||||
* PDF is explicitly a "保底可读" format in this product, and this module keeps
|
||||
* that promise literal: the server sends the original bytes and the client hands
|
||||
* them to a PDF surface it does not own.
|
||||
*
|
||||
* ## Why an iframe here, when the reflowable reader refused one
|
||||
*
|
||||
* The container module explains the reasoning at length; the short version is
|
||||
* that the trade flips for PDF. A reflowable chapter needs to be measured,
|
||||
* paginated and selected *by this client*, and a frame would force a second
|
||||
* paginator inside it. A PDF needs none of that: the browser's own viewer already
|
||||
* handles page layout, zoom, text selection, search and print, and it expects to
|
||||
* own a browsing context to do it. Fighting that to keep a single container type
|
||||
* would mean reimplementing a PDF viewer, which is a project of its own.
|
||||
*
|
||||
* On Android the same logic points at the platform viewer, which is why the shell
|
||||
* routes `document`-kind books away from the WebView entirely.
|
||||
*
|
||||
* ## Why not PDF.js
|
||||
*
|
||||
* It would bundle about a megabyte of JavaScript plus a worker into both the H5
|
||||
* and the Android assets to do worse than the browser's built-in viewer on the
|
||||
* device it is running on, and worse than the platform viewer on Android. The
|
||||
* server also refuses to parse the page tree, so a PDF.js-based client would
|
||||
* still have to discover the page count itself. There is no version of this that
|
||||
* is better than "give the bytes to a viewer".
|
||||
*
|
||||
* ## What the client does contribute
|
||||
*
|
||||
* Progress. The viewer is opaque, so the position has to come from somewhere the
|
||||
* client can observe: the URL fragment (`#page=N`), which is the one interface
|
||||
* every PDF viewer agrees on. Writing it is how "continue reading" works for a
|
||||
* PDF at all; reading it back is not possible without the viewer's cooperation,
|
||||
* so a PDF's progress is coarse — page-level, from the client's own tracking of
|
||||
* where it sent the viewer — and that limitation is stated rather than papered
|
||||
* over with a guess.
|
||||
*/
|
||||
|
||||
import { ApiClient, type BookDto } from '../net/api.ts';
|
||||
import { Emitter } from '../lib/events.ts';
|
||||
import { parseLocator, serializeLocator } from '../lib/locator.ts';
|
||||
|
||||
export interface PdfState {
|
||||
book: BookDto;
|
||||
/** Page the viewer was last sent to, when the client knows it. */
|
||||
page: number;
|
||||
/** The URL the viewer is showing, for diagnostics. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PdfOptions {
|
||||
device?: string;
|
||||
}
|
||||
|
||||
export class PdfReader {
|
||||
readonly state = new Emitter<PdfState>();
|
||||
private frame: HTMLIFrameElement | null = null;
|
||||
private page = 1;
|
||||
|
||||
constructor(
|
||||
private readonly host: HTMLElement,
|
||||
private readonly api: ApiClient,
|
||||
private readonly book: BookDto,
|
||||
private readonly options: PdfOptions = {},
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Open the document.
|
||||
*
|
||||
* The URL carries the auth token in a query parameter rather than a header,
|
||||
* because an iframe cannot set headers. The server accepts `?access_token=` on
|
||||
* asset and content routes for exactly this reason. It is the one place a
|
||||
* token travels in a URL, and it is deliberate: the alternative is proxying the
|
||||
* whole document through `fetch` and a Blob URL, which defeats the viewer's own
|
||||
* range requests and makes a 100MB manual load entirely before the first page
|
||||
* appears.
|
||||
*/
|
||||
async open(bookId: string): Promise<PdfState> {
|
||||
const saved = parseLocator(
|
||||
(await this.api.progress(bookId).catch(() => ({ progress: null }))).progress?.locator ?? '',
|
||||
);
|
||||
if (saved?.block) this.page = Math.max(1, saved.block);
|
||||
|
||||
const frame = document.createElement('iframe');
|
||||
frame.className = 'reader-pdf';
|
||||
// No `sandbox` attribute at all, and that is a deliberate reversal of the
|
||||
// first version of this code, which granted `allow-same-origin` and nothing
|
||||
// else on the reasoning that the viewer needed no scripting.
|
||||
//
|
||||
// It does. The browser's built-in PDF viewer is implemented *as a script
|
||||
// inside the frame*, so a sandbox without `allow-scripts` makes Chromium
|
||||
// refuse the navigation outright — the request is aborted and the reader sees
|
||||
// an empty box with no error. Since the alternative is granting
|
||||
// `allow-scripts` to a sandbox that already has `allow-same-origin` (which is
|
||||
// a documented escape from the sandbox, and therefore theatre), there is no
|
||||
// sandbox setting that both works and means anything here.
|
||||
//
|
||||
// The frame's content is the user's own PDF, served from their own server, so
|
||||
// the exposure is their own file. Framing it is the same trust decision as
|
||||
// opening it.
|
||||
frame.title = this.book.title;
|
||||
this.frame = frame;
|
||||
this.host.replaceChildren(frame);
|
||||
this.setPage(bookId, this.page);
|
||||
return { book: this.book, page: this.page, url: frame.src };
|
||||
}
|
||||
|
||||
/** Send the viewer to a page. The fragment is the only portable interface. */
|
||||
setPage(bookId: string, page: number): void {
|
||||
this.page = Math.max(1, Math.floor(page));
|
||||
if (this.frame) this.frame.src = this.url(bookId, this.page);
|
||||
this.state.emit({ book: this.book, page: this.page, url: this.frame?.src ?? '' });
|
||||
void this.saveProgress(bookId);
|
||||
}
|
||||
|
||||
/** PDF readers are opaque; the client can only report where it sent them. */
|
||||
async flush(bookId: string): Promise<void> {
|
||||
await this.saveProgress(bookId);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.host.replaceChildren();
|
||||
this.frame = null;
|
||||
this.state.clear();
|
||||
}
|
||||
|
||||
private url(bookId: string, page: number): string {
|
||||
const token = this.api.currentSession?.accessToken ?? '';
|
||||
const base = this.api.url(`/api/v1/books/${encodeURIComponent(bookId)}/assets?ref=document`);
|
||||
const separator = base.includes('?') ? '&' : '?';
|
||||
return `${base}${separator}access_token=${encodeURIComponent(token)}#page=${page}`;
|
||||
}
|
||||
|
||||
private async saveProgress(bookId: string): Promise<void> {
|
||||
// Page count is unknown to the server by design, so a percentage would be a
|
||||
// guess. `block` carries the page number and `percent` stays 0 rather than
|
||||
// inventing a denominator.
|
||||
const locator = {
|
||||
chapter: 'document',
|
||||
spine: 0,
|
||||
block: this.page,
|
||||
ratio: 0,
|
||||
percent: 0,
|
||||
};
|
||||
await this.api
|
||||
.putProgress(bookId, {
|
||||
locator: serializeLocator(locator),
|
||||
percentage: 0,
|
||||
chapterTitle: `第 ${this.page} 页`,
|
||||
device: this.options.device ?? 'web',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
+7
-152
@@ -1,159 +1,14 @@
|
||||
/**
|
||||
* Neighbour prefetching.
|
||||
* Neighbour prefetching: whether to fetch the next section while this one is read.
|
||||
*
|
||||
* The reader's perceived speed is almost entirely about whether the next thing
|
||||
* the user does is already in memory. Turning a page, opening the next chapter,
|
||||
* scrolling a comic — all three are predictable one step ahead, and a phone on a
|
||||
* tunnel feels a two-second round trip on every one of them.
|
||||
*
|
||||
* The policy is deliberately conservative:
|
||||
*
|
||||
* - **One step ahead, at most** (configurable). A comic pager that fetched three
|
||||
* pages ahead on every turn would download a whole volume in a few swipes,
|
||||
* on the reader's data plan.
|
||||
* - **A byte budget, not just a count.** A chapter can be 2KB or 8MB; counting
|
||||
* requests is not the same as bounding bytes.
|
||||
* - **Skipped when the connection says so.** `navigator.connection.saveData`
|
||||
* exists for a reason, and `2g` makes prefetching actively harmful.
|
||||
* - **Cancelled on navigation.** Prefetching the chapter after the one the user
|
||||
* just left is pure waste; the in-flight request is dropped instead of being
|
||||
* allowed to finish.
|
||||
* The reader's perceived speed is almost entirely about whether the next thing the
|
||||
* user does is already in memory. Turning onto a comic's next page or a serial's next
|
||||
* chapter is predictable one step ahead, and on a phone in a tunnel every one of them
|
||||
* would otherwise be a round trip. `ReaderView.open` fetches exactly one section
|
||||
* ahead (a staged book's section loads are shared, so the page turn that catches up
|
||||
* with a prefetch does not fetch twice); this decides whether to do it at all.
|
||||
*/
|
||||
|
||||
export interface PrefetchOptions {
|
||||
/** How many items past the current one to fetch. */
|
||||
ahead?: number;
|
||||
/** Ceiling on total prefetched bytes. */
|
||||
maxBytes?: number;
|
||||
/** Item sizes from the manifest, when the format reports them. */
|
||||
sizeOf?: (key: string) => number | undefined;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
blob: Blob;
|
||||
bytes: number;
|
||||
/** Order of arrival, for eviction. */
|
||||
seq: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bounded, ordered cache of fetched resources.
|
||||
*
|
||||
* Keys are format-private refs. The cache stores Blobs rather than strings so the
|
||||
* same instance serves chapter documents and comic pages; callers decode.
|
||||
*/
|
||||
export class PrefetchCache {
|
||||
private entries = new Map<string, Entry>();
|
||||
private pending = new Map<string, Promise<Blob>>();
|
||||
private bytes = 0;
|
||||
private counter = 0;
|
||||
private readonly ahead: number;
|
||||
private readonly maxBytes: number;
|
||||
|
||||
constructor(options: PrefetchOptions = {}) {
|
||||
this.ahead = options.ahead ?? 1;
|
||||
this.maxBytes = options.maxBytes ?? 32 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/** A cached resource, if it is already here. */
|
||||
peek(key: string): Blob | undefined {
|
||||
return this.entries.get(key)?.blob;
|
||||
}
|
||||
|
||||
/** Await a resource, joining an in-flight request rather than duplicating it. */
|
||||
async get(key: string, load: (key: string) => Promise<Blob>): Promise<Blob> {
|
||||
const cached = this.entries.get(key);
|
||||
if (cached) return cached.blob;
|
||||
|
||||
const inFlight = this.pending.get(key);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const promise = load(key).then((blob) => {
|
||||
this.store(key, blob);
|
||||
return blob;
|
||||
});
|
||||
this.pending.set(key, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
this.pending.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the cache for the items around `keys[from]`.
|
||||
*
|
||||
* Returns a function that cancels the still-running requests, which the caller
|
||||
* invokes when the reader moves somewhere else.
|
||||
*/
|
||||
warm(
|
||||
keys: readonly string[],
|
||||
from: number,
|
||||
load: (key: string) => Promise<Blob>,
|
||||
options: PrefetchOptions = {},
|
||||
): () => void {
|
||||
if (!shouldPrefetch()) return () => undefined;
|
||||
const ahead = options.ahead ?? this.ahead;
|
||||
const sizeOf = options.sizeOf;
|
||||
|
||||
let budget = this.maxBytes - this.bytes;
|
||||
const aborted = new Set<string>();
|
||||
|
||||
for (let step = 1; step <= ahead; step += 1) {
|
||||
const key = keys[from + step];
|
||||
if (!key || this.entries.has(key) || this.pending.has(key)) continue;
|
||||
const estimate = sizeOf?.(key);
|
||||
if (estimate !== undefined && estimate > budget) continue;
|
||||
if (estimate !== undefined) budget -= estimate;
|
||||
|
||||
void this.get(key, load).catch(() => {
|
||||
// A failed prefetch is not an error the reader needs to see: the item is
|
||||
// still reachable by asking for it, and the foreground request will
|
||||
// report the real problem.
|
||||
});
|
||||
aborted.add(key);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const key of aborted) {
|
||||
this.entries.delete(key);
|
||||
this.pending.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Drop everything but `keep`, oldest first. Used when the book changes. */
|
||||
trim(keep: number): void {
|
||||
if (this.entries.size <= keep) return;
|
||||
const ordered = [...this.entries.entries()].sort((a, b) => a[1].seq - b[1].seq);
|
||||
for (const [key, entry] of ordered.slice(0, ordered.length - keep)) {
|
||||
this.bytes -= entry.bytes;
|
||||
this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
this.pending.clear();
|
||||
this.bytes = 0;
|
||||
}
|
||||
|
||||
private store(key: string, blob: Blob): void {
|
||||
const bytes = blob.size;
|
||||
// An oversized single resource is not cached: keeping it would evict
|
||||
// everything else and still blow the budget.
|
||||
if (bytes > this.maxBytes) return;
|
||||
this.bytes += bytes;
|
||||
this.entries.set(key, { blob, bytes, seq: this.counter++ });
|
||||
while (this.bytes > this.maxBytes) {
|
||||
const oldest = [...this.entries.entries()].sort((a, b) => a[1].seq - b[1].seq)[0];
|
||||
if (!oldest) break;
|
||||
this.bytes -= oldest[1].bytes;
|
||||
this.entries.delete(oldest[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether prefetching is welcome.
|
||||
*
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
/**
|
||||
* The reflowable book reader.
|
||||
*
|
||||
* Owns the chapter lifecycle for EPUB and TXT:
|
||||
*
|
||||
* chapter N-1 (cached) → chapter N (mounted, paginated) → chapter N+1 (prefetched)
|
||||
*
|
||||
* Four decisions are load-bearing and worth stating.
|
||||
*
|
||||
* ### 1. Chapters are fetched one at a time, and windowed
|
||||
*
|
||||
* The whole book is never downloaded. The manifest carries the first window of
|
||||
* chapters; turning past its last one fetches the next window. A 1200-chapter
|
||||
* omnibus therefore costs one window of metadata and one chapter of bytes to
|
||||
* open, and the client's memory holds at most three chapters.
|
||||
*
|
||||
* ### 2. Chapter identity is the server's `href`, never a spine index
|
||||
*
|
||||
* Windowed manifests return a prefix of the spine, so an index computed against
|
||||
* a window means a different chapter in the full spine. This was a real bug in
|
||||
* the first version of the server contract and is why items are addressed by
|
||||
* archive path.
|
||||
*
|
||||
* ### 3. Progress is reported on the chapter's turn, not on every page
|
||||
*
|
||||
* A page turn happens several times a minute and each one is a write to the
|
||||
* user's account. Reporting on page turn would be a request per swipe; reporting
|
||||
* only on chapter change would lose the reader's place inside a chapter. So it is
|
||||
* debounced, and flushed on close and on visibility change — the two moments a
|
||||
* browser may not give us another chance.
|
||||
*
|
||||
* ### 4. Append-vs-replace for the text of a book with no spine
|
||||
*
|
||||
* A TXT file with no chapter headings has no addressable structure. Scrolling it
|
||||
* as a stream is the only honest option, and the server exposes it as byte
|
||||
* ranges (`chunk:<offset>`). The reader appends rather than replacing, because
|
||||
* replacing would fight the reader's own scroll position.
|
||||
*/
|
||||
|
||||
import { ApiClient, type BookContent, type BookDto, type ContentItem } from '../net/api.ts';
|
||||
import {
|
||||
contains,
|
||||
groupForSpine,
|
||||
toLocalIndex,
|
||||
toSpineIndex,
|
||||
toWindow,
|
||||
type Window,
|
||||
} from './window.ts';
|
||||
import { ChapterContainer, type ReaderStyle } from './container.ts';
|
||||
import { Paginator, capturePosition, pageFraction, restorePosition } from './paginator.ts';
|
||||
import { PrefetchCache } from './prefetch.ts';
|
||||
import { Emitter } from '../lib/events.ts';
|
||||
import { bookPercent, parseLocator, serializeLocator, type Locator } from '../lib/locator.ts';
|
||||
import { withAssetToken } from '../net/asset-url.ts';
|
||||
|
||||
export interface ReaderState {
|
||||
book: BookDto;
|
||||
/** All groups, so the table of contents is complete before every window is fetched. */
|
||||
groups: BookContent['groups'];
|
||||
/** Items of the loaded window. */
|
||||
items: ContentItem[];
|
||||
/** Index into `items` of the chapter on screen. */
|
||||
index: number;
|
||||
/** Total chapters in the book (not just this window). */
|
||||
total: number;
|
||||
page: number;
|
||||
pageCount: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ReaderOptions {
|
||||
/** Device name recorded with each progress update. */
|
||||
device?: string;
|
||||
/** Milliseconds to coalesce progress writes; 0 disables debouncing. */
|
||||
progressDebounceMs?: number;
|
||||
/** Host platform, so the reader can adapt gesture expectations. */
|
||||
platform?: 'web' | 'android';
|
||||
/** Type size and page width, mirrored into each chapter document. */
|
||||
style: ReaderStyle;
|
||||
}
|
||||
|
||||
export class BookReader {
|
||||
readonly state: Emitter<ReaderState> = new Emitter();
|
||||
private readonly container: ChapterContainer;
|
||||
private readonly paginator: Paginator;
|
||||
private readonly cache = new PrefetchCache();
|
||||
private window: Window | null = null;
|
||||
private current: ReaderState | null = null;
|
||||
private progressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private cancelPrefetch: (() => void) | null = null;
|
||||
private readonly device: string;
|
||||
|
||||
private style: ReaderStyle;
|
||||
|
||||
constructor(
|
||||
host: HTMLElement,
|
||||
private readonly api: ApiClient,
|
||||
private readonly book: BookDto,
|
||||
private readonly options: ReaderOptions,
|
||||
) {
|
||||
this.device = options.device ?? 'web';
|
||||
this.style = options.style;
|
||||
this.container = new ChapterContainer(host);
|
||||
this.paginator = new Paginator(null, {
|
||||
columnGap: options.style.columnGap,
|
||||
pagePadding: options.style.pagePadding,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the book at a stored position.
|
||||
*
|
||||
* One request. The manifest carries the addressable structure as well as the
|
||||
* book, so there is no second round trip before the first page can be drawn —
|
||||
* which over a tunnel is the difference between a book opening and a book that
|
||||
* appears to hang.
|
||||
*/
|
||||
async open(bookId: string, group = 0): Promise<ReaderState> {
|
||||
const manifest = await this.api.manifest(bookId, group);
|
||||
this.window = toWindow(manifest.content ?? null, {
|
||||
...(manifest.kind !== undefined ? { kind: manifest.kind } : {}),
|
||||
...(manifest.total !== undefined ? { total: manifest.total } : {}),
|
||||
...(manifest.groups !== undefined ? { groups: manifest.groups } : {}),
|
||||
...(manifest.items !== undefined ? { items: manifest.items } : {}),
|
||||
});
|
||||
|
||||
const saved = parseLocator(
|
||||
(await this.api.progress(bookId).catch(() => ({ progress: null }))).progress?.locator ?? '',
|
||||
);
|
||||
|
||||
// A saved chapter is matched by its `href`, which is the only identifier
|
||||
// that survives a window boundary. Falling back to the window's first item
|
||||
// is right: it is where a book starts.
|
||||
const target = saved
|
||||
? this.window.items.findIndex((item) => item.href === saved.chapter)
|
||||
: -1;
|
||||
await this.show(target >= 0 ? target : 0, saved ?? undefined);
|
||||
return this.current!;
|
||||
}
|
||||
|
||||
/** The chapter's document, for highlight and search integration. */
|
||||
get chapterDocument(): Document | null {
|
||||
return this.container.document();
|
||||
}
|
||||
|
||||
/** Turn pages; a spill at either end moves to the neighbouring chapter. */
|
||||
async turn(pages: number): Promise<void> {
|
||||
if (!this.current || !this.window) return;
|
||||
const { changed, spill } = this.paginator.turn(pages);
|
||||
if (spill !== 0) {
|
||||
const next = this.current.index + Math.sign(spill);
|
||||
if (next < 0 || next >= this.window.items.length) {
|
||||
if (changed) this.publish();
|
||||
return;
|
||||
}
|
||||
await this.show(next);
|
||||
// A forward spill lands on the *first* page of the next chapter and a
|
||||
// backward spill on its last, which is what a real book does.
|
||||
if (spill > 0) this.paginator.goTo(0);
|
||||
else this.paginator.goTo(this.paginator.total - 1);
|
||||
this.publish();
|
||||
return;
|
||||
}
|
||||
if (changed) this.publish();
|
||||
}
|
||||
|
||||
/** Jump to a chapter by index within the loaded window. */
|
||||
/**
|
||||
* Jump to a whole-book position.
|
||||
*
|
||||
* Fetches the window that contains it when necessary, which is what makes the
|
||||
* table of contents of a 1200-chapter book work with only one window in memory.
|
||||
*/
|
||||
async goToSpine(spine: number): Promise<void> {
|
||||
if (!this.window) return;
|
||||
await this.ensureWindow(spine);
|
||||
if (!this.window || !contains(this.window, spine)) return;
|
||||
await this.show(toLocalIndex(this.window, spine));
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to a table-of-contents entry.
|
||||
*
|
||||
* Either a whole-book index or a chapter href works: a comic's table of
|
||||
* contents is a list of volumes, and its entries map to a group rather than to
|
||||
* a single chapter.
|
||||
*/
|
||||
async goToSpineOfHref(href: string): Promise<boolean> {
|
||||
if (!this.window) return false;
|
||||
const local = this.window.items.findIndex((item) => item.href === href);
|
||||
if (local >= 0) {
|
||||
await this.show(local);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Recompute geometry after a resize, keeping the reader's place. */
|
||||
relayout(): void {
|
||||
if (!this.current) return;
|
||||
const geometry = this.paginator.layout(this.container.width());
|
||||
const position = capturePosition(this.container.scroller(), this.paginator.index, geometry.stride);
|
||||
const after = this.paginator.layout(this.container.width());
|
||||
this.paginator.goTo(restorePosition(this.container.scroller(), position, after.stride));
|
||||
this.publish();
|
||||
}
|
||||
|
||||
/** Update the type size and repaginate, keeping the reader's place. */
|
||||
async restyle(style: ReaderStyle): Promise<void> {
|
||||
this.style = style;
|
||||
if (!this.current) return;
|
||||
await this.reshow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render the current chapter from the cache.
|
||||
*
|
||||
* Used after a type-size change: a chapter document is styled at load time, and
|
||||
* the only way to re-style it is to load it again. Reading it from the prefetch
|
||||
* cache makes this a re-layout rather than a network round trip.
|
||||
*/
|
||||
private async reshow(): Promise<void> {
|
||||
const state = this.current;
|
||||
if (!state) return;
|
||||
const item = this.window?.items[state.index];
|
||||
if (!item) return;
|
||||
const stride = this.paginator.layout(this.container.width()).stride;
|
||||
const position = capturePosition(this.container.scroller(), this.paginator.index, stride);
|
||||
await this.show(state.index, {
|
||||
chapter: item.href,
|
||||
spine: toSpineIndex(this.window!, state.index),
|
||||
block: position.blockIndex,
|
||||
ratio: position.offsetRatio,
|
||||
percent: state.pageCount > 1 ? state.page / state.pageCount : 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the pending progress write.
|
||||
*
|
||||
* Called on `pagehide` and when the app goes to the background. A browser that
|
||||
* is being closed does not run another timer, so a debounced write that is not
|
||||
* flushed here is a lost position.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (this.progressTimer) {
|
||||
clearTimeout(this.progressTimer);
|
||||
this.progressTimer = null;
|
||||
}
|
||||
await this.writeProgress();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.cancelPrefetch?.();
|
||||
this.cache.clear();
|
||||
this.container.destroy();
|
||||
this.state.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- internals
|
||||
|
||||
private async show(index: number, restore?: Locator): Promise<void> {
|
||||
const window = this.window;
|
||||
if (!window) return;
|
||||
const item = window.items[index];
|
||||
if (!item) return;
|
||||
|
||||
const xml = await this.cache
|
||||
.get(item.href, () => this.api.asset(this.book.id, item.href))
|
||||
.then((blob) => blob.text());
|
||||
|
||||
// The chapter's own images and stylesheets are fetched by the browser, not by
|
||||
// us, so their URLs have to be self-authenticating before the document is
|
||||
// handed to the frame.
|
||||
await this.container.mount(withAssetToken(xml, this.api), this.style);
|
||||
// The scroller belongs to the chapter's own document, so the paginator is
|
||||
// pointed at it after the frame has loaded and laid out.
|
||||
this.paginator.attach(this.container.scroller());
|
||||
const geometry = this.paginator.layout(this.container.width());
|
||||
|
||||
// Restoring an intra-chapter position is only meaningful for the chapter it
|
||||
// was captured in. Landing in a different chapter uses its first page.
|
||||
const target =
|
||||
restore && restore.chapter === item.href
|
||||
? restorePosition(this.container.scroller(), { blockIndex: restore.block, offsetRatio: restore.ratio }, geometry.stride)
|
||||
: 0;
|
||||
this.paginator.goTo(Math.min(target, geometry.pageCount - 1));
|
||||
|
||||
this.current = {
|
||||
book: this.book,
|
||||
groups: window.groups,
|
||||
items: window.items,
|
||||
index,
|
||||
total: window.total,
|
||||
page: this.paginator.index,
|
||||
pageCount: this.paginator.total,
|
||||
warnings: [],
|
||||
};
|
||||
this.scheduleProgress();
|
||||
this.prefetchFrom(index);
|
||||
this.publish();
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
if (!this.current) return;
|
||||
const state: ReaderState = {
|
||||
...this.current,
|
||||
page: this.paginator.index,
|
||||
pageCount: this.paginator.total,
|
||||
};
|
||||
this.current = state;
|
||||
this.state.emit(state);
|
||||
}
|
||||
|
||||
private scheduleProgress(): void {
|
||||
const delay = this.options.progressDebounceMs ?? 4000;
|
||||
if (delay === 0) {
|
||||
void this.writeProgress();
|
||||
return;
|
||||
}
|
||||
if (this.progressTimer) clearTimeout(this.progressTimer);
|
||||
this.progressTimer = setTimeout(() => {
|
||||
this.progressTimer = null;
|
||||
void this.writeProgress();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private async writeProgress(): Promise<void> {
|
||||
const window = this.window;
|
||||
const state = this.current;
|
||||
if (!window || !state) return;
|
||||
const item = window.items[state.index];
|
||||
if (!item) return;
|
||||
|
||||
const stride = this.paginator.layout(this.container.width()).stride;
|
||||
const position = capturePosition(this.container.scroller(), this.paginator.index, stride);
|
||||
const fraction = pageFraction(this.paginator.index, this.paginator.total);
|
||||
// The spine index is derived from the loaded window's group, never from the
|
||||
// window's own array offset: `items` holds one window, so `index` is local.
|
||||
const spine = toSpineIndex(window, state.index);
|
||||
const locator: Locator = {
|
||||
chapter: item.href,
|
||||
spine,
|
||||
block: position.blockIndex,
|
||||
ratio: position.offsetRatio,
|
||||
percent: bookPercent(spine, window.total, fraction),
|
||||
};
|
||||
|
||||
await this.api
|
||||
.putProgress(this.book.id, {
|
||||
locator: serializeLocator(locator),
|
||||
percentage: locator.percent,
|
||||
chapterTitle: item.title,
|
||||
device: this.device,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed progress write is not worth interrupting reading for. The next
|
||||
// turn retries, and the position is still on the device.
|
||||
});
|
||||
}
|
||||
|
||||
/** Prefetch the next chapter, and cancel the previous prefetch. */
|
||||
private prefetchFrom(index: number): void {
|
||||
const window = this.window;
|
||||
if (!window) return;
|
||||
this.cancelPrefetch?.();
|
||||
const keys = window.items.map((item) => item.href);
|
||||
this.cancelPrefetch = this.cache.warm(keys, index, (key) => this.api.asset(this.book.id, key), {
|
||||
// A phone on a tunnel re-reading a volume should not download it three
|
||||
// pages at a time, but it should not stall on every turn either.
|
||||
ahead: this.options.platform === 'android' ? 1 : 2,
|
||||
sizeOf: (key) => window.items.find((item) => item.href === key)?.size,
|
||||
});
|
||||
}
|
||||
|
||||
/** Load the window holding a whole-book index, if it is not loaded already. */
|
||||
private async ensureWindow(spine: number): Promise<void> {
|
||||
if (!this.window || contains(this.window, spine)) return;
|
||||
const group = groupForSpine(this.window, spine);
|
||||
if (!group) return;
|
||||
const loaded = await this.api.items(this.book.id, group.seq);
|
||||
this.window = toWindow(loaded);
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
/**
|
||||
* The plain-text reader.
|
||||
*
|
||||
* TXT is the format that punishes naive implementations, because everything the
|
||||
* reader wants — a page count, a table of contents, a scroll position that means
|
||||
* something — depends on the text being measured, and the text is 20MB.
|
||||
*
|
||||
* The approach:
|
||||
*
|
||||
* - **A table of contents comes from the server.** Chapter headings are detected
|
||||
* server-side (encoding-aware) and returned as items. The client does not
|
||||
* re-scan the text for headings, which would mean downloading the whole file
|
||||
* to build a contents list.
|
||||
* - **Chapters are fetched one at a time.** A book with headings is read exactly
|
||||
* like an EPUB, which is why this module is a thin layer over the reflowable
|
||||
* reader rather than the reader.
|
||||
* - **A book without headings is streamed in chunks.** The server exposes byte
|
||||
* ranges; the client appends them as the reader reaches the bottom. Appending
|
||||
* rather than replacing is what keeps a scroll position valid.
|
||||
*
|
||||
* Its typography is the reader's own, and it comes from the same place the
|
||||
* windowed TXT path gets it: `formats/segments.ts` decides what a paragraph is,
|
||||
* so a book cannot look like a different book depending on whether it arrived as
|
||||
* a window or as a stream. The one thing this path does differently is *add* to
|
||||
* the reading surface instead of replacing it — the split is therefore applied per
|
||||
* chunk, which is why a chunk boundary is always taken at a paragraph end (see
|
||||
* `paragraphsOf`).
|
||||
*/
|
||||
|
||||
import { ApiClient, type BookDto } from '../net/api.ts';
|
||||
import { Emitter } from '../lib/events.ts';
|
||||
import { endsAtParagraphBreak, paragraphElement, splitTextParagraphs } from '../formats/segments.ts';
|
||||
|
||||
/** Size of a streamed chunk, matching the server's own window. */
|
||||
const CHUNK_CHARS = 256 * 1024;
|
||||
/** Stop appending this far from the end. */
|
||||
const APPEND_THRESHOLD_PX = 1200;
|
||||
|
||||
export interface TextState {
|
||||
book: BookDto;
|
||||
/** Position in the text, in characters. */
|
||||
offset: number;
|
||||
/** Chapters when the book has headings; empty when it does not. */
|
||||
chapters: number;
|
||||
/** Whether more text is available to append. */
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export interface TextOptions {
|
||||
device?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a plain-text book.
|
||||
*
|
||||
* Two modes, chosen by whether the server found headings. This is not a
|
||||
* simplification: the choice changes what "a position" means (a chapter index or
|
||||
* a character offset) and therefore what a progress update contains.
|
||||
*/
|
||||
export class TextReader {
|
||||
readonly state = new Emitter<TextState>();
|
||||
private offset = 0;
|
||||
private loading = false;
|
||||
private finished = false;
|
||||
/**
|
||||
* The tail of the last chunk, which could not be split yet.
|
||||
*
|
||||
* At most one paragraph, and it exists because a split needs to see the *next*
|
||||
* line: the last paragraph of a chunk might still be continued by the first line
|
||||
* of the one after it.
|
||||
*/
|
||||
private carry = '';
|
||||
private readonly scroll: HTMLElement;
|
||||
|
||||
private readonly host: HTMLElement;
|
||||
|
||||
constructor(
|
||||
host: HTMLElement,
|
||||
private readonly api: ApiClient,
|
||||
private readonly book: BookDto,
|
||||
private readonly options: TextOptions = {},
|
||||
) {
|
||||
this.host = host;
|
||||
this.scroll = document.createElement('div');
|
||||
this.scroll.className = 'reader-text';
|
||||
this.host.replaceChildren(this.scroll);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the book, appending as the reader approaches the end.
|
||||
*
|
||||
* The offset is tracked in characters rather than bytes because the client
|
||||
* counts characters: the server's `chunk:` references are character offsets,
|
||||
* and the reply's length is reported in bytes. Mixing the two would cut a
|
||||
* Chinese novel in the middle of a character every 256KB — a visible defect
|
||||
* roughly every few pages.
|
||||
*/
|
||||
async open(): Promise<void> {
|
||||
await this.append();
|
||||
this.scroll.addEventListener('scroll', () => this.maybeAppend());
|
||||
}
|
||||
|
||||
/** Load the next chapter, when the book has a table of contents. */
|
||||
async goToChapter(index: number, bookId: string): Promise<void> {
|
||||
const blob = await this.api.asset(bookId, `chapter:${index}`);
|
||||
const { fragment } = paragraphsOf(await blob.text(), '');
|
||||
this.scroll.replaceChildren(fragment);
|
||||
this.offset = 0;
|
||||
this.scroll.scrollTop = 0;
|
||||
this.emit(false);
|
||||
await this.saveProgress(bookId, index);
|
||||
}
|
||||
|
||||
async flush(bookId: string): Promise<void> {
|
||||
await this.saveProgress(bookId, null);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.host.replaceChildren();
|
||||
this.state.clear();
|
||||
}
|
||||
|
||||
private maybeAppend(): void {
|
||||
if (this.finished || this.loading) return;
|
||||
const remaining = this.scroll.scrollHeight - this.scroll.scrollTop - this.scroll.clientHeight;
|
||||
if (remaining > APPEND_THRESHOLD_PX) return;
|
||||
void this.append();
|
||||
}
|
||||
|
||||
private async append(): Promise<void> {
|
||||
if (this.loading || this.finished) return;
|
||||
this.loading = true;
|
||||
this.emit(true);
|
||||
try {
|
||||
const blob = await this.api.asset(this.book.id, `chunk:${this.offset}`);
|
||||
const text = await blob.text();
|
||||
if (text.length === 0) {
|
||||
this.finished = true;
|
||||
return;
|
||||
}
|
||||
this.offset += text.length;
|
||||
const { fragment, rest } = paragraphsOf(text, this.carry);
|
||||
this.scroll.append(fragment);
|
||||
// Kept for the next chunk: see `paragraphsOf`. Re-emitting it is what would
|
||||
// duplicate a paragraph across a chunk boundary.
|
||||
this.carry = rest;
|
||||
// A short reply means the end was reached; asking again would return empty
|
||||
// forever and keep a spinner on screen.
|
||||
if (text.length < CHUNK_CHARS) {
|
||||
this.finished = true;
|
||||
// The final chunk is the one place where "wait for the next line" has no
|
||||
// next line; whatever was held back has to be drawn or the novel loses its
|
||||
// last paragraph.
|
||||
if (this.carry) {
|
||||
const { fragment: tail } = paragraphsOf(`${this.carry}\n\n`, '');
|
||||
this.scroll.append(tail);
|
||||
this.carry = '';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.emit(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveProgress(bookId: string, chapter: number | null): Promise<void> {
|
||||
// A streamed text has no known total length, so progress is "how far in" as a
|
||||
// fraction of what has been loaded. Reporting it as whole-book progress would
|
||||
// need the byte count the server deliberately does not compute.
|
||||
const loaded = this.scroll.scrollHeight || 1;
|
||||
const ratio = Math.max(0, Math.min(1, this.scroll.scrollTop / loaded));
|
||||
const locator =
|
||||
chapter !== null
|
||||
? JSON.stringify({ chapter: `chapter:${chapter}`, spine: chapter, block: 0, ratio, percent: ratio })
|
||||
: JSON.stringify({ chapter: `chunk:${Math.round(this.offset * ratio)}`, spine: 0, block: 0, ratio, percent: ratio });
|
||||
await this.api
|
||||
.putProgress(bookId, {
|
||||
locator,
|
||||
percentage: ratio,
|
||||
chapterTitle: chapter !== null ? `第 ${chapter + 1} 章` : '',
|
||||
device: this.options.device ?? 'web',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
private emit(loading: boolean): void {
|
||||
this.state.emit({
|
||||
book: this.book,
|
||||
offset: this.offset,
|
||||
chapters: 0,
|
||||
hasMore: !this.finished,
|
||||
loading,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A chunk of text as `<p>` elements, for the reader's typography.
|
||||
*
|
||||
* The split itself is `formats/segments.ts`, shared with the windowed TXT path so
|
||||
* the two cannot disagree about where a paragraph is. What is *not* shared is what
|
||||
* a hard-wrapped line means, and the difference is why this function exists
|
||||
* instead of a one-line call: a stream arrives in 256KB pieces, so a chunk
|
||||
* boundary usually lands in the middle of a paragraph, and both of the split's
|
||||
* rules look at the *next* line to decide. Splitting each chunk independently
|
||||
* would therefore turn every chunk boundary into a spurious paragraph break — one
|
||||
* extra indent somewhere in the middle of a novel, which is exactly the kind of
|
||||
* defect that is never reported but always visible.
|
||||
*
|
||||
* `carry` is the text left over from the previous chunk: everything the split
|
||||
* could not finish, which is at most the final incomplete paragraph. It is
|
||||
* prepended so the boundary paragraph is decided with both of its halves present,
|
||||
* and the last paragraph is held back rather than emitted — there is no way to
|
||||
* know yet whether more of it is coming.
|
||||
*/
|
||||
function paragraphsOf(
|
||||
text: string,
|
||||
carry: string,
|
||||
): { fragment: DocumentFragment; rest: string } {
|
||||
const combined = carry + text;
|
||||
const split = splitTextParagraphs(combined);
|
||||
// The last paragraph is only held back when the chunk was cut, which is the
|
||||
// case whenever it is not the final one. An empty result means the chunk ended
|
||||
// at a paragraph break, so there is nothing to carry.
|
||||
const endsClean = endsAtParagraphBreak(combined);
|
||||
const settled = endsClean ? split : split.slice(0, -1);
|
||||
const rest = endsClean ? '' : (split[split.length - 1]?.text ?? '');
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const paragraph of settled) fragment.append(paragraphElement(paragraph));
|
||||
return { fragment, rest };
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
/**
|
||||
* The client's style intervention.
|
||||
*
|
||||
* This file is the product's core differentiator, so it is worth being explicit
|
||||
* about what it does and does not do.
|
||||
*
|
||||
* ## The rule
|
||||
*
|
||||
* **The publisher's layout wins.** The client may set defaults only for things
|
||||
* the book left unspecified, and may expose values the reader is entitled to
|
||||
* control (text size, theme, page width) through variables that a book is free
|
||||
* to ignore.
|
||||
*
|
||||
* Concretely, this stylesheet never sets: `font-family`, `line-height`,
|
||||
* `text-align`, `letter-spacing`, `margin` on content elements, `writing-mode`,
|
||||
* or anything else a typographer chose. Readium CSS does set them — its premise
|
||||
* is that a consistent, controlled reading experience beats fidelity, and that
|
||||
* premise is the opposite of this product's.
|
||||
*
|
||||
* ## How the reader's settings reach the book without overwriting it
|
||||
*
|
||||
* Through CSS custom properties with fallbacks a book can ship its own value
|
||||
* for. `font-size: var(--reader-font-size, 1em)` means: a book that set an
|
||||
* explicit size keeps it, and a book that did not inherits the reader's choice.
|
||||
* That is the whole mechanism, and it is why `!important` appears nowhere here.
|
||||
*
|
||||
* ## Root font size, not per-element font sizes
|
||||
*
|
||||
* Setting `font-size` on the container only affects text that uses relative
|
||||
* units. That is deliberate: a book that declares `font-size: 14px` on a
|
||||
* paragraph has stated an intent, and the reader's slider has no business
|
||||
* overriding it. What the slider changes is the base that everything relative
|
||||
* scales from, which is the behaviour a reader expects from a real book app and
|
||||
* the only one that can be implemented without fighting the book.
|
||||
*/
|
||||
|
||||
export type ThemeMode = 'light' | 'sepia' | 'dark';
|
||||
|
||||
export interface ReaderSettings {
|
||||
/** Base font size in CSS pixels. */
|
||||
fontSize: number;
|
||||
/** Page width in CSS pixels, for the column layout. */
|
||||
pageWidth: number;
|
||||
/** Gap between columns; the paginator must be told the same number. */
|
||||
columnGap: number;
|
||||
/** Padding inside a page. */
|
||||
pagePadding: number;
|
||||
theme: ThemeMode;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: ReaderSettings = {
|
||||
fontSize: 18,
|
||||
pageWidth: 720,
|
||||
columnGap: 32,
|
||||
pagePadding: 24,
|
||||
theme: 'light',
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme palettes.
|
||||
*
|
||||
* Applied as custom properties rather than as rules on the book's elements, so a
|
||||
* book that hard-codes `color: #333` keeps it. That is the correct trade: a dark
|
||||
* theme that silently defeats a print-oriented book's contrast is a bug report,
|
||||
* whereas a book whose text stays dark on a "dark" background is one the reader
|
||||
* can see and judge.
|
||||
*/
|
||||
export const PALETTES: Record<ThemeMode, { background: string; text: string; muted: string }> = {
|
||||
light: { background: '#ffffff', text: '#111111', muted: '#6b7280' },
|
||||
sepia: { background: '#f6f0e4', text: '#3b3121', muted: '#8a7c63' },
|
||||
dark: { background: '#14161a', text: '#c9cdd4', muted: '#7c828c' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Styles shared by every chapter, applied from the *outer* document.
|
||||
*
|
||||
* They live outside the shadow root on purpose: rules that reference a shadow
|
||||
* root's own content cannot be written from outside it, but the values can still
|
||||
* be inherited in, because custom properties cross the boundary. Keeping the
|
||||
* layout rules (column count, page size, scroll behaviour) out here means the
|
||||
* per-chapter shadow root holds only what came out of the book.
|
||||
*/
|
||||
export function readerCss(hostSelector: string, settings: ReaderSettings): string {
|
||||
const palette = PALETTES[settings.theme];
|
||||
return `
|
||||
${hostSelector} {
|
||||
--reader-font-size: ${settings.fontSize}px;
|
||||
--reader-page-width: ${settings.pageWidth}px;
|
||||
--reader-column-gap: ${settings.columnGap}px;
|
||||
--reader-page-padding: ${settings.pagePadding}px;
|
||||
--reader-bg: ${palette.background};
|
||||
--reader-fg: ${palette.text};
|
||||
--reader-muted: ${palette.muted};
|
||||
--reader-chapter-progress: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: var(--reader-bg);
|
||||
color: var(--reader-fg);
|
||||
/* The container is a scroll *host*, never a scroll area: pages are moved by
|
||||
scrollLeft, so a vertical scrollbar appearing here would mean the paginator
|
||||
lost track of the geometry. */
|
||||
overflow: hidden;
|
||||
font-size: var(--reader-font-size);
|
||||
}
|
||||
|
||||
${hostSelector} .reader-viewport {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: var(--reader-page-width);
|
||||
/* One page wide; the pages after the first are reached by scrolling sideways.
|
||||
overflow-x: auto rather than scroll, so a chapter shorter than one page does
|
||||
not show a scrollbar it cannot use. */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
/* Scroll snapping makes a swipe land on a page boundary instead of between two
|
||||
pages, which is what "turning a page" means. The paginator still sets
|
||||
scrollLeft explicitly; snapping is what makes *user* gestures agree with it. */
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
${hostSelector} .reader-viewport::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Styles that must live inside the shadow root.
|
||||
*
|
||||
* A short, fixed list, and every rule in it is about the *container* rather than
|
||||
* the book: the column layout that creates pages, and the snap points that align
|
||||
* a page to the viewport. Nothing here selects a publisher element type.
|
||||
*
|
||||
* `direction: ltr` is set explicitly because the column model reverses when the
|
||||
* inherited direction flips, and a book with `dir="rtl"` on a footnote container
|
||||
* would otherwise silently paginate right-to-left from that point on.
|
||||
*/
|
||||
export function chapterCss(): string {
|
||||
return `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.reader-chapter {
|
||||
/* Columns are the pagination mechanism: the browser lays the chapter out as
|
||||
N columns of exactly one page width, and a page is a scroll offset. */
|
||||
column-width: calc(var(--reader-page-width) - var(--reader-page-padding) * 2);
|
||||
column-gap: var(--reader-column-gap);
|
||||
column-fill: auto;
|
||||
direction: ltr;
|
||||
height: 100%;
|
||||
padding: 0 var(--reader-page-padding);
|
||||
box-sizing: border-box;
|
||||
/* The reader's font size is inherited through this element, so a book's
|
||||
relative sizes scale with it and a book's absolute sizes do not. */
|
||||
font-size: var(--reader-font-size);
|
||||
color: var(--reader-fg);
|
||||
}
|
||||
.reader-chapter > * {
|
||||
scroll-snap-align: start;
|
||||
/* A block taller than a page must be splittable across columns; "avoid" would
|
||||
push a long paragraph to the next page and leave a gap. Only images opt out. */
|
||||
break-inside: auto;
|
||||
}
|
||||
.reader-chapter img,
|
||||
.reader-chapter svg {
|
||||
/* Publisher images are sized in their own units and routinely overflow the
|
||||
column. Clamping them is the one place where "do not touch the book" has to
|
||||
give way: an image that breaks the column geometry makes the whole chapter
|
||||
unreadable, not just unstyled. */
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
break-inside: avoid;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.reader-chapter img[data-reader-unsized='true'] {
|
||||
/* Reserve a line for an image the book gave no dimensions, so the chapter does
|
||||
not reflow under the reader when it finishes loading. */
|
||||
min-height: 1em;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -209,53 +209,3 @@ function isSkipped(node: Text): boolean {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chunk list, navigated by sentence rather than by index arithmetic.
|
||||
*
|
||||
* The reader keeps a cursor into this; `next`/`previous` skip over chunks whose
|
||||
* text node has been detached by a chapter change, which is what makes the
|
||||
* toolbar's buttons safe to press at any moment.
|
||||
*/
|
||||
export class ChunkCursor {
|
||||
private chunks: SpokenChunk[] = [];
|
||||
private cursor = -1;
|
||||
|
||||
get all(): readonly SpokenChunk[] {
|
||||
return this.chunks;
|
||||
}
|
||||
|
||||
get index(): number {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
get current(): SpokenChunk | null {
|
||||
return this.chunks[this.cursor] ?? null;
|
||||
}
|
||||
|
||||
reset(chunks: SpokenChunk[], index = 0): void {
|
||||
this.chunks = chunks;
|
||||
this.cursor = chunks.length === 0 ? -1 : Math.max(0, Math.min(chunks.length - 1, index));
|
||||
}
|
||||
|
||||
next(): SpokenChunk | null {
|
||||
if (this.cursor + 1 >= this.chunks.length) return null;
|
||||
this.cursor += 1;
|
||||
return this.current;
|
||||
}
|
||||
|
||||
previous(): SpokenChunk | null {
|
||||
if (this.cursor <= 0) return null;
|
||||
this.cursor -= 1;
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** The first chunk at or after `chunk`, used when resuming after a jump. */
|
||||
seekTo(chunk: SpokenChunk | null): boolean {
|
||||
if (!chunk) return false;
|
||||
const index = this.chunks.indexOf(chunk);
|
||||
if (index === -1) return false;
|
||||
this.cursor = index;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* The loaded window of a book.
|
||||
*
|
||||
* When the server returns a manifest narrowed to `?group=N`, `items` holds only
|
||||
* that group's entries. Every translation between "whole-book spine index" (what
|
||||
* progress and the table of contents use) and "index into the array in memory"
|
||||
* (what the DOM code uses) has to go through one place, or the two drift and the
|
||||
* reader opens the wrong chapter.
|
||||
*
|
||||
* This was the bug that made windowed loading look unsafe at first: two
|
||||
* different call sites each computed the offset slightly differently and got
|
||||
* different answers for the same chapter. There is now one function.
|
||||
*/
|
||||
|
||||
import type { BookContent, ContentGroup, ContentItem } from '../net/api.ts';
|
||||
|
||||
export interface Window {
|
||||
/** The window's items, in reading order. */
|
||||
items: ContentItem[];
|
||||
/** The window's first group, when the format has groups. */
|
||||
group: ContentGroup | null;
|
||||
/** All groups of the book, so a table of contents stays complete. */
|
||||
groups: ContentGroup[];
|
||||
/** Chapters/pages in the whole book, not just this window. */
|
||||
total: number;
|
||||
kind: BookContent['kind'];
|
||||
}
|
||||
|
||||
/** Build a window from a manifest response that may or may not be narrowed. */
|
||||
export function toWindow(content: BookContent | null, fallback?: Partial<BookContent>): Window {
|
||||
const items = content?.items ?? fallback?.items ?? [];
|
||||
const groups = content?.groups ?? fallback?.groups ?? [];
|
||||
const total = content?.total ?? fallback?.total ?? items.length;
|
||||
|
||||
// A narrowed response is the one that carries a `group` index; an unnarrowed
|
||||
// one starts at the book's first item by definition.
|
||||
const index = content?.group ?? 0;
|
||||
return {
|
||||
items,
|
||||
group: groups[index] ?? null,
|
||||
groups,
|
||||
total,
|
||||
kind: content?.kind ?? fallback?.kind ?? 'document',
|
||||
};
|
||||
}
|
||||
|
||||
/** Whole-book index of a local item index. */
|
||||
export function toSpineIndex(window: Window, localIndex: number): number {
|
||||
if (!window.group) return localIndex;
|
||||
return window.group.offset + Math.max(0, Math.min(window.group.count - 1, localIndex));
|
||||
}
|
||||
|
||||
/** Local item index of a whole-book index, when that index is in this window. */
|
||||
export function toLocalIndex(window: Window, spine: number): number {
|
||||
if (!window.group) return spine;
|
||||
return spine - window.group.offset;
|
||||
}
|
||||
|
||||
/** Whether a whole-book index is inside the loaded window. */
|
||||
export function contains(window: Window, spine: number): boolean {
|
||||
if (!window.group) return spine >= 0 && spine < window.items.length;
|
||||
return spine >= window.group.offset && spine < window.group.offset + window.group.count;
|
||||
}
|
||||
|
||||
/** Whose group holds a whole-book index. */
|
||||
export function groupForSpine(window: Window, spine: number): ContentGroup | null {
|
||||
return window.groups.find((group) => spine >= group.offset && spine < group.offset + group.count) ?? null;
|
||||
}
|
||||
|
||||
/** Chapter title for a whole-book index, from the groups when items are absent. */
|
||||
export function titleForSpine(window: Window, spine: number): string {
|
||||
const group = groupForSpine(window, spine);
|
||||
if (!group) return '';
|
||||
const local = group.seq === window.group?.seq && window.group ? spine - group.offset : -1;
|
||||
const item = local >= 0 ? window.items[local] : undefined;
|
||||
return item?.title ?? group.title;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Reader settings, persisted per device.
|
||||
*
|
||||
* Deliberately local rather than synced. Font size and theme are properties of
|
||||
* the screen the reader is holding — a phone and a tablet want different values —
|
||||
* whereas the reading position is a property of the reader. Syncing the first
|
||||
* would mean opening a book on the tablet in the phone's font size, which is the
|
||||
* one behaviour nobody wants.
|
||||
*
|
||||
* The palette is mirrored into the reader's own theme module so the shell's
|
||||
* chrome and the book's page never disagree about whether the reader chose dark.
|
||||
*/
|
||||
|
||||
import type { ReaderSettings, ThemeMode } from '../render/theme.ts';
|
||||
import { DEFAULT_SETTINGS } from '../render/theme.ts';
|
||||
|
||||
const KEY = 'reader.settings';
|
||||
|
||||
export function loadSettings(): ReaderSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return { ...DEFAULT_SETTINGS };
|
||||
const parsed = JSON.parse(raw) as Partial<ReaderSettings>;
|
||||
return {
|
||||
fontSize: clamp(parsed.fontSize, 12, 32, DEFAULT_SETTINGS.fontSize),
|
||||
pageWidth: clamp(parsed.pageWidth, 360, 1200, DEFAULT_SETTINGS.pageWidth),
|
||||
columnGap: DEFAULT_SETTINGS.columnGap,
|
||||
pagePadding: DEFAULT_SETTINGS.pagePadding,
|
||||
theme: isTheme(parsed.theme) ? parsed.theme : DEFAULT_SETTINGS.theme,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(settings: ReaderSettings): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
// Private browsing modes throw on write. The settings still apply for this
|
||||
// session, which is better than failing the interaction.
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the theme on the root element, where the shell's own variables live. */
|
||||
export function applyTheme(root: HTMLElement, theme: ThemeMode): void {
|
||||
root.dataset.theme = theme;
|
||||
const colors: Record<ThemeMode, string> = { light: '#ffffff', sepia: '#f6f0e4', dark: '#14161a' };
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', colors[theme]);
|
||||
}
|
||||
|
||||
function clamp(value: unknown, min: number, max: number, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
||||
return Math.max(min, Math.min(max, Math.round(value)));
|
||||
}
|
||||
|
||||
function isTheme(value: unknown): value is ThemeMode {
|
||||
return value === 'light' || value === 'sepia' || value === 'dark';
|
||||
}
|
||||
|
||||
export type { ReaderSettings };
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* Shelf ordering, in one place.
|
||||
*
|
||||
* Extracted from the screen because it has to agree with the server: the client
|
||||
* sorts its *cached* shelf while offline and the server sorts the real one, and a
|
||||
* library that reorders itself when a phone goes through a tunnel reads as the app
|
||||
* having lost the reader's choice. Keeping both rules in one function is how they
|
||||
* stay the same rule.
|
||||
*
|
||||
* `localeCompare` with `numeric: true` is the part that matters for Chinese
|
||||
* libraries: a plain string compare puts 第10章 before 第9章, because '1' < '9'.
|
||||
*/
|
||||
|
||||
import type { Book } from '../api/types.ts';
|
||||
import type { ShelfSort } from '../store/settings.ts';
|
||||
|
||||
/**
|
||||
* Direction for a sort key.
|
||||
*
|
||||
* Title and author ascend — a reader who asks for "by title" means A→Z. The date
|
||||
* sorts descend, because "recent" means newest first, and nobody has ever asked
|
||||
* for their oldest book at the top of the shelf.
|
||||
*/
|
||||
export function shelfOrder(sort: ShelfSort): 'asc' | 'desc' {
|
||||
return sort === 'title' || sort === 'author' ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* The key the *server* knows this sort by.
|
||||
*
|
||||
* The client and the server do not have the same vocabulary for two reasons, and
|
||||
* neither is negotiable:
|
||||
*
|
||||
* - **`recent` has no server equivalent.** It orders by *reading* time, and the
|
||||
* progress rows that carry it live on the `/library/continue` endpoint rather
|
||||
* than on the book list. The client has that data (it is the same store the
|
||||
* shelf draws its progress bars from), so the ordering is done on this side.
|
||||
* - **`updated` is not a server sort key.** The server offers `title`, `author`,
|
||||
* `added` and `updated`, and `updated` there means the *file's* mtime, which is
|
||||
* a different question from "when did I acquire this" — so the client only ever
|
||||
* asks for `added`.
|
||||
*
|
||||
* A device-local ordering also has the property the reader wants from it: it is
|
||||
* stable while a page is turned, because it does not depend on the network.
|
||||
*/
|
||||
export function shelfServerSort(sort: ShelfSort): 'title' | 'author' | 'added' {
|
||||
return sort === 'title' || sort === 'author' ? sort : 'added';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a sort is done on the client, over the whole cached shelf.
|
||||
*
|
||||
* This decides something visible, which is why it is a named function rather than
|
||||
* a condition at one call site: a **client-side sort must fetch every book**, because
|
||||
* sorting one page of sixty and drawing it under a pager that says "page 1 of 34"
|
||||
* is a lie about the order of the other thirty-three pages. A server-side sort can
|
||||
* page, because the server is the one doing the ordering.
|
||||
*/
|
||||
export function isLocalSort(sort: ShelfSort): boolean {
|
||||
return sort === 'recent';
|
||||
}
|
||||
|
||||
/** A book's sortable key, as a string so every branch compares the same way. */
|
||||
export function shelfSortKey(book: Book, sort: ShelfSort): string {
|
||||
switch (sort) {
|
||||
case 'title':
|
||||
return book.title ?? '';
|
||||
case 'author':
|
||||
return book.author ?? '';
|
||||
case 'added':
|
||||
// `addedAt` is when this account first saw the book; `updatedAt` moves when
|
||||
// a file changes on disk. The shelf offers the acquisition date, so that is
|
||||
// what is compared — and `updatedAt` is only the fallback for a server that
|
||||
// predates the field.
|
||||
return String(book.addedAt ?? book.updatedAt ?? 0).padStart(20, '0');
|
||||
default:
|
||||
// `recent` is *not* a key on the book: it is the reading time, which lives on
|
||||
// the progress row. See `sortByRecency`, which takes the map the shelf keeps
|
||||
// for exactly this.
|
||||
return '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How recently each book was read, as a lookup for the `recent` sort.
|
||||
*
|
||||
* Built from the continue-reading responses rather than from the sync store: the
|
||||
* server computes `lastReadAt` across devices, so a book finished on a phone sorts
|
||||
* to the top on the tablet. The value is a *timestamp*, and a book that has never
|
||||
* been opened has none — which is the ordering's whole point, because "never
|
||||
* started" is the correct position at the bottom of "where was I".
|
||||
*/
|
||||
export type ReadingTimes = ReadonlyMap<string, number>;
|
||||
|
||||
/** Newest read first, never-opened books last, ties broken on the book id. */
|
||||
export function sortByRecency(books: Book[], times: ReadingTimes): Book[] {
|
||||
return [...books].sort((left, right) => {
|
||||
const leftAt = times.get(left.id) ?? 0;
|
||||
const rightAt = times.get(right.id) ?? 0;
|
||||
if (leftAt !== rightAt) return rightAt - leftAt;
|
||||
// A stable tie-break, for the same reason `compareBooks` has one: two books
|
||||
// opened in the same second must not swap places between two renders.
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function compareBooks(left: Book, right: Book, sort: ShelfSort): number {
|
||||
const direction = shelfOrder(sort) === 'asc' ? 1 : -1;
|
||||
const result = shelfSortKey(left, sort).localeCompare(shelfSortKey(right, sort), 'zh-Hans-CN', {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
// A stable tie-break on id: without it, two books added in the same second
|
||||
// could swap places between two renders, which looks like the shelf shuffling
|
||||
// itself under the reader's thumb.
|
||||
if (result !== 0) return result * direction;
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
export function sortBooks(books: Book[], sort: ShelfSort, times?: ReadingTimes): Book[] {
|
||||
// `recent` is the one key the books themselves cannot answer, so it is routed
|
||||
// rather than squeezed through `compareBooks` with an empty key.
|
||||
if (sort === 'recent') return sortByRecency(books, times ?? new Map());
|
||||
return [...books].sort((left, right) => compareBooks(left, right, sort));
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { SourceType } from '../api/sources.ts';
|
||||
|
||||
const capabilities: Record<string, string> = {
|
||||
browse: '浏览目录', search: '搜索', 'search.filters': '搜索筛选', 'search.cancel': '取消搜索',
|
||||
'search.session': '继续搜索', 'content.alternatives': '换源', detail: '书籍详情',
|
||||
'acquire.file': '获取文件', 'acquire.chapters': '获取章节', 'content.manifest': '章节目录',
|
||||
'content.resource': '读取正文', 'content.update': '更新目录',
|
||||
};
|
||||
|
||||
export function SourceCapabilities({ type }: { type: SourceType }) {
|
||||
return <details className="source-capabilities"><summary>插件能力与兼容性</summary>
|
||||
<p>{type.label} · {type.version}</p>
|
||||
<dl>{Object.entries(capabilities).map(([key, label]) => <div key={key}><dt>{label}</dt><dd>{type.capabilities.includes(key) ? '已声明' : '未声明'}</dd></div>)}</dl>
|
||||
<p className="muted">能力由插件声明,实际可用性取决于站点、账号权限及插件适配。网站登录、验证码与付费内容由站点和插件处理。</p>
|
||||
<p className="muted">OPDS 来源用于连接外部书库;通过“书架设置 → 连接外部阅读器”可创建 Reader OPDS 服务端的只读凭据。Legado 规则需要专用适配插件,不能直接作为 Reader 插件导入。</p>
|
||||
</details>;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Pagination logic tests.
|
||||
*
|
||||
* The DOM-dependent parts are covered by the layout itself; what is testable
|
||||
* without a browser is the arithmetic, and that is where the position-drift bugs
|
||||
* live. The `stride` and `blockAt` functions are pure, so they are tested
|
||||
* directly rather than through a fake viewport that would only agree with itself.
|
||||
*/
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { pageFraction } from '../src/render/paginator.ts';
|
||||
|
||||
describe('page arithmetic', () => {
|
||||
test('a single-page chapter is always at the start', () => {
|
||||
// Returning 1/1 here would report a chapter as fully read the moment it is
|
||||
// opened, and the reader's progress would jump to the next chapter's start.
|
||||
assert.equal(pageFraction(0, 1), 0);
|
||||
});
|
||||
|
||||
test('the first and last pages are the endpoints', () => {
|
||||
assert.equal(pageFraction(0, 10), 0);
|
||||
assert.equal(pageFraction(9, 10), 1);
|
||||
});
|
||||
|
||||
test('progress never leaves [0, 1]', () => {
|
||||
assert.equal(pageFraction(-1, 10), 0);
|
||||
assert.equal(pageFraction(99, 10), 1);
|
||||
});
|
||||
|
||||
test('a page count of zero does not divide by zero', () => {
|
||||
assert.equal(pageFraction(0, 0), 0);
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,7 @@ import {
|
||||
cleanTextMarkup,
|
||||
htmlToText,
|
||||
isTextOnlyMarkup,
|
||||
endsAtParagraphBreak,
|
||||
escapeHtml,
|
||||
paragraphElement,
|
||||
splitChapterHeading,
|
||||
splitTextParagraphs,
|
||||
textToChapterHtml,
|
||||
@@ -167,13 +165,6 @@ describe('the markup form', () => {
|
||||
expect(html).not.toContain('<h3>');
|
||||
expect(html).toContain('<p>他说第二章他看过。</p>');
|
||||
});
|
||||
|
||||
it('uses textContent for the node form, so the DOM is the escaper', () => {
|
||||
const element = paragraphElement({ text: '<b>不是标签</b>', leading: 0 });
|
||||
expect(element.tagName).toBe('P');
|
||||
expect(element.textContent).toBe('<b>不是标签</b>');
|
||||
expect(element.querySelector('b')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitChapterHeading', () => {
|
||||
@@ -207,61 +198,6 @@ describe('splitChapterHeading', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Streaming across chunk boundaries.
|
||||
*
|
||||
* `render/text.ts` splits each 256KB chunk as it arrives, and both of the split's
|
||||
* rules look at the *next* line to decide — so a chunk boundary that lands inside a
|
||||
* paragraph would otherwise become a spurious paragraph break, one extra indent
|
||||
* somewhere in the middle of a novel. The fix is the carry: the last paragraph of a
|
||||
* chunk is held back until the chunk that continues it arrives.
|
||||
*
|
||||
* The arithmetic is asserted here rather than through the reader, because the
|
||||
* reader needs a DOM, a scroll container and a fetch to reach it, and the part that
|
||||
* goes wrong is the predicate.
|
||||
*/
|
||||
describe('chunk boundaries and the carry', () => {
|
||||
const endsAtBreak = endsAtParagraphBreak;
|
||||
|
||||
const settle = (text: string): { settled: string[]; rest: string } => {
|
||||
const split = splitTextParagraphs(text);
|
||||
if (endsAtBreak(text)) return { settled: split.map((p) => p.text), rest: '' };
|
||||
return { settled: split.slice(0, -1).map((p) => p.text), rest: split[split.length - 1]?.text ?? '' };
|
||||
};
|
||||
|
||||
it('holds back a paragraph that the next chunk continues', () => {
|
||||
// The boundary landed mid-sentence: emitting the tail here would draw a
|
||||
// paragraph break that the file does not have.
|
||||
expect(settle('第一段。\n\n第二段')).toEqual({ settled: ['第一段。'], rest: '第二段' });
|
||||
});
|
||||
|
||||
it('emits everything when the chunk ends at a paragraph break', () => {
|
||||
expect(settle('第一段。\n\n第二段。\n')).toEqual({ settled: ['第一段。', '第二段。'], rest: '' });
|
||||
});
|
||||
|
||||
it('carries the whole chunk when it has no complete paragraph yet', () => {
|
||||
expect(settle('第一段')).toEqual({ settled: [], rest: '第一段' });
|
||||
});
|
||||
|
||||
it('reassembles the same paragraphs as one pass over the whole text', () => {
|
||||
// The property that matters: however the text is cut, what is drawn is what a
|
||||
// single split would have drawn.
|
||||
const whole = '他走了。\n\n她留下了。\n天亮了。\n\n风停了。';
|
||||
const cuts = ['他走了。\n\n她留', '下了。\n天亮', '了。\n\n风停了。'];
|
||||
|
||||
const drawn: string[] = [];
|
||||
let carry = '';
|
||||
for (const cut of cuts) {
|
||||
const { settled, rest } = settle(carry + cut);
|
||||
drawn.push(...settled);
|
||||
carry = rest;
|
||||
}
|
||||
drawn.push(...settle(`${carry}\n\n`).settled);
|
||||
|
||||
expect(drawn).toEqual(splitTextParagraphs(whole).map((p) => p.text));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TXT files saved from a web page still carry its markup. Escaped, the tags are
|
||||
* drawn as text and every `<br/>` line becomes a gap, so they are cleaned before
|
||||
@@ -292,11 +228,6 @@ describe('web-page markup in a TXT', () => {
|
||||
expect(cleanTextMarkup('如果 a<b 且 <书名> 未知')).toBe('如果 a<b 且 <书名> 未知');
|
||||
expect(cleanTextMarkup(cleanTextMarkup(scraped))).toBe(cleanTextMarkup(scraped));
|
||||
});
|
||||
|
||||
it('treats a trailing break tag as the end of a paragraph when streaming', () => {
|
||||
expect(endsAtParagraphBreak('一段。<br/>')).toBe(true);
|
||||
expect(endsAtParagraphBreak('一段')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/** A book source's chapter is a scraped HTML fragment; legado-E reads it as text. */
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isLocalSort, shelfOrder, shelfServerSort, shelfSortKey, sortBooks, sortByRecency } from '../src/ui/shelf-order.ts';
|
||||
import type { Book } from '../src/api/types.ts';
|
||||
|
||||
/**
|
||||
* Shelf ordering.
|
||||
*
|
||||
* The rule has to match the server's, because the client sorts its own cache and
|
||||
* the server sorts the real list. A mismatch shows up as a library that reorders
|
||||
* itself when a phone goes offline — which reads as the reader's setting having
|
||||
* been ignored.
|
||||
*/
|
||||
|
||||
function book(partial: Partial<Book> & { id: string }): Book {
|
||||
return {
|
||||
title: '',
|
||||
author: '',
|
||||
publisher: '',
|
||||
language: '',
|
||||
isbn: '',
|
||||
description: '',
|
||||
series: '',
|
||||
seriesIndex: null,
|
||||
tags: [],
|
||||
pubdate: '',
|
||||
format: 'epub',
|
||||
coverUrl: null,
|
||||
fileSize: 0,
|
||||
pageCount: null,
|
||||
source: '',
|
||||
manualFields: [],
|
||||
updatedAt: 0,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('shelfOrder', () => {
|
||||
it('ascends for name keys and descends for dates', () => {
|
||||
// A reader who asks for "by title" means A→Z; "recent" means newest first.
|
||||
expect(shelfOrder('title')).toBe('asc');
|
||||
expect(shelfOrder('author')).toBe('asc');
|
||||
expect(shelfOrder('recent')).toBe('desc');
|
||||
expect(shelfOrder('added')).toBe('desc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the client/server sort split', () => {
|
||||
it('translates every client sort into one the server knows', () => {
|
||||
// `recent` is a reading-time order and the server has no equivalent, so it
|
||||
// rides on an `added` page the client is about to re-sort. Sending the client's
|
||||
// own vocabulary would be a 400 the day the server validates the parameter.
|
||||
expect(shelfServerSort('recent')).toBe('added');
|
||||
expect(shelfServerSort('added')).toBe('added');
|
||||
expect(shelfServerSort('title')).toBe('title');
|
||||
expect(shelfServerSort('author')).toBe('author');
|
||||
});
|
||||
|
||||
it('says which sorts must be done over the whole shelf', () => {
|
||||
// A client-side sort cannot page: sorting one page and drawing it under a pager
|
||||
// that says "page 1 of 34" is a lie about the other thirty-three.
|
||||
expect(isLocalSort('recent')).toBe(true);
|
||||
expect(isLocalSort('added')).toBe(false);
|
||||
expect(isLocalSort('title')).toBe(false);
|
||||
expect(isLocalSort('author')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortByRecency', () => {
|
||||
it('puts the most recently read first and never-opened books last', () => {
|
||||
const books = [
|
||||
book({ id: 'never' }),
|
||||
book({ id: 'yesterday' }),
|
||||
book({ id: 'today' }),
|
||||
];
|
||||
const times = new Map([
|
||||
['today', 900],
|
||||
['yesterday', 500],
|
||||
]);
|
||||
// "Never opened" is not "read at time zero" — it is absent, and absent belongs at
|
||||
// the bottom, which is what `?? 0` produces.
|
||||
expect(sortByRecency(books, times).map((entry) => entry.id)).toEqual(['today', 'yesterday', 'never']);
|
||||
});
|
||||
|
||||
it('is stable for two books read in the same second', () => {
|
||||
const books = [book({ id: 'z' }), book({ id: 'a' })];
|
||||
const times = new Map([
|
||||
['z', 100],
|
||||
['a', 100],
|
||||
]);
|
||||
expect(sortByRecency(books, times).map((entry) => entry.id)).toEqual(['a', 'z']);
|
||||
});
|
||||
|
||||
it('is what `sortBooks` routes `recent` to', () => {
|
||||
const books = [book({ id: 'old', updatedAt: 900 }), book({ id: 'new', updatedAt: 1 })];
|
||||
const times = new Map([['new', 999]]);
|
||||
// The book that was *updated* most recently is not the one that was *read* most
|
||||
// recently, and `recent` asks the second question.
|
||||
expect(sortBooks(books, 'recent', times).map((entry) => entry.id)).toEqual(['new', 'old']);
|
||||
expect(sortBooks(books, 'added').map((entry) => entry.id)).toEqual(['old', 'new']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortBooks', () => {
|
||||
it('sorts Chinese chapter titles numerically', () => {
|
||||
const books = [
|
||||
book({ id: 'a', title: '第10章' }),
|
||||
book({ id: 'b', title: '第9章' }),
|
||||
book({ id: 'c', title: '第2章' }),
|
||||
];
|
||||
// Plain string comparison would give 第10章 < 第2章 < 第9章, which is the bug
|
||||
// `numeric: true` exists to prevent.
|
||||
expect(sortBooks(books, 'title').map((entry) => entry.title)).toEqual(['第2章', '第9章', '第10章']);
|
||||
});
|
||||
|
||||
it('puts the newest acquisition first', () => {
|
||||
const books = [
|
||||
book({ id: 'old', title: 'Old', addedAt: 100 }),
|
||||
book({ id: 'new', title: 'New', addedAt: 300 }),
|
||||
book({ id: 'mid', title: 'Mid', addedAt: 200 }),
|
||||
];
|
||||
expect(sortBooks(books, 'added').map((entry) => entry.id)).toEqual(['new', 'mid', 'old']);
|
||||
});
|
||||
|
||||
it('uses addedAt for the acquisition sort, not the file mtime', () => {
|
||||
const books = [
|
||||
// A book acquired yesterday whose file was replaced today.
|
||||
book({ id: 'replaced', addedAt: 100, updatedAt: 900 }),
|
||||
book({ id: 'acquired', addedAt: 500, updatedAt: 500 }),
|
||||
];
|
||||
// "最近入库" asks about acquisition, not about when the file last changed. The
|
||||
// two are different questions, and `updated` is not a sort the shelf offers —
|
||||
// the acquisition date is what a reader means by "最近".
|
||||
expect(sortBooks(books, 'added').map((entry) => entry.id)).toEqual(['acquired', 'replaced']);
|
||||
});
|
||||
|
||||
it('falls back to updatedAt when the server does not send addedAt', () => {
|
||||
const older = book({ id: 'older', updatedAt: 100 });
|
||||
const newer = book({ id: 'newer', updatedAt: 200 });
|
||||
// An older server omits the field; the shelf must still order, not throw.
|
||||
expect(sortBooks([older, newer], 'added').map((entry) => entry.id)).toEqual(['newer', 'older']);
|
||||
});
|
||||
|
||||
it('is stable for books that tie, so the grid does not shuffle between renders', () => {
|
||||
const books = [
|
||||
book({ id: 'z', title: 'Same', updatedAt: 100 }),
|
||||
book({ id: 'a', title: 'Same', updatedAt: 100 }),
|
||||
];
|
||||
const first = sortBooks(books, 'title').map((entry) => entry.id);
|
||||
const second = sortBooks([...books].reverse(), 'title').map((entry) => entry.id);
|
||||
expect(first).toEqual(second);
|
||||
expect(first).toEqual(['a', 'z']);
|
||||
});
|
||||
|
||||
it('does not mutate the array it is given', () => {
|
||||
const books = [book({ id: 'b', title: 'B' }), book({ id: 'a', title: 'A' })];
|
||||
sortBooks(books, 'title');
|
||||
expect(books.map((entry) => entry.id)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('has a key for every sort', () => {
|
||||
const sample = book({ id: 'x', title: 'T', author: 'A', addedAt: 5, updatedAt: 7 });
|
||||
for (const sort of ['title', 'author', 'added', 'recent'] as const) {
|
||||
expect(shelfSortKey(sample, sort)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collectSpokenChunks, splitSentence, ChunkCursor } from '../src/render/tts-text.ts';
|
||||
import { collectSpokenChunks, splitSentence } from '../src/render/tts-text.ts';
|
||||
|
||||
/**
|
||||
* Sentence chunking.
|
||||
@@ -97,41 +97,3 @@ describe('collectSpokenChunks', () => {
|
||||
expect(chunks[0]?.start).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChunkCursor', () => {
|
||||
const chunks = ['a', 'b', 'c'].map((text, index) => ({
|
||||
text,
|
||||
node: null,
|
||||
start: 0,
|
||||
blockIndex: index,
|
||||
}));
|
||||
|
||||
it('advances and stops at the end instead of wrapping', () => {
|
||||
const cursor = new ChunkCursor();
|
||||
cursor.reset(chunks, 0);
|
||||
expect(cursor.current?.text).toBe('a');
|
||||
expect(cursor.next()?.text).toBe('b');
|
||||
expect(cursor.next()?.text).toBe('c');
|
||||
expect(cursor.next()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not move before the first chunk', () => {
|
||||
const cursor = new ChunkCursor();
|
||||
cursor.reset(chunks, 0);
|
||||
expect(cursor.previous()).toBeNull();
|
||||
expect(cursor.index).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps a reset index past the end', () => {
|
||||
const cursor = new ChunkCursor();
|
||||
cursor.reset(chunks, 99);
|
||||
expect(cursor.index).toBe(2);
|
||||
});
|
||||
|
||||
it('reports no current chunk for an empty queue', () => {
|
||||
const cursor = new ChunkCursor();
|
||||
cursor.reset([], 0);
|
||||
expect(cursor.current).toBeNull();
|
||||
expect(cursor.next()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Window arithmetic tests.
|
||||
*
|
||||
* This is the highest-risk logic in the client: every translation between a
|
||||
* whole-book index and an index into the loaded array goes through it, and a
|
||||
* mistake shows up as "resume opens the wrong chapter" — which the user only
|
||||
* notices after their position has been corrupted.
|
||||
*/
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { contains, groupForSpine, toLocalIndex, toSpineIndex, toWindow } from '../src/render/window.ts';
|
||||
import type { BookContent, ContentItem } from '../src/net/api.ts';
|
||||
|
||||
function items(from: number, count: number): ContentItem[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `s${from + i}`,
|
||||
seq: from + i,
|
||||
title: `第 ${from + i + 1} 章`,
|
||||
kind: 'chapter' as const,
|
||||
mediaType: 'application/xhtml+xml',
|
||||
href: `xhtml:ch${from + i}.xhtml`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** A windowed manifest: chapters 40..79 of a 200-chapter book, window size 40. */
|
||||
function windowedContent(): BookContent {
|
||||
return {
|
||||
kind: 'reflowable',
|
||||
total: 200,
|
||||
groups: [
|
||||
{ id: 'spine:0', seq: 0, title: '1–40', count: 40, offset: 0 },
|
||||
{ id: 'spine:40', seq: 1, title: '41–80', count: 40, offset: 40 },
|
||||
{ id: 'spine:80', seq: 2, title: '81–120', count: 40, offset: 80 },
|
||||
],
|
||||
items: items(40, 40),
|
||||
group: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('window arithmetic', () => {
|
||||
test('a windowed response reports the group it actually carries', () => {
|
||||
const window = toWindow(windowedContent());
|
||||
assert.equal(window.group?.offset, 40);
|
||||
assert.equal(window.total, 200);
|
||||
assert.equal(window.items.length, 40);
|
||||
});
|
||||
|
||||
test('local index 0 maps to the window group offset, not to 0', () => {
|
||||
// The bug this pins: computing the spine index from the array position makes
|
||||
// chapter 41 look like chapter 1, so a synced position lands 40 chapters
|
||||
// early and the reader's next device resumes in the wrong place.
|
||||
const window = toWindow(windowedContent());
|
||||
assert.equal(toSpineIndex(window, 0), 40);
|
||||
assert.equal(toSpineIndex(window, 7), 47);
|
||||
assert.equal(toLocalIndex(window, 40), 0);
|
||||
assert.equal(toLocalIndex(window, 47), 7);
|
||||
});
|
||||
|
||||
test('round-tripping a spine index through local and back is stable', () => {
|
||||
const window = toWindow(windowedContent());
|
||||
for (const spine of [40, 55, 79]) {
|
||||
assert.equal(toSpineIndex(window, toLocalIndex(window, spine)), spine);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unwindowed response has no group and identity offsets', () => {
|
||||
const content: BookContent = { kind: 'paged', total: 3, groups: [{ id: 'p', seq: 0, title: '页', count: 3, offset: 0 }], items: items(0, 3) };
|
||||
const window = toWindow(content);
|
||||
assert.equal(toSpineIndex(window, 2), 2);
|
||||
assert.equal(toLocalIndex(window, 2), 2);
|
||||
});
|
||||
|
||||
test('containment is answered from the group, not from the array length', () => {
|
||||
const window = toWindow(windowedContent());
|
||||
// Chapter 5 exists in the book but not in this window: a jump to it must
|
||||
// trigger a fetch, and answering from `items.length` would wrongly claim it.
|
||||
assert.equal(contains(window, 5), false);
|
||||
assert.equal(contains(window, 40), true);
|
||||
assert.equal(contains(window, 79), true);
|
||||
// Chapter 81 belongs to a group that is not loaded.
|
||||
assert.equal(contains(window, 81), false);
|
||||
assert.equal(contains(window, 120), false);
|
||||
});
|
||||
|
||||
test('a spine index resolves to the group that holds it', () => {
|
||||
const window = toWindow(windowedContent());
|
||||
assert.equal(groupForSpine(window, 0)?.seq, 0);
|
||||
assert.equal(groupForSpine(window, 39)?.seq, 0);
|
||||
assert.equal(groupForSpine(window, 40)?.seq, 1);
|
||||
// The fixture declares three windows; spine 199 is beyond all of them, so
|
||||
// the caller must treat it as unreachable rather than as the last group.
|
||||
assert.equal(groupForSpine(window, 119)?.seq, 2);
|
||||
assert.equal(groupForSpine(window, 199), null);
|
||||
assert.equal(groupForSpine(window, 500), null);
|
||||
});
|
||||
|
||||
test('a manifest without content falls back to the flat fields', () => {
|
||||
// The server sends `content` plus flattened copies for older clients; a build
|
||||
// that predates `content` must still open the book.
|
||||
const window = toWindow(null, { kind: 'paged', total: 2, items: items(0, 2), groups: [] });
|
||||
assert.equal(window.kind, 'paged');
|
||||
assert.equal(window.items.length, 2);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { adoptWindow, createStagedDoc, isMarkupMediaType, windowIndexOf } from '../src/formats/windowed.ts';
|
||||
import { HttpRangeSource, RemoteZip, type RangeSource } from '../src/formats/remote-zip.ts';
|
||||
import type { BookContent, ContentItem } from '../src/net/api.ts';
|
||||
|
||||
/**
|
||||
@@ -348,149 +347,3 @@ describe('staged documents', () => {
|
||||
expect(section?.image?.bytes[0]).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/** A byte source backed by an in-memory buffer, with range accounting. */
|
||||
function memorySource(bytes: Uint8Array): RangeSource & { reads: Array<[number, number]> } {
|
||||
const reads: Array<[number, number]> = [];
|
||||
return {
|
||||
reads,
|
||||
async size() {
|
||||
return bytes.byteLength;
|
||||
},
|
||||
async read(start, end) {
|
||||
reads.push([start, end]);
|
||||
return bytes.subarray(start, end + 1);
|
||||
},
|
||||
async whole() {
|
||||
return bytes;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('remote zip', () => {
|
||||
it('reads only the central directory and the requested entry', async () => {
|
||||
// The whole point: opening an archive must not read it. A test that only
|
||||
// checked the returned bytes would pass even if the implementation slurped
|
||||
// the file, so this asserts on the ranges that were actually requested.
|
||||
const zip = buildStoredZip([
|
||||
['META-INF/container.xml', '<container/>'],
|
||||
['OEBPS/ch1.xhtml', '<html><body>一</body></html>'],
|
||||
]);
|
||||
const source = memorySource(zip);
|
||||
const remote = await RemoteZip.open(source);
|
||||
const beforeEntryReads = source.reads.length;
|
||||
|
||||
const chapter = await remote.bytes('OEBPS/ch1.xhtml');
|
||||
expect(new TextDecoder().decode(chapter!)).toContain('一');
|
||||
|
||||
const entryReads = source.reads.slice(beforeEntryReads);
|
||||
const requested = entryReads.reduce((sum, [start, end]) => sum + (end - start + 1), 0);
|
||||
// Well under the archive, and more than zero: it read something, but not
|
||||
// everything.
|
||||
expect(requested).toBeLessThan(zip.byteLength);
|
||||
expect(requested).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reports a stored entry as byte-addressable and a deflated one as not', async () => {
|
||||
const zip = buildStoredZip([['page.jpg', 'raw-bytes']]);
|
||||
const remote = await RemoteZip.open(memorySource(zip));
|
||||
expect(remote.isSeekable('page.jpg')).toBe(true);
|
||||
const url = await remote.resourceUrl('page.jpg', 'https://host/api/v1/books/x/content');
|
||||
expect(url).toContain('#bytes=');
|
||||
});
|
||||
|
||||
it('falls back to a whole-file read when ranges are ignored', async () => {
|
||||
// A proxy that strips Range is not an exotic case, and a client that trusted
|
||||
// the range response would then inflate the wrong bytes and show garbage.
|
||||
const zip = buildStoredZip([['a.txt', 'hello']]);
|
||||
let servedWhole = false;
|
||||
const source: RangeSource = {
|
||||
async size() {
|
||||
servedWhole = true;
|
||||
return zip.byteLength;
|
||||
},
|
||||
async read(start, end) {
|
||||
return zip.subarray(start, end + 1);
|
||||
},
|
||||
async whole() {
|
||||
return zip;
|
||||
},
|
||||
};
|
||||
const remote = await RemoteZip.open(source);
|
||||
expect(servedWhole).toBe(true);
|
||||
expect(new TextDecoder().decode((await remote.bytes('a.txt'))!)).toBe('hello');
|
||||
});
|
||||
|
||||
it('asks for the last byte once and remembers the size', async () => {
|
||||
const zip = buildStoredZip([['a.txt', 'hello']]);
|
||||
let probes = 0;
|
||||
const source = new HttpRangeSource(async (range) => {
|
||||
if (range) {
|
||||
probes += 1;
|
||||
return { bytes: zip.subarray(range.start, range.end + 1), total: zip.byteLength, ranged: true };
|
||||
}
|
||||
return { bytes: zip, total: zip.byteLength, ranged: false };
|
||||
});
|
||||
const size = await source.size();
|
||||
await source.size();
|
||||
expect(size).toBe(zip.byteLength);
|
||||
expect(probes).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/** Minimal `stored` ZIP writer, so the tests do not depend on a zip library. */
|
||||
function buildStoredZip(entries: Array<[string, string]>): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
const chunks: Uint8Array[] = [];
|
||||
const central: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const [name, body] of entries) {
|
||||
const nameBytes = encoder.encode(name);
|
||||
const data = encoder.encode(body);
|
||||
const local = new Uint8Array(30 + nameBytes.length);
|
||||
const localView = new DataView(local.buffer);
|
||||
localView.setUint32(0, 0x04034b50, true);
|
||||
localView.setUint16(4, 20, true);
|
||||
localView.setUint16(8, 0, true); // stored
|
||||
localView.setUint32(18, data.byteLength, true);
|
||||
localView.setUint32(22, data.byteLength, true);
|
||||
localView.setUint16(26, nameBytes.length, true);
|
||||
local.set(nameBytes, 30);
|
||||
chunks.push(local, data);
|
||||
|
||||
const record = new Uint8Array(46 + nameBytes.length);
|
||||
const recordView = new DataView(record.buffer);
|
||||
recordView.setUint32(0, 0x02014b50, true);
|
||||
recordView.setUint16(4, 20, true);
|
||||
recordView.setUint16(6, 20, true);
|
||||
recordView.setUint16(10, 0, true);
|
||||
recordView.setUint32(20, data.byteLength, true);
|
||||
recordView.setUint32(24, data.byteLength, true);
|
||||
recordView.setUint16(28, nameBytes.length, true);
|
||||
recordView.setUint32(42, offset, true);
|
||||
record.set(nameBytes, 46);
|
||||
central.push(record);
|
||||
|
||||
offset += local.byteLength + data.byteLength;
|
||||
}
|
||||
|
||||
const centralSize = central.reduce((sum, part) => sum + part.byteLength, 0);
|
||||
const end = new Uint8Array(22);
|
||||
const endView = new DataView(end.buffer);
|
||||
endView.setUint32(0, 0x06054b50, true);
|
||||
endView.setUint16(8, entries.length, true);
|
||||
endView.setUint16(10, entries.length, true);
|
||||
endView.setUint32(12, centralSize, true);
|
||||
endView.setUint32(16, offset, true);
|
||||
|
||||
const all = [...chunks, ...central, end];
|
||||
const total = all.reduce((sum, part) => sum + part.byteLength, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let cursor = 0;
|
||||
for (const part of all) {
|
||||
out.set(part, cursor);
|
||||
cursor += part.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -53,8 +53,6 @@ export default defineConfig({
|
||||
exclude: [
|
||||
'test/api.test.ts',
|
||||
'test/asset-url.test.ts',
|
||||
'test/paginator.test.ts',
|
||||
'test/window.test.ts',
|
||||
'test/windowed-toc.test.ts',
|
||||
'node_modules/**',
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user