diff --git a/docs/architecture.md b/docs/architecture.md index ac0c749..13d704f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` 不存在这两处, diff --git a/docs/development.en.md b/docs/development.en.md index 8ace76b..e840dc9 100644 --- a/docs/development.en.md +++ b/docs/development.en.md @@ -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. diff --git a/docs/development.zh-CN.md b/docs/development.zh-CN.md index 8010450..03bf32e 100644 --- a/docs/development.zh-CN.md +++ b/docs/development.zh-CN.md @@ -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`。 diff --git a/docs/ui.md b/docs/ui.md index 08d4a20..07167f2 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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` | 媒体库编辑、权限和保存后的重读 | diff --git a/web/package.json b/web/package.json index 03e1771..4fede4c 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/bridge.ts b/web/src/bridge.ts deleted file mode 100644 index fa29e8e..0000000 --- a/web/src/bridge.ts +++ /dev/null @@ -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' }; -} diff --git a/web/src/features/sources/pages/index.ts b/web/src/features/sources/pages/index.ts deleted file mode 100644 index 28ecd17..0000000 --- a/web/src/features/sources/pages/index.ts +++ /dev/null @@ -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'; diff --git a/web/src/formats/remote-zip.ts b/web/src/formats/remote-zip.ts deleted file mode 100644 index df68f25..0000000 --- a/web/src/formats/remote-zip.ts +++ /dev/null @@ -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; - /** Reads `[start, end]` inclusive. Rejects when the server cannot serve it. */ - read(start: number, end: number): Promise; - /** Reads the whole file. Used only when ranges are unavailable. */ - whole(): Promise; -} - -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 { - 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 { - 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 { - 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(); - - private constructor( - private readonly source: RangeSource, - private readonly entries: Map, - private readonly order: ZipEntry[], - ) {} - - static async open(source: RangeSource): Promise { - 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 { - 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 `` fetch one page with a - * range request instead of having the whole page copied through JavaScript. - */ - async resourceUrl(path: string, url: string): Promise { - 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 { - 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 { - 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 { - 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 }; diff --git a/web/src/formats/segments.ts b/web/src/formats/segments.ts index 1afe658..2eaddc2 100644 --- a/web/src/formats/segments.ts +++ b/web/src/formats/segments.ts @@ -239,41 +239,6 @@ function toParagraph(block: string): TextParagraph { return { text: withoutLeading.trimEnd(), leading }; } -/** - * One paragraph as a `

`. - * - * `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 `

` elements. - * - * The wrapper is a `

` 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 ? `

${escapeHtml(heading)}

\n` : ''; return `
\n${promoted}${textToParagraphHtml(body)}\n
\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)); -} diff --git a/web/src/formats/txt.ts b/web/src/formats/txt.ts index c19a374..f7da62b 100644 --- a/web/src/formats/txt.ts +++ b/web/src/formats/txt.ts @@ -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; diff --git a/web/src/formats/windowed.ts b/web/src/formats/windowed.ts index f25c769..bf7033f 100644 --- a/web/src/formats/windowed.ts +++ b/web/src/formats/windowed.ts @@ -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 }; diff --git a/web/src/lib/events.ts b/web/src/lib/events.ts deleted file mode 100644 index 78be1a8..0000000 --- a/web/src/lib/events.ts +++ /dev/null @@ -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 = (value: T) => void; - -export class Emitter { - private listeners = new Set>(); - - on(listener: Listener): () => 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(); - } -} diff --git a/web/src/lib/locator.ts b/web/src/lib/locator.ts deleted file mode 100644 index 91dd229..0000000 --- a/web/src/lib/locator.ts +++ /dev/null @@ -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: , spine: , block: , 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; - 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)); -} diff --git a/web/src/render/container.ts b/web/src/render/container.ts deleted file mode 100644 index b8a78cf..0000000 --- a/web/src/render/container.ts +++ /dev/null @@ -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 - * `` with `` and a `` with ``. 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 `` or a ``. 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 | 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 `