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:
hectorqinandClaude Opus 5.5 committed 2026-10-30 09:49:37 +00:00
1 parent 12fbe0cbcd
commit d8b3491e95
33 files changed
+18 -3362

No files matched your search

+2 -2
View File
@@ -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` 不存在这两处,
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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",
-54
View File
@@ -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' };
}
-5
View File
@@ -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';
-356
View File
@@ -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 };
-48
View File
@@ -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));
}
-11
View File
@@ -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;
+2 -7
View File
@@ -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 };
-26
View File
@@ -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();
}
}
-92
View File
@@ -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));
}
-302
View File
@@ -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);
});
}
-234
View File
@@ -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;
}
}
}
-215
View File
@@ -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);
}
-157
View File
@@ -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
View File
@@ -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.
*
-385
View File
@@ -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);
}
}
-233
View File
@@ -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 };
}
-185
View File
@@ -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;
}
`;
}
-50
View File
@@ -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;
}
}
-77
View File
@@ -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;
}
-61
View File
@@ -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 };
-125
View File
@@ -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));
}
-17
View File
@@ -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>;
}
-33
View File
@@ -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);
});
});
-69
View File
@@ -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. */
-166
View File
@@ -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 -39
View File
@@ -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();
});
});
-104
View File
@@ -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);
});
});
-147
View File
@@ -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;
}
-2
View File
@@ -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/**',
],