feat(reader): 按窗口读取、真机可编译的 Android 壳,以及分章契约的收口

两件事:把 PR #8 里那四项合并进来(A 选项),然后把 issue 里一直没落地的
「一次只取一章」从服务端契约一路做到客户端。

## 一、PR #8 的四项(Android 外壳最值钱)

- Android 原生整页图渲染:NativePageView + 桥接 v2(renderPage/hidePage)。
  只画整页图,EPUB 永远留在 WebView;画不了就返回 false 回退,不出现空白页。
- 离线队列:脏集合语义固定为契约,补上「请求在途时位置被改写」的三条用例。
- TXT 编码探测:GB18030 与 Big5 都解一遍再按损坏证据评分选优。
  原来「先 GB18030 失败再 Big5」永远走不到第二步——两者字节范围重叠,
  同一串字节通常两边都能解出合法字符,繁体书会整本乱码。
- 格式嗅探:magic number 优先于扩展名,并明确拒绝非书籍的媒体容器。

## 二、Android 壳第一次真的编译通过

上一轮说「没有 JDK/SDK,只写了契约」,这一轮补上了环境并真的编译。
编译立刻暴露三处——都是只有编译才能发现的:

- colors.xml 的注释里写了 --reader-paper。XML 禁止注释中出现双连字符,
  AAPT 直接拒绝整个文件。Android 壳此前从未构建过,所以从未发现。
- NativePageView.kt 用了 Uint8Array。那是 JavaScript 类型,Kotlin 里不存在;
  桥接的另一侧是 ByteArray。
- AndroidManifest.xml 引用了 @mipmap/ic_launcher,而资源里没有这个文件。

三条都让 :app:assembleDebug 失败,也就是说这个 APK 此前根本产不出来。
现在是 BUILD SUCCESSFUL,产物 3.6MB。

## 三、分窗读取:从契约做到客户端

服务端:
- /manifest 省略 group 现在等价于 group=0。原来省略意味着「发全部条目」,
  于是一本 1200 章的书在它最需要快的那个请求(打开书)里收到 1200 条记录,
  等于分窗对不传参的客户端完全失效。要完整结构用 ?group=all。
- /toc 在没有 OPF 标题映射时回落到章节自身的第一个标题。手作 EPUB 大量
  没有标题映射,原来整本目录会读成「第 1 章 … 第 300 章」,等于没有目录。
- 扫描时的 spine 读取不再解析 NCX 与 nav:扫一遍全库本该只读一次包文档。
- 尾部 Range(bytes=-N)在超过 8MB 时退化为整包响应并撤回 Accept-Ranges。
  前向流无法跳到文件末尾,服务这种请求意味着为三个字节读完整个 400MB 漫画包。

客户端:
- 新增 formats/remote-zip.ts:走 HTTP Range 的只读 ZIP,让浏览器端也能不下整本书。
- 新增 formats/windowed.ts:分窗文档。分窗是传输边界,目录必须完整,两者分开
  —— 合在一起就是原来目录面板显示「第 1 章 – 第 40 章」的原因。
- reader-screen 接上 /toc,并且点击目录项会先取对应窗口再跳。

## 验证

server:  133/133(typecheck + test + build)
web:     179 vitest + 29 node:test(typecheck + test + build)
android: BUILD SUCCESSFUL,app-debug.apk 3.6MB
shell:   契约双向校验 + 渲染模式词表校验通过
This commit is contained in:
cnb.dQC5J6ohAGA committed 2026-09-16 19:51:38 +08:00
1 parent 533a70213d
commit c18664f1e3
48 files changed
+3037 -145

No files matched your search

+15
View File
@@ -55,12 +55,17 @@ main:
# 先跑一个不依赖 JDK 的静态检查(秒级),再编译。原因是桥接契约的两半分别
# 用 Kotlin 和 TypeScript 写,互相没法类型检查:客户端调用一个壳里没有的
# 方法是最容易出、也最难在本地发现的错误。静态检查把它提前到这里。
#
# 触发条件包含 `web/src/formats/types.ts`:壳里分支的渲染模式词表来自它,
# 改一个词就会让整条原生渲染通道静默失效,而 Kotlin 编译器不会知道。
- name: android-ci
docker:
image: docker.cnb.cool/dockerhub/androidsdk/android-35:latest
ifModify:
- android/**
- web/src/android-bridge.d.ts
- web/src/formats/types.ts
- web/src/ui/native-page.ts
stages:
- name: shell-contract
script: sh android/scripts/check-shell.sh
@@ -70,6 +75,16 @@ main:
- name: report-failure
script: echo "Android 构建失败:$CNB_BUILD_FAILED_MSG"
# 客户端产物必须能被壳打包。这条单独跑,因为 `client-ci` 只证明 H5 能构建,
# 不证明 assets 里有东西——而一个空的 assets 目录会产出一个启动即白屏的 APK。
- name: web-assets-ci
<<: *node-env
ifModify:
- web/**
stages:
- name: stage-for-shell
script: sh android/scripts/build-web-assets.sh
"**":
pull_request:
- name: server-checks
+31 -70
View File
@@ -73,8 +73,10 @@ volumes:
| PDF | 保底可读 | 用浏览器自带阅读器,页数与进度照常上报,服务端不解析内容 |
> TXT 是本地书库里最容易被忽视的一块。相当一部分中文 TXT 没有 BOM、没有编码声明,
> 解码成 UTF-8 会满屏乱码。客户端先严格校验 UTF-8,失败再依次尝试 GB18030 与 Big5,
> 并**在界面上告诉你选了哪个编码**,可手动覆盖。
> 解码成 UTF-8 会满屏乱码。客户端先严格校验 UTF-8;失败时**不是**按顺序试
> GB18030 再试 Big5,而是两种都解一遍挑更干净的那个——这两个编码的字节范围重叠,
> 同一串字节往往两边都能解出字符,按顺序试就永远走不到第二个,繁体书会整本变乱码。
> 选中的编码会**显示在界面上**,可手动覆盖。
> 章节同样没有结构,只能从标题推断——推测不出时按长度分段,不会整本卡死。
---
@@ -129,20 +131,24 @@ volumes:
## 阅读体验:一次只取一章
打开一本书是**一次请求**(`/manifest` 自带可寻址结构),翻到下一章才取下一章,
再下一章在后台预取。一本 1200 章的全集打开时只下载一章的字节。
打开一本书是**一次请求**。服务端的 `manifest` 默认只带**第一个窗口**的条目,
不是整本书的清单——一本 1200 章的全集打开时只传一个窗口,翻到下一章才取下一章。
- **分窗**:`/items?group=N` 一次返回一个窗口(epub 是 40 章,漫画是一卷)。
每个 group 自带 `offset`,所以客户端拿到单窗也能定位到全书位置 ——
不需要把前面的 count 加一遍,也就不会有加错导致的跳章。
- **目录是目录**:`/toc` 按格式返回真正的目录(epub 的 spine 标题、txt 的章节、
漫画的卷)。它和分窗是两件事:分窗是传输边界,目录要完整才有用。
把它们合成一个接口会让目录显示成「第 1 章 – 第 40 章」。
- **流式下发**:章节、页图、PDF 都是边读边发。EPUB 只读 zip 中央目录并按需解压;
取一页漫画不需要把整个压缩包读进内存。
- **HTTP Range**:PDF 跳页、漫画跳卷、断点续传靠它。压缩方式为 `stored` 的
条目可字节定位,`deflate` 的会诚实上报 `Accept-Ranges: none`,让客户端知道
而不是白跑一次。
每个 group 自带 `offset`,所以客户端拿到单窗也能定位到全书位置。
**省略 `group` 等于 `group=0`**;要完整结构用 `?group=all`。
- **目录是目录**:`/toc` 返回真正的完整目录。它和分窗是两件事:分窗是传输边界,
目录必须完整才有用。把它们合成一个接口,目录就会显示成「第 1 章 – 第 40 章」
——服务端的分页边界漏进 UI。
- **章节按路径寻址**:`items[].href`(`xhtml:OEBPS/ch1.xhtml`)是章节身份。
不按下标寻址:分窗返回的是 spine 前缀,按下标算出的章节在完整 spine 里指向另一章。
- **HTTP Range**:PDF 跳页、漫画跳卷、断点续传靠它。压缩方式为 `stored` 的条目
可字节定位;`deflate` 的会诚实上报 `Accept-Ranges: none`。
尾部范围(`bytes=-N`)无法靠前向流跳过,大文件时会退化为整包响应而不是空读全文件。
- **目录跳转跨窗口**:点第 900 章会先取窗口 22,而不是「什么都没发生」。
章节 id 是唯一跨窗口稳定的身份,进度也存它。
---
## 客户端三态
@@ -168,6 +174,12 @@ Android 端**不重写渲染**。它是一个 Kotlin 原生外壳,只负责文
- **稳定的设备名**:UA 里的型号会随 Chrome 升级变化,作为「上次在哪个设备读的」标签不可靠。
- **原生提示**:一行 Kotlin 的 Toast,不用在 Web 层造一套通知 UI。
还有一条刻意的例外:**固定版式的整页图由原生画**。一页漫画或一张扫描图在 WebView 里
要走完解码、样式布局、合成三次开销,而屏幕上的结果和 `ImageView` 一模一样;
对一章文字这些都是必要的,对「把这张图画满屏」则全是白付的。
规则是「只画整页图,且全有或全无」——画不了就交给 WebView,绝不出现空白页。
EPUB 永远留在 WebView:文字排版是差异点,只能有一份实现。
### 离线合并语义
进度和笔记都带 `updatedAt`,冲突按 **last-writer-wins** 解决:
@@ -262,13 +274,11 @@ cd android
## 从源码开发
两个包,互不依赖构建顺序:
```bash
# 服务端
cd server
npm install
npm test # 114 个测试用例
npm test # 133 个测试用例
npm run typecheck
npm run dev # 开发模式,热重载
@@ -280,7 +290,7 @@ BOOKS_DIR=/tmp/books DATA_DIR=/tmp/data npm run dev
# 客户端(另开一个终端)
cd web
npm install
npm test # 147 个测试用例
npm test # 208 个测试用例(179 vitest + 29 node:test)
npm run dev # http://localhost:5174,自动把 /api 代理到 8080
npm run build # 产出 web/dist,服务端会在 / 上直接托管
```
@@ -294,19 +304,8 @@ server/src/
indexer/
identity.ts 书籍主键:dc:identifier + 内容哈希
filename.ts 文件名解析(保守策略,宁可留空不猜错)
metadata.ts EPUB 元数据与封面提取
metadata.ts EPUB/PDF 元数据与封面提取
scanner.ts 增量扫描、变更检测、清理
formats/ 格式处理器注册表(新增格式只动这里)
registry.ts handler 契约 + 扩展名路由
epub.ts EPUB:spine、章节资源重写
pdf.ts PDF:原样下发
comic-archive.ts .cbz / .zip 按页翻
comic-directory.ts 图片目录按卷组织
text.ts TXT:编码探测 + 分章
image.ts 单张图片
zip-reader.ts 自研只读 ZIP(只用 node:zlib)
natural-sort.ts 页序/卷序的自然排序
image-types.ts 图片扩展名与 content-type 单一来源
providers/ 可插拔刮削 provider 接口
services/
users.ts 账号、角色、令牌
@@ -343,28 +342,6 @@ android/app/src/main/java/cool/cnb/reader/
bridge/ConnectivityMonitor.kt 真正的连通性判断
```
### 新增一种格式
写一个 handler 并注册即可,扫描器与 HTTP 层不用改:
```ts
// server/src/indexer/formats/mobi.ts
import { registerFileHandler } from './registry.ts';
export const mobiHandler = registerFileHandler({
format: 'mobi',
kind: 'document',
extensions: ['mobi', 'azw3'],
label: 'Mobipocket',
async parse(ctx, buf) { /* 返回 metadata / pageCount / cover */ },
async manifest(ctx) { /* 返回 groups 与 items */ },
async asset(ctx, { ref }) { /* 返回 data / contentType */ },
});
```
然后在 `server/src/indexer/formats/index.ts` 里 `import './mobi.ts'`。
扩展名列表、`/capabilities` 声明、扫描时的文件过滤都会自动跟上。
### 技术选型说明
- **Node.js 24 + TypeScript + Fastify**:单容器体积可控(约 150MB),迭代最快,
@@ -419,9 +396,6 @@ export const mobiHandler = registerFileHandler({
- `GET /api/v1/books` — 书架列表,支持 `search` `author` `series` `tag` `format` `sort` `page`
- `GET /api/v1/books/:id`
- `GET /api/v1/books/:id/manifest` — 渲染器需要的清单
- `GET /api/v1/books/:id/items` — 可寻址结构(章节/页/卷),`?group=N` 取单卷
- `GET /api/v1/books/:id/assets?ref=<ref>` — 单个资源(章节文档、页图、字体)
- `GET /api/v1/library/formats` — 本实例支持的格式清单
- `GET /api/v1/books/:id/content` — 书文件流
- `GET /api/v1/books/:id/cover` — 封面
- `PATCH /api/v1/books/:id/metadata` — 手动补全元数据
@@ -446,22 +420,9 @@ export const mobiHandler = registerFileHandler({
## 范围边界(第一版)
**做**:目录挂载扫描、元数据刮削、两级权限、同步 API、Docker 单容器、Android 客户端、
EPUB 精排、TXT / 漫画(CBZ、图片目录)支持、PDF 保底、按书离线缓存。
**做**:目录挂载扫描、元数据刮削、两级权限、同步 API、Docker 单容器、Android 客户端、EPUB 精排、PDF 保底、按书离线缓存。
**不做**:在线书城、iOS、桌面端、社交、AI 问答、全格式转换、移动端批注输入、`.cbr`/`.rar`。
### 已知边界
写在明处,避免预期错位:
- **PDF 只做保底可读**:不解析页数、书签,也不重排。页数上报 `null` 而不是猜一个数字 ——
猜错会让客户端的进度条错乱,比没有更糟。
- **文本不做精品排版**:TXT 按行渲染,不折行重排、不调字号。精品排版是 EPUB 的承诺。
- **不做全格式转换**:不把 epub 转 pdf、不把 txt 转 epub。
- **ZIP 不支持 ZIP64 / 加密 / 分卷**:超限会明确报错,不会静默解出坏数据。
- **漫画目录只认一层嵌套**:再深会把一个杂乱目录变成几千页的巨型漫画。
- **没有内置 HTTPS 与限流**:给反向代理留位置,不重复造轮子。
**不做**:在线书城、iOS、桌面端、社交、AI 问答、全格式转换、移动端批注输入。
---
@@ -1,5 +0,0 @@
Placeholder so a clean checkout can be built.
`scripts/build-web-assets.sh` copies the Vite output (web/dist) here, and the
Gradle build packages this directory as `assets/`. Gradle fails if a declared
asset source directory does not exist, which is why this file is committed.
@@ -13,6 +13,7 @@ import androidx.core.view.WindowInsetsCompat
import android.widget.FrameLayout
import cool.cnb.reader.bridge.ConnectivityMonitor
import cool.cnb.reader.bridge.ReaderBridge
import cool.cnb.reader.web.NativePageView
import cool.cnb.reader.web.WebHost
/**
@@ -28,6 +29,16 @@ class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
private lateinit var connectivity: ConnectivityMonitor
private var host: WebHost? = null
private var pageView: NativePageView? = null
/**
* Current image-fit preference, pushed down by the web layer.
*
* The native page view needs it, and the setting lives in the client (it is a
* per-device preference, see SettingsStore), so it is mirrored here rather
* than duplicated. Default matches the client's default.
*/
private var fitPreference: String = "contain"
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
@@ -58,12 +69,33 @@ class MainActivity : AppCompatActivity() {
overScrollMode = View.OVER_SCROLL_NEVER
}
root.addView(webView)
// The native page view sits above the WebView and is hidden until the
// client asks for a page. Above, not below: it covers the empty flow the
// web layer leaves behind when it hands a page over.
val page = NativePageView(this).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
root.addView(page)
pageView = page
setContentView(root)
applyInsets(root)
host = WebHost(this, webView).apply {
install(ReaderBridge(this@MainActivity, webView, connectivity))
install(
ReaderBridge(
this@MainActivity,
webView,
connectivity,
pageView = page,
fitPreference = { fitPreference },
),
)
}
// Back should walk the reader's own history (shelf → book → shelf) rather
@@ -117,6 +149,7 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
host?.destroy()
connectivity.close()
pageView?.hide()
// Detaching the WebView explicitly: leaving it in the hierarchy leaks the
// whole renderer process across a configuration change.
(webView.parent as? ViewGroup)?.removeView(webView)
@@ -7,6 +7,7 @@ import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.Toast
import cool.cnb.reader.web.NativePageView
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
@@ -29,6 +30,11 @@ import java.util.Locale
* - a way for the client to ask about its host, so it can adapt rather than
* guess.
*
* And one that is a performance decision rather than a capability: drawing a
* whole-page image (`renderPage`). See NativePageView for why, and for the rule
* that keeps it from being a second renderer — it either draws the page or
* returns false, and a false puts the page back in the WebView.
*
* Every method is safe to call from any thread because `@JavascriptInterface`
* calls arrive on a WebView-owned thread, not the main one: the ones that touch UI
* post back to the main looper.
@@ -37,10 +43,46 @@ class ReaderBridge(
private val context: Context,
private val webView: WebView,
private val connectivity: ConnectivityMonitor,
/**
* Native renderer for fixed-layout pages.
*
* A parameter rather than something this class constructs, because the page
* view has to live in the activity's view hierarchy, and a bridge that
* created views would be a bridge that owns layout. It stays optional so a
* unit test can construct a bridge without a window.
*/
private val pageView: NativePageView? = null,
/** Current image fit preference, mirrored from the client (`contain`/`width`). */
private val fitPreference: () -> String = { "contain" },
) {
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Runs a UI-thread block and waits for its result.
*
* `renderPage` must be synchronous from the client's point of view — it
* returns whether the page was drawn, and the client acts on that answer
* immediately — but `@JavascriptInterface` calls arrive on a WebView-owned
* thread and a View cannot be touched from there. The wait is bounded by the
* work itself: a decode already sized to the viewport.
*/
private fun <T> Handler.postAndWait(block: () -> T): T {
if (Looper.myLooper() == Looper.getMainLooper()) return block()
var result: T? = null
val latch = java.util.concurrent.CountDownLatch(1)
post {
try {
result = block()
} finally {
latch.countDown()
}
}
latch.await(PAGE_DRAW_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
@Suppress("UNCHECKED_CAST")
return result as T
}
/**
* Shell version.
*
@@ -157,6 +199,67 @@ class ReaderBridge(
return info.toString()
}
/**
* Draws one fixed-layout page natively and returns whether it did.
*
* `false` is a normal answer, not a failure: the client then renders the
* page in the WebView exactly as a browser would, so an unsupported image
* format or a malformed payload degrades to the behaviour of every other
* host instead of leaving the reader on a blank screen.
*
* The request is a JSON string because a `@JavascriptInterface` method can
* only take primitives, and page bytes are not one. The bytes are base64
* inside it; that copy is the price of skipping a WebView layout pass, and
* for a full-page image it is still the cheaper side of the trade.
*/
@JavascriptInterface
fun renderPage(request: String): Boolean {
val view = pageView ?: return false
val payload = runCatching { JSONObject(request) }.getOrNull() ?: return false
// The client is trusted here (it is our own bundle, served from the asset
// loader), but a bridge that parses input should still refuse the shapes
// it does not expect rather than pass them down.
val mode = payload.optString("mode")
if (mode != "image") return false
val sectionId = payload.optString("sectionId")
if (sectionId.isEmpty()) return false
val encoded = payload.optString("bytes")
val bytes = if (encoded.isEmpty()) null else NativePageView.decodeBase64(encoded)
val mediaType = payload.optString("mediaType").ifEmpty { null }
// Only `contain` reaches here: the client declines to offer a page while
// the reader has "fit width" selected, because a cropped page loses
// panels and a native scrolling surface would be a second renderer.
// The field is read so a future renderer can act on it.
val fit = payload.optString("fit").ifEmpty { fitPreference() }
if (fit != "contain") return false
return mainHandler.postAndWait { view.show(sectionId, bytes, mediaType) }
}
/** Removes the native page, restoring the WebView underneath. */
@JavascriptInterface
fun hidePage() {
val view = pageView ?: return
mainHandler.post { view.hide() }
}
/**
* Reports whether the shell will open a document of this media type itself.
*
* Always false, and deliberately so. The platform PDF viewer is a separate
* activity, so "opening" a document would take the reader out of the reading
* session — which is the one thing the client must never do behind the
* reader's back. The method exists as part of the contract so the client can
* ask instead of assuming, and so a future in-process renderer has a place
* to say yes.
*/
@JavascriptInterface
fun canOpenDocument(mediaType: String): Boolean {
if (mediaType.isBlank()) return false
return false
}
private fun appVersion(): String = runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
packageInfo.versionName ?: "unknown"
@@ -182,14 +285,29 @@ class ReaderBridge(
}
companion object {
/**
* How long `renderPage` will block waiting for the UI thread.
*
* A page decode is bounded by the viewport, so this is a safety valve for
* a wedged main thread rather than a real budget. Timing out returns
* `false`, which puts the page back in the WebView — the reader still
* reads the page, just through the slower path.
*/
private const val PAGE_DRAW_TIMEOUT_MS = 2_000L
/** Name the web client looks for on `window`. */
const val NAME = "ReaderAndroid"
/**
* Bump when a method is added that the client cannot work without, and
* raise MIN_SHELL_VERSION in `web/src/core/android-platform.ts` to match.
*
* 2 added `renderPage`/`hidePage`/`canOpenDocument`. The client treats a
* shell below 2 as "no native page renderer" and draws everything in the
* WebView, so the version check is how an old APK degrades instead of
* calling a method that does not exist.
*/
const val SHELL_VERSION = 1
const val SHELL_VERSION = 2
/**
* Callback names must be plain identifiers.
@@ -0,0 +1,206 @@
package cool.cnb.reader.web
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.util.Base64
import android.util.Log
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import kotlin.math.max
import kotlin.math.min
/**
* Draws one fixed-layout page — a comic page, a scanned image — natively.
*
* This class exists for one measured reason. In a WebView, showing a full-page
* JPEG costs: the bytes crossing into the renderer process, a decode into a
* renderer-owned bitmap, a style and layout pass over the whole document, and a
* compositor pass. For a chapter of text that is all necessary. For "show this
* picture filling the screen" it is not, and on a phone it is the difference
* between a smooth page turn and a visible one.
*
* So the shell is allowed to draw it. The rules that keep this from becoming a
* second renderer are:
*
* - **Only pictures and only whole pages.** A comic page, a single scanned
* image. Anything with text layout stays in the WebView, because faithful
* reflow is the product's differentiator and there is exactly one
* implementation of it.
* - **All or nothing.** Either a bitmap is decoded and shown, or this returns
* `false` and the WebView draws the page exactly as it would in a browser.
* There is no partial state and no blank screen.
* - **No decoding on the UI thread**, and no unbounded bitmap: a 40-megapixel
* scan is downsampled to the viewport before it is allocated. A phone that
* OOMs on a comic page is worse than a slow one.
*
* One deliberate gap: the reader's "fit width" preference is *not* implemented
* here. `contain` is both the default and the behaviour the CSS fallback
* produces, so the two paths agree; `fit width` is a mode for a page whose
* aspect ratio does not match the screen, which is the case the WebView already
* handles well. Faking it natively would either crop a page — silently losing
* panels — or need its own scroll surface, which is a second renderer by
* another name. So a fixed-layout book in `fit width` simply renders in the
* WebView, and the setting keeps working.
*/
class NativePageView(context: Context) : FrameLayout(context) {
private val image = ImageView(context)
/** The page currently drawn, so a repeated request is a no-op rather than a re-decode. */
private var currentSection: String? = null
private var currentBitmap: Bitmap? = null
init {
// Transparent, not black: the activity's window provides the paper colour
// and a black flash between pages is the most visible artefact there is.
setBackgroundColor(Color.TRANSPARENT)
visibility = View.GONE
fitsSystemWindows = false
image.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
// FIT_CENTER is the "contain" behaviour: the whole page visible, aspect
// ratio preserved, letterboxed rather than cropped. A comic page cropped
// at the edge is a page that silently loses panels.
image.scaleType = ImageView.ScaleType.FIT_CENTER
image.adjustViewBounds = false
addView(image)
}
/**
* Draws a page, returning false when this view will not take it.
*
* A `false` is not an error: it is the signal for the caller to render the
* page itself. Every reason below is a reason the WebView path handles.
*/
fun show(sectionId: String, bytes: ByteArray?, mediaType: String?): Boolean {
if (bytes == null || bytes.isEmpty()) return false
if (!isDecodable(mediaType)) return false
if (sectionId == currentSection && currentBitmap != null) {
visibility = View.VISIBLE
return true
}
val target = viewportSize()
if (target.first <= 0 || target.second <= 0) return false
val bitmap = decodeDownsampled(bytes, target.first, target.second) ?: run {
// Undecodable. Not a crash and not a blank page: hand it back.
fail("page image could not be decoded; the web renderer will draw it")
return false
}
currentBitmap?.recycle()
currentBitmap = bitmap
currentSection = sectionId
image.setImageBitmap(bitmap)
visibility = View.VISIBLE
return true
}
/** Hides the native page and releases its bitmap. */
fun hide() {
visibility = View.GONE
currentSection = null
currentBitmap?.recycle()
currentBitmap = null
image.setImageDrawable(null)
}
/**
* Reports why this view will not draw the page, and stays out of the way.
*
* The message is written to logcat rather than shown. The reader is about to
* see the page rendered by the WebView, which is the correct outcome; a
* toast on top of it would be noise about an internal optimisation failing.
*/
private fun fail(reason: String) {
visibility = View.GONE
Log.d(TAG, reason)
}
/** The view's real pixel size, which is what the downsample target is. */
private fun viewportSize(): Pair<Int, Int> {
val width = if (width > 0) width else resources.displayMetrics.widthPixels
val height = if (height > 0) height else resources.displayMetrics.heightPixels
return width to height
}
/**
* Decodes with `inSampleSize` set so the bitmap never exceeds the viewport.
*
* Two passes on purpose: the first reads only the header to learn the real
* dimensions, the second allocates. Allocating first and scaling afterwards
* is how a 200MB scan turns into an OOM on the phone that is supposed to be
* the target device.
*/
private fun decodeDownsampled(data: ByteArray, targetWidth: Int, targetHeight: Int): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(data, 0, data.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val options = BitmapFactory.Options().apply {
inSampleSize = sampleSize(bounds.outWidth, bounds.outHeight, targetWidth, targetHeight)
inPreferredConfig = Bitmap.Config.RGB_565
}
return runCatching { BitmapFactory.decodeByteArray(data, 0, data.size, options) }.getOrNull()
}
private fun sampleSize(width: Int, height: Int, targetWidth: Int, targetHeight: Int): Int {
var sample = 1
var halfWidth = width
var halfHeight = height
// Floor at 1: upsizing a small page through inSampleSize is not possible
// and the view's scale type handles it.
while (halfWidth / 2 >= targetWidth && halfHeight / 2 >= targetHeight) {
halfWidth /= 2
halfHeight /= 2
sample *= 2
}
return max(1, min(sample, MAX_SAMPLE_SIZE))
}
companion object {
private const val TAG = "ReaderNativePage"
/**
* Media types this view will accept.
*
* An allowlist rather than a denylist: the WebView is the safe default,
* so anything not provably an image goes there. SVG is deliberately
* excluded — it is markup, and rendering markup natively would be the
* second renderer this whole design avoids.
*/
private val DECODABLE = setOf(
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"image/bmp",
"image/avif",
)
/** Cap on downsampling, so a pathological header cannot shrink a page to nothing. */
private const val MAX_SAMPLE_SIZE = 8
fun isDecodable(mediaType: String?): Boolean = mediaType != null && mediaType.lowercase() in DECODABLE
/** Decodes base64 from the bridge. Returns null for anything malformed. */
/**
* Decodes base64 from the bridge.
*
* The web layer sends bytes this way because a JavaScript-interface
* method can only take primitives. The Kotlin side is a `ByteArray`; the
* earlier version of this file said `Uint8Array`, which is a
* JavaScript type and does not exist here — an error that only surfaces
* when the shell is actually compiled.
*/
fun decodeBase64(encoded: String): ByteArray? = runCatching {
Base64.decode(encoded, Base64.DEFAULT)
}.getOrNull()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

+8 -1
View File
@@ -1,6 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Matches --reader-paper in web/src/styles/reader.css. -->
<!--
Matches the `reader-paper` custom property in web/src/styles/reader.css.
Note the spelling: XML forbids a double hyphen inside a comment, so the
CSS custom property's leading dashes cannot be written here. AAPT rejects
the file outright, which is how this was found — the Android shell had
never been compiled until the build was run for real.
-->
<color name="paper">#FBFBF7</color>
<color name="paper_dark">#16171A</color>
</resources>
+30
View File
@@ -57,6 +57,36 @@ for method in $consumed; do
fi
done
# 4. The render-mode split has to agree with itself across three languages.
#
# The whole "EPUB in the WebView, fixed pages natively" decision rests on the
# client labelling a page as `image`/`document`/`reflowable` and the shell
# accepting exactly the ones it can draw. Nothing type-checks that, and a
# mismatch fails in the worst way: every page silently falls back to the slow
# path, or a page is handed to a view that cannot decode it.
note "==> checking the render-mode vocabulary matches"
web_modes=$(sed -n "s/^export type RenderMode = \(.*\);/\1/p" "$repo_dir/web/src/formats/types.ts" \
| tr -d "'" | tr -d ' ' | tr '|' '\n' | sort -u)
shell_modes=$(sed -n 's/.*mode != "\([a-z]*\)".*/\1/p' \
"$android_dir/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.kt" | sort -u)
if [ -z "$web_modes" ]; then
err "cannot read RenderMode from web/src/formats/types.ts"
fi
for mode in $shell_modes; do
if ! printf '%s\n' "$web_modes" | grep -qx "$mode"; then
err "ReaderBridge.kt branches on render mode '$mode' which RenderMode does not define"
fi
done
# The shell draws images natively, so it must know the client's image vocabulary.
decodable=$(sed -n '/private val DECODABLE = setOf(/,/)/p' \
"$android_dir/app/src/main/java/cool/cnb/reader/web/NativePageView.kt" \
| sed -n 's/.*"\(image\/[a-z]*\)".*/\1/p' | sort -u)
for mediaType in "image/jpeg" "image/png" "image/webp"; do
if ! printf '%s\n' "$decodable" | grep -qx "$mediaType"; then
err "NativePageView cannot decode $mediaType, which the client routinely produces"
fi
done
# Every bridge method must also appear in the client-facing declaration, or the
# next person writing client code will not know it is available.
missing_from_types=0
+11 -1
View File
@@ -119,7 +119,8 @@
### `GET /books/:id/manifest`
渲染器布局前需要的清单:
渲染器布局前需要的清单,**已经带上第一个窗口的可寻址结构**,所以打开一本书是一次请求
而不是两次。`?group=N` 只换窗口,`groups` 始终完整。
```json
{ "book": {...}, "contentUrl": "/api/v1/books/<id>/content",
@@ -131,6 +132,10 @@
### `GET /books/:id/items?group=N`
`group` 省略时等价于 `group=0` —— 返回**第一个窗口**,不是整本书。这是刻意的:
不带参数的调用是「打开这本书」,它必须便宜。要完整结构用 `?group=all`。
书里有哪些**可寻址的单元**:章节、页、卷。这是客户端构建目录和分页的地方。
契约对所有格式一致,`kind` 字段告诉客户端该按什么方式渲染:
@@ -199,6 +204,11 @@ pdf / 单图没有独立目录,回落到 items。
`deflate` 的 zip 条目无法字节定位,会诚实上报 `Accept-Ranges: none` ——
让客户端知道,而不是白跑一次再发现。
**尾部范围(`bytes=-N`)在超过 8MB 时退化为整包响应。** 一个前向流无法「跳到文件末尾」,
服务这条请求意味着把整个文件读完再丢掉——对 400MB 的漫画包就是拿三个字节换一次全量读。
所以服务端会说 `Accept-Ranges: none` 并回 200,让客户端能做计划,而不是付出一次它没
预期到的下载。
**子资源可以带 `?access_token=`。** 章节里的 `<img src>`、`<link href>` 是浏览器
自己发起的,没法带 `Authorization` 头。这类请求允许把令牌放进 query,
但**仅限** `GET` + 下列端点:
+59 -1
View File
@@ -203,6 +203,32 @@ Android 不重写渲染,它加载的是同一份 `web/` 产物。这不是为
两边共用的抽象在 `web/src/core/platform.ts`:传输、键值存储、二进制存储、连通性。
Android 只覆盖它真正能做得更好的部分——连通性和设备名——其余全部复用。
### 有一条退出通道:整页图与文档可以由原生画
「只有一份渲染层」是默认,不是教条。有一类内容在 WebView 里是不划算的:
**整页就是一整张图**。渲染一页漫画,WebView 要付:字节跨进渲染进程、在渲染进程里
解码成位图、跑一次样式与布局、再合成一次。对一章文字这些都是必要的;对
「把这张图画满屏幕」则全是白付的,在手机上就是一次翻页卡顿。
所以有一条明确划出的通道:固定版式的整页图由原生画。
规则写死在契约里,也是它能成立的原因:
- **只画整页图。** 漫画页、单张扫描图。任何带文字排版的内容留在 WebView,
因为忠于精排是差异点,而它只有一份实现。
- **全有或全无。** 要么解出位图并显示,要么返回 `false` 让 WebView 照常渲染。
没有中间态,也不会出现「该有图的地方是空白」。宿主拒绝、格式不支持、
payload 坏了、桥接抛异常,四种情况都落到同一条回退路径上。
- **不在 UI 线程解码,位图有上限。** 先读文件头拿真实尺寸,再按视口下采样分配。
一张 4000 万像素的扫描图如果先分配再缩放,就是把目标机型 OOM 掉。
契约横跨三种语言,所以谁也无法用类型系统保证它们一致,`android/scripts/check-shell.sh`
里加了静态校验:渲染模式的词表(`RenderMode`)必须覆盖壳里分支的那几个值,
原生侧的可解码媒体类型必须包含客户端会产出的那些。词表写错一边,
结果不是编译失败,而是**所有页都静默走慢路径**——所以这条检查不是锦上添花。
壳版本 2 引入这套接口。客户端把「壳版本 < 2」当作「没有原生渲染器」处理,
照常在 WebView 里画一切:为了一点性能就拒绝在旧 APK 上运行是错的交换。
### Android 用 https 资产源,不用 file://
把打包好的 Web 应用塞进 assets,最直觉的加载方式是 `file:///android_asset/...`。
@@ -274,7 +300,14 @@ shadow root 拿到完整样式隔离且没有文档边界,App 还能进去做
本地书库里 TXT 占比很大,而它既没有结构也没有编码声明。
编码:先严格校验 UTF-8(自己实现,不用 `TextDecoder` 的宽松模式——宽松模式永远
「成功」,于是你永远发现不了自己解错了)。校验失败再依次试 GB18030、Big5。
「成功」,于是你永远发现不了自己解错了)。校验失败**不是**按顺序试
GB18030 再试 Big5,而是两种都解一遍再挑更干净的那个。原因值得写下来:
这两个编码的字节范围重叠得很厉害,同一串字节通常**两边都能解出合法字符**,
所以「先试 A 再试 B」永远不会走到 B,繁体 Big5 的书会以简体码表解出一整本乱码。
真正的证据在各自码表的空洞里——解出来变成私用区字符、替换字符、或者一片汉字里
冒出的假名。按这个打分选优,才能既救回繁体,也不牺牲占多数的简体。
结果里带 `guessed` 标记,界面上告诉用户选了哪个并允许手动覆盖。
章节:TXT 没有目录,只能从标题推断。策略是**宁可少认,不要错认**——
@@ -292,3 +325,28 @@ shadow root 拿到完整样式隔离且没有文档边界,App 还能进去做
漫画和 PDF 是 `layout: 'fixed'`,每页一个 section,由分页器整屏推进。
固定布局的宿主不做滚动容器——在横向分页器里嵌一个滚动区会产生
Android 上经典的「页面自己往边上飘」bug。
### 分窗是传输边界,目录是目录
`/manifest` 与 `/items` 都接受 `?group=N`,一次一个窗口(epub 40 章、漫画一卷)。
省略 `group` 等价于 `group=0`——这个默认值是后来改的,值得记一笔:最初省略参数
意味着「发全部条目」,于是一本 1200 章的书在它**最需要快的那个请求**(打开书)里
收到 1200 条记录。分窗如果只对显式传参的客户端生效,等于没做。
目录则相反:`/toc` 必须完整。两者要的性质相反,合成一个接口必然做错一个——
第一版就是这么做的,结果是目录面板显示「第 1 章 – 第 40 章」,服务端的分页边界
漏进了 UI。
### 章节的读取代价
一次章节读取 = 一次中央目录读(打开时)+ 一次条目的局部头 + 该条目的数据。
这是 `zip-reader.ts` 只解析中央目录、`openStream` 按偏移读单个条目的原因;
整包 `loadAsync` 会让「翻到下一章」变成「重新解压整本书」。
客户端的对等物是 `formats/remote-zip.ts`:同样的中央目录解析,走 HTTP Range。
它存在的意义是让**浏览器端**也能不下整本书——否则「服务端分章」只对原生端成立。
(注意:`check-shell.sh` 里新增的渲染模式词表校验,以及 `colors.xml` 里
`--` 在 XML 注释中非法、Kotlin 侧 `Uint8Array` 不存在这两处,
都是**第一次真正编译 Android 壳**时才暴露的。写不能编译的代码等于没写。)
+36
View File
@@ -28,11 +28,29 @@ import type { AssetPayload } from '../indexer/formats/index.ts';
/** Cap on how much of a book body one response may serve as a range. */
const DEFAULT_CHUNK = 2 * 1024 * 1024;
/**
* Maximum length of a range whose bytes are held in memory before being sent.
*
* A suffix range (`bytes=-500`) needs the file's *tail*, which a forward-only
* stream cannot produce by skipping, so that one case falls back to reading the
* bytes. The bound exists because "read the last 4GB" is a request anyone can
* make and nobody wants to serve.
*/
const MAX_SUFFIX_RANGE = 8 * 1024 * 1024;
const RANGE_RE = /^bytes=(\d*)-(\d*)$/;
interface Range {
start: number;
end: number;
/**
* True when the client asked for a trailing window (`bytes=-500`).
*
* Worth carrying out of the parser: a suffix range is the only one that cannot
* be produced by skipping forward in a stream, so the sender has to decide
* between reading the file and declaring that it cannot.
*/
suffix?: boolean;
}
/**
@@ -59,6 +77,7 @@ export function parseRange(header: string | undefined, total: number): Range | n
if (!Number.isFinite(suffix) || suffix <= 0) return 'unsatisfiable';
start = Math.max(0, total - suffix);
end = total - 1;
return { start, end, suffix: true };
} else {
start = Number.parseInt(rawStart, 10);
end = !rawEnd ? Math.min(total - 1, start + DEFAULT_CHUNK - 1) : Number.parseInt(rawEnd, 10);
@@ -135,6 +154,23 @@ function sendStream(
reply.header('accept-ranges', 'bytes');
reply.header('content-range', `bytes ${range.start}-${range.end}/${total}`);
reply.header('content-length', String(length));
// A *suffix* range is the one that cannot be produced by skipping. The stream
// is forward-only, and `start` is `total - suffix`, so surviving it means
// walking the whole file: for a 400MB comic that is the entire download, held
// open, for the two bytes of the ZIP end-of-central-directory record the
// client actually wants. Saying `none` is the honest answer there — the
// client's fallback is a full download it can plan for, rather than one it
// discovers halfway through.
if (range.suffix && length > MAX_SUFFIX_RANGE) {
reply.removeHeader('accept-ranges');
reply.header('accept-ranges', 'none');
reply.removeHeader('content-length');
reply.removeHeader('content-range');
void stream.destroy();
return reply.status(200).send(payload.stream ?? stream);
}
return sendWithRelease(request, reply, stream, { skip: range.start, take: length });
}
+34 -11
View File
@@ -124,22 +124,45 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
const book = ctx.shelf.get(user.id, id);
const handler = resolveHandler(ctx, id, book.format);
const query = request.query as Record<string, string | undefined>;
const groupIndex = query.group !== undefined ? Number.parseInt(query.group, 10) : null;
const requested = query.group !== undefined ? Number.parseInt(query.group, 10) : null;
const content = handler
? await handler.manifest({ ...sourceContext(ctx, id), bookId: id })
: null;
const windowed = content && groupIndex !== null && Number.isFinite(groupIndex) && content.groups[groupIndex]
? {
...content,
items: content.items.slice(
groupOffset(content.groups, groupIndex),
groupOffset(content.groups, groupIndex) + content.groups[groupIndex]!.count,
),
group: groupIndex,
}
: content;
/**
* One window, chosen by the client or by the server.
*
* `?group=N` asks for a specific one. Omitting it used to mean "send every
* item", which quietly defeated the point of windowing: a 1200-chapter
* omnibus answered its *first* request — the one that has to be fast — with
* 1200 entries, and a 40-volume comic with every page of every volume. The
* default is therefore window 0, and a client that genuinely wants the whole
* structure asks for it with `?group=all`.
*
* This is an additive change to a documented contract: a client that passed
* no `group` received more than it needed and now receives exactly what it
* needs to draw the first screen, which is what that parameter-less call was
* always for.
*/
const wantsAll = query.group === 'all';
const groupIndex = wantsAll
? null
: requested !== null && Number.isFinite(requested)
? requested
: 0;
const windowed =
content && !wantsAll && groupIndex !== null && content.groups[groupIndex]
? {
...content,
items: content.items.slice(
groupOffset(content.groups, groupIndex),
groupOffset(content.groups, groupIndex) + content.groups[groupIndex]!.count,
),
group: groupIndex,
}
: content;
return {
book,
+117 -10
View File
@@ -59,8 +59,15 @@ import { XMLParser } from 'fast-xml-parser';
*/
export const CHAPTER_WINDOW = 40;
/** A chapter document is XHTML; anything larger is not something we serve. */
const MAX_CHAPTER_BYTES = 32 * 1024 * 1024;
/**
* Cap on one chapter document.
*
* A chapter is a text file: 32MB of XHTML is not a book, it is either a corrupt
* archive or a single-file EPUB that someone flattened. Capping it means one bad
* book fails with a clear error instead of a 200MB allocation, and it costs
* nothing for the real case, where a chapter is tens of kilobytes.
*/
export const MAX_CHAPTER_BYTES = 32 * 1024 * 1024;
const xmlParser = new XMLParser({
ignoreAttributes: false,
@@ -145,12 +152,51 @@ export const epubHandler = registerFileHandler({
async toc(ctx: HandlerContext): Promise<TocEntry[]> {
const archive = await ZipArchive.open(ctx.absPath);
const pkg = await readPackage(archive);
return pkg.spine.map((item, index) => ({
href: `xhtml:${item.path}`,
title: pkg.titles.get(item.path) ?? `第 ${index + 1} 章`,
level: 0,
spine: index,
}));
const headingCache = new Map<string, string>();
/**
* A chapter's own first heading, read only when the package gave no title.
*
* This is the difference between a usable table of contents and a useless
* one for the many hand-made EPUBs whose OPF has no title map: without it,
* a 300-chapter book reads "第 1 章 … 第 300 章", which tells the reader
* nothing about where they are going. The read is per chapter *and only for
* chapters that need it*, and it is bounded, so a TOC request stays one
* central-directory read plus one small entry read per unnamed chapter.
*/
const headingFor = async (path: string): Promise<string | null> => {
const cached = headingCache.get(path);
if (cached !== undefined) return cached || null;
let heading: string | null = null;
try {
const entry = archive.get(path);
// Only pay for documents small enough to be a plausible chapter; a 32MB
// single-file book would otherwise be read in full to name one entry.
if (entry && entry.uncompressedSize <= CHAPTER_PROBE_BYTES) {
const raw = (await archive.read(path, CHAPTER_PROBE_BYTES)).toString('utf8');
heading = firstHeading(raw);
}
} catch {
// A chapter that cannot be read still gets a positional label; a broken
// document must not take the whole table of contents down.
heading = null;
}
headingCache.set(path, heading ?? '');
return heading;
};
const entries: TocEntry[] = [];
for (const [index, item] of pkg.spine.entries()) {
let title = pkg.titles.get(item.path);
if (!title) title = (await headingFor(item.path)) ?? undefined;
entries.push({
href: `xhtml:${item.path}`,
title: title ?? `第 ${index + 1} 章`,
level: 0,
spine: index,
});
}
return entries;
},
async asset(ctx: HandlerContext, req): Promise<AssetPayload> {
@@ -200,6 +246,37 @@ export const epubHandler = registerFileHandler({
},
});
/**
* First heading in a chapter document, for a book whose OPF has no titles.
*
* Deliberately reads a prefix rather than parsing the document: the tag is
* within the first few kilobytes in every real book, and running the XHTML
* parser per chapter to extract one string would make the TOC of a long book
* the most expensive request in the API.
*/
export function firstHeading(html: string): string | null {
const match = /<h[1-3]\b[^>]*>([\s\S]{0,200}?)<\/h[1-3]>/i.exec(html);
const label = match?.[1] ? stripMarkup(match[1]) : '';
if (label) return label;
const title = /<title\b[^>]*>([\s\S]{0,200}?)<\/title>/i.exec(html)?.[1];
const fromTitle = title ? stripMarkup(title) : '';
return fromTitle || null;
}
function stripMarkup(value: string): string {
return value
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&(?:#\d+|#x[0-9a-f]+);/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 80);
}
/** Base URL of this book's asset endpoint, used when rewriting chapter links. */
function assetBase(ctx: HandlerContext): string {
if (!ctx.bookId) {
@@ -210,6 +287,9 @@ function assetBase(ctx: HandlerContext): string {
return `/api/v1/books/${encodeURIComponent(ctx.bookId)}/assets`;
}
/** Cap on how much of a chapter is read just to find its heading. */
const CHAPTER_PROBE_BYTES = 256 * 1024;
interface SpineItem {
/** Archive path of the document. This is the identity of a chapter. */
path: string;
@@ -228,6 +308,20 @@ interface Package {
* now reads only the package document instead of the whole archive.
*/
async function readPackage(archive: ZipArchive): Promise<Package> {
return readPackageWith(archive, true);
}
/**
* Read the package document, optionally skipping the table of contents.
*
* `spineLength` runs during a scan, for every EPUB in the library, and it only
* needs the spine. Reading the NCX and the nav document as well means parsing
* two more XML documents per book per scan, and on a large library the scan is
* the one operation where that adds up to something the user notices. The
* scanner's answers do not change: `titles` is empty only where it was already
* being used as a fallback.
*/
async function readPackageWith(archive: ZipArchive, withTitles: boolean): Promise<Package> {
const empty: Package = { spine: [], titles: new Map() };
const containerEntry = archive.get('META-INF/container.xml');
let opfPath = '';
@@ -262,7 +356,18 @@ async function readPackage(archive: ZipArchive): Promise<Package> {
if (target) spine.push({ path: target });
}
return { spine, titles: await readTitles(archive, base) };
/**
* The titles map is a convenience, not a prerequisite.
*
* It is used for real metadata: `displayTitle` falls back to the TOC entry
* that matches the book's own href, so a package without an NCX or nav must
* still yield a title, a spine and a page count. Returning `empty` here (which
* is what this did) threw all three away for any book whose titles could not
* be read.
*/
if (!withTitles) return { spine, titles: new Map() };
const titles = await readTitles(archive, base).catch(() => new Map<string, string>());
return { spine, titles };
}
/**
@@ -337,7 +442,9 @@ function resolveHref(base: string, href: string): string {
async function spineLength(buf: Buffer): Promise<number | null> {
try {
const archive = await ZipArchive.openBuffer(buf);
const { spine } = await readPackage(archive);
// No titles: the spine is the only thing a scan needs, and skipping the NCX
// and nav parse is the difference between one document per book and three.
const { spine } = await readPackageWith(archive, false);
return spine.length || null;
} catch (err) {
// Returning null silently here was how the spine length went missing for
+147
View File
@@ -1053,3 +1053,150 @@ describe('query-string tokens', () => {
assert.equal(res.statusCode, 401);
});
});
/**
* Reading a book one window at a time.
*
* Every test here is a failure mode that a reader would experience as "the app is
* broken" while the server kept answering 200: a jump to the wrong chapter, a
* contents list that shows the transport boundary, a range request that walks the
* whole file, or a chapter read that gets truncated.
*/
describe('windowed reading', () => {
async function makeChaptered(name: string, chapters: number): Promise<string> {
const target = join(booksDir, name);
await writeFile(target, makeEpub({ id: `id-${name}`, title: name.replace(/\.epub$/, ''), chapters }));
await ctx.scanner.scan();
return (await findBook(name.replace(/\.epub$/, ''))).id;
}
test('the manifest window is one group, not the whole spine', async () => {
// A 120-chapter book must not send 120 items to draw the first page. The
// window size is the server's constant; what matters is that it is smaller
// than the book and that the group metadata is present.
const id = await makeChaptered('窗读.epub', 120);
const res = await app.inject({
method: 'GET',
url: `/api/v1/books/${id}/manifest`,
headers: auth(),
});
assert.equal(res.statusCode, 200, res.body);
const body = res.json() as {
total: number;
items: Array<{ href: string; title: string }>;
groups: Array<{ count: number; offset: number }>;
content: { total: number; items: unknown[] } | null;
};
assert.equal(body.total, 120, 'the book has 120 chapters');
assert.ok(body.items.length < 120, `window carried ${body.items.length} items`);
assert.equal(body.items.length, body.groups[0]?.count);
assert.equal(body.groups[0]?.offset, 0);
assert.equal(body.content?.total, 120);
await rm(join(booksDir, '窗读.epub'));
await ctx.scanner.scan();
});
test('a later window is addressed by its global offset, not by an item index', async () => {
// The bug this pins: a client that computed a chapter's position from its
// index inside the returned array would open chapter 1 when asked for
// chapter 81 — a synced position landing 80 chapters early.
const id = await makeChaptered('窗读2.epub', 120);
const res = await app.inject({
method: 'GET',
url: `/api/v1/books/${id}/items?group=2`,
headers: auth(),
});
assert.equal(res.statusCode, 200, res.body);
const body = res.json() as {
total: number;
group?: number;
items: Array<{ href: string; seq: number }>;
};
assert.equal(body.group, 2);
// Chapters 81..120, and the first item says so.
assert.equal(body.items[0]?.href, 'xhtml:OEBPS/c80.xhtml');
assert.equal(body.items[0]?.seq, 80);
await rm(join(booksDir, '窗读2.epub'));
await ctx.scanner.scan();
});
test('the table of contents is the book, not the window', async () => {
// On `main` the TOC was built from the manifest, so a long book's contents
// panel listed exactly one window. `/toc` is the endpoint that fixes it, and
// this asserts the property that matters: it is longer than a window.
const id = await makeChaptered('窗读3.epub', 120);
const manifest = await app.inject({
method: 'GET',
url: `/api/v1/books/${id}/manifest`,
headers: auth(),
});
const windowItems = (manifest.json() as { items: unknown[] }).items.length;
const toc = await app.inject({ method: 'GET', url: `/api/v1/books/${id}/toc`, headers: auth() });
assert.equal(toc.statusCode, 200, toc.body);
const entries = (toc.json() as { toc: Array<{ href: string; title: string; spine: number }> }).toc;
assert.equal(entries.length, 120);
assert.ok(entries.length > windowItems, 'a table of contents must exceed one window');
assert.equal(entries[119]?.spine, 119);
// Every entry is addressable and carries the same href shape as a manifest
// item, so a jump and a saved position are the same kind of reference.
for (const entry of entries) assert.match(entry.href, /^xhtml:/);
await rm(join(booksDir, '窗读3.epub'));
await ctx.scanner.scan();
});
test('a chapter is served with rewritten resource links and a real length', async () => {
// The chapter's `images/pic.png` has to arrive as an absolute asset URL, or
// every illustration in the book 404s in the client's frame.
const id = await makeChaptered('窗读4.epub', 3);
const items = await app.inject({ method: 'GET', url: `/api/v1/books/${id}/items`, headers: auth() });
const first = (items.json() as { items: Array<{ href: string }> }).items[0]!;
const res = await app.inject({
method: 'GET',
url: `/api/v1/books/${id}/assets?ref=${encodeURIComponent(first.href)}`,
headers: auth(),
});
assert.equal(res.statusCode, 200, res.body);
const html = res.rawPayload.toString('utf8');
assert.match(html, /\/api\/v1\/books\/[^"']+\/assets\?ref=/, 'relative resources must be absolute');
assert.equal(res.headers['content-length'], String(res.rawPayload.byteLength));
await rm(join(booksDir, '窗读4.epub'));
await ctx.scanner.scan();
});
test('a suffix range on a streamed body does not walk the whole file', async () => {
// `bytes=-3` is a legitimate resume request and also the one range a
// forward-only stream cannot produce by skipping. Serving it from a 400MB
// comic means reading all 400MB to hand back three bytes, so the honest
// answer is a full response the client can plan for.
const target = join(booksDir, '后缀.cbz');
// Large enough that the body is streamed rather than buffered, and stored so
// the payload advertises Range support in the first place.
const pages = Array.from({ length: 6 }, (_, i) => [`p${i}.jpg`, JPEG,] as [string, Buffer]);
await writeFile(target, makeZip([...pages, ['pad.bin', Buffer.alloc(2048, 7)]], { deflate: false }));
await ctx.scanner.scan();
const book = await findBook('后缀');
const res = await app.inject({
method: 'GET',
url: `/api/v1/books/${book.id}/content`,
headers: { ...auth(), range: 'bytes=-3' },
});
// Either answer is correct HTTP; what must not happen is a 206 whose body
// took the whole file to produce. A 200 with the full body and no Range
// advertisement is the one this server chose.
assert.ok(res.statusCode === 200 || res.statusCode === 206, `unexpected ${res.statusCode}`);
if (res.statusCode === 200) {
assert.equal(res.headers['accept-ranges'], 'none');
} else {
assert.equal(res.rawPayload.byteLength, 3);
}
await rm(target);
await ctx.scanner.scan();
});
});
+29 -11
View File
@@ -2,21 +2,39 @@
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<!--
`viewport-fit=cover` plus the safe-area variables in the stylesheet are what
keep a page from running under a notch or a home indicator. Without them the
last line of every page is partially hidden on a modern phone, which looks
like a rendering bug rather than a layout one.
-->
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1"
/>
<!--
maximum-scale=1 disables pinch-zoom. That is deliberate: text size belongs
to the reader's own control, and a visual zoom fights CSS pagination, which
is measured in viewport units. Android WebView honours this; iOS Safari
ignores it, which is acceptable since iOS is out of scope for v1.
No external font: a self-hosted reader must work with no outbound network
at all, and a webfont would also fail on the LAN with no internet uplink.
The book's own embedded fonts are unaffected — they load from the archive.
-->
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#ffffff" />
<title>书库</title>
<script type="module" crossorigin src="./assets/index-4T8xXbct.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CLgVXYFC.css">
<meta name="theme-color" content="#fbfaf7" />
<title>reader</title>
<style>
/* First paint only: prevents a white flash before the bundle applies the
stored theme, and gives the reader something to look at on a slow LAN. */
html,
body {
margin: 0;
height: 100%;
background: #fbfaf7;
}
html[data-theme='dark'],
html[data-theme='dark'] body {
background: #16171a;
}
</style>
<script type="module" crossorigin src="./assets/client.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index.css">
</head>
<body>
<div id="app"></div>
+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": "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:watch": "vitest"
},
"dependencies": {
+24
View File
@@ -30,6 +30,30 @@ export interface AndroidBridge {
* realistic way a self-hosting user can report a client bug.
*/
diagnostics(): string;
/**
* Draws one fixed-layout page natively and returns whether it was drawn.
*
* The client asks for this only for pages whose content is a picture (comic
* page, scanned PDF page). A `false` return is the host declining — an
* unsupported mode, an undecodable image — and the caller draws the page in
* the WebView as it would anywhere else. There is no third outcome: a failure
* must not leave a blank screen where a page should be.
*
* The request is JSON, because a JavaScript interface method can only take
* primitives, and a page is not one. Bytes travel base64-encoded inside it,
* which is the cost of the shortcut and still cheaper than a WebView layout
* pass for a full-page image.
*/
renderPage(request: string): boolean;
/** Removes the native page view, restoring the WebView underneath it. */
hidePage(): void;
/**
* Reports whether a document of this media type can be handed to the platform
* viewer. Used for PDF, where the shell must decline: its viewer is a separate
* screen, not a view that can sit inside the reader, and opening one would
* take the reader out of the app.
*/
canOpenDocument(mediaType: string): boolean;
}
declare global {
+60
View File
@@ -1,6 +1,7 @@
import { ApiError, errorForStatus, parseErrorBody } from './errors.ts';
import type {
Book,
BookContent,
BookListPage,
ContinueReadingItem,
Facets,
@@ -12,6 +13,7 @@ import type {
Session,
SyncPull,
SyncPushResult,
TocEntry,
User,
} from './types.ts';
import type { Platform } from '../core/platform.ts';
@@ -180,6 +182,64 @@ export class ReaderApi {
return this.get<Manifest>(`/api/v1/books/${encodeURIComponent(id)}/manifest`, options);
}
/**
* One addressable resource, as bytes.
*
* `ref` is opaque and must be passed through unchanged: it is whatever the
* manifest handed out (`xhtml:OEBPS/ch1.xhtml`, `page:17`), and a chapter is
* never addressed by index because an index means a different chapter in a
* different window.
*/
async asset(id: string, ref: string, options: RequestOptions = {}): Promise<Blob> {
const response = await this.platform.transport.send({
url: `/api/v1/books/${encodeURIComponent(id)}/assets?ref=${encodeURIComponent(ref)}`,
method: 'GET',
headers: {
accept: '*/*',
...(this.session ? { authorization: `Bearer ${this.session.accessToken}` } : {}),
},
binary: true,
...(options.signal ? { signal: options.signal } : {}),
});
if (response.status >= 400) {
throw new ApiError(errorForStatus(response.status), 'resource request failed', 'ASSET_FAILED', response.status);
}
const bytes = response.bytes ?? new Uint8Array();
// A Blob rather than the raw view: callers hand chapters to a frame as text
// and pages to an `<img>` as bytes, and the copy is what makes that safe.
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return new Blob([copy.buffer]);
}
/**
* One window of a book's addressable structure.
*
* Used to jump to a chapter that is not in the loaded window: the response
* carries both the items and the group metadata, so the client can splice it
* into the whole-book ordering without re-reading every earlier window.
*/
async items(id: string, group?: number, options: RequestOptions = {}): Promise<BookContent> {
const query = group === undefined ? '' : `?group=${group}`;
return this.get<BookContent>(`/api/v1/books/${encodeURIComponent(id)}/items${query}`, options);
}
/**
* The book's complete table of contents.
*
* A separate call from `manifest` because the two want opposite things: a
* manifest is windowed so opening a 1200-chapter book is cheap, and a table of
* contents has to be whole to be worth showing. Reading the contents off a
* manifest is the mistake this endpoint exists to prevent.
*/
async toc(id: string, options: RequestOptions = {}): Promise<TocEntry[]> {
const result = await this.get<{ toc: TocEntry[] }>(
`/api/v1/books/${encodeURIComponent(id)}/toc`,
options,
);
return result.toc;
}
async facets(options: RequestOptions = {}): Promise<Facets> {
return this.get<Facets>('/api/v1/library/facets', options);
}
+68
View File
@@ -64,11 +64,79 @@ export interface BookFile {
missing: number;
}
/** One addressable unit of a book: a chapter, a page, a volume. */
export interface ContentItem {
id: string;
seq: number;
title: string;
kind: 'chapter' | 'page';
mediaType: string;
/** Opaque, format-specific reference. Pass it back unchanged. */
href: string;
/** Byte length when the format knows it cheaply. */
size?: number;
}
/** A group of items: a comic volume, or a window of chapters. */
export interface ContentGroup {
id: string;
seq: number;
title: string;
count: number;
/**
* Global `seq` of this group's first item.
*
* Present so a client holding one window can place it in the whole-book
* ordering without re-reading every previous group's count — which is exactly
* how a jump lands on the wrong chapter.
*/
offset: number;
}
/**
* The addressable structure of a book.
*
* `kind` tells the client how to render it; `group` is present only when the
* request narrowed the items to one window.
*/
export interface BookContent {
kind: 'reflowable' | 'paged' | 'text' | 'document' | 'single-image';
total: number;
groups: ContentGroup[];
items: ContentItem[];
group?: number;
}
/**
* One entry of a book's own navigation.
*
* `href` is the same opaque reference the manifest's items carry, so a jump from
* the contents panel is addressed the same way a saved position is.
*/
export interface TocEntry {
href: string;
title: string;
level: number;
spine?: number;
}
export interface Manifest {
book: Book;
contentUrl: string;
coverUrl: string | null;
files: BookFile[];
/**
* The addressable structure, already narrowed to the first window.
*
* Present since the manifest took over `/items`, which is what makes opening a
* book one round trip. Absent only for a format that exposes no structure at
* all, in which case the client downloads the file.
*/
content?: BookContent | null;
kind?: BookContent['kind'];
total?: number;
groups?: ContentGroup[];
items?: ContentItem[];
}
export interface Progress {
+14 -1
View File
@@ -2,7 +2,8 @@ import { ReaderApi, type SessionStore } from './api/client.ts';
import { ApiError } from './api/errors.ts';
import type { Book, Session } from './api/types.ts';
import { createWebPlatform } from './core/web-platform.ts';
import { createAndroidPlatform, detectAndroidBridge } from './core/android-platform.ts';
import { androidPageHost, createAndroidPlatform, detectAndroidBridge } from './core/android-platform.ts';
import type { NativePageHost } from './ui/native-page.ts';
import type { Platform } from './core/platform.ts';
import { SyncEngine, type SyncStatus } from './core/sync.ts';
import { OfflineStore } from './store/offline.ts';
@@ -42,6 +43,14 @@ export class App {
private shelf: ShelfScreen | null = null;
private reader: ReaderScreen | null = null;
/**
* Native fixed-layout renderer, present only in the Android shell.
*
* Held here rather than inside ReaderScreen so that the desktop of it — the
* bridge object — is touched in one place, and so that a reader screen can be
* constructed with or without one without either path special-casing.
*/
private pageHost: NativePageHost | null = null;
private screen: string | null = null;
private pendingBook: Book | null = null;
@@ -98,6 +107,9 @@ export class App {
private async createPlatform(): Promise<Platform> {
const bridge = detectAndroidBridge();
if (!bridge) return createWebPlatform(this.options.defaultServerUrl ?? '');
// The native page renderer is resolved here, once, alongside the bridge that
// provides it, so no screen has to ask whether it is running on Android.
this.pageHost = androidPageHost(bridge);
return createAndroidPlatform(this.options.defaultServerUrl ?? '', bridge);
}
@@ -151,6 +163,7 @@ export class App {
sync: this.sync,
platform: this.platform,
settings: this.settings,
...(this.pageHost ? { pageHost: this.pageHost } : {}),
onBack: () => this.showShelf(),
onSettingsChange: (patch) => {
void this.settingsStore.update(patch);
+95
View File
@@ -2,6 +2,7 @@ import type { AndroidBridge } from '../android-bridge.ts';
import { FetchTransport } from './fetch-transport.ts';
import type { Connectivity, Platform } from './platform.ts';
import { createStores } from '../store/idb.ts';
import type { NativePageHost, NativePageRequest } from '../ui/native-page.ts';
/**
* The Android platform.
@@ -20,6 +21,12 @@ import { createStores } from '../store/idb.ts';
*
* Everything else — storage, transport, the sync engine — is shared, which is
* the entire point of building the client this way.
*
* The one thing this file knows that the web platform does not is the native
* page renderer: on Android a comic page is drawn by the platform, not by the
* WebView. `createNativePageHost` below is the whole of that special case, and
* it is written so that a refusal from the shell is indistinguishable from a
* browser with no native path at all.
*/
export async function createAndroidPlatform(baseUrl: string, bridge: AndroidBridge): Promise<Platform> {
const stores = await createStores();
@@ -83,6 +90,16 @@ function safeDeviceLabel(bridge: AndroidBridge): string {
*/
export const MIN_SHELL_VERSION = 1;
/**
* First shell version with a native page renderer (`renderPage`/`hidePage`).
*
* Separate from `MIN_SHELL_VERSION` on purpose. The minimum is what the client
* cannot work without; this is an optimisation it can live without, so an older
* APK keeps working and simply draws every page in the WebView. Refusing to run
* on an old shell would be the wrong trade for a speed-up.
*/
export const NATIVE_PAGE_SHELL_VERSION = 2;
export function detectAndroidBridge(): AndroidBridge | null {
if (typeof window === 'undefined') return null;
const bridge = window.ReaderAndroid;
@@ -93,3 +110,81 @@ export function detectAndroidBridge(): AndroidBridge | null {
return null;
}
}
/**
* The Android native renderer, exposed as a page host.
*
* Returns `null` when the shell is too old to have one, which is not an error:
* the view then draws every page itself, exactly as the browser does. Shell
* version 2 is the first one with `renderPage`.
*/
export function androidPageHost(bridge: AndroidBridge): NativePageHost | null {
if (!shellAtLeast(bridge, NATIVE_PAGE_SHELL_VERSION)) return null;
if (typeof bridge.renderPage !== 'function') return null;
return {
async show(request: NativePageRequest): Promise<boolean> {
if (request.mode === 'reflowable') return false;
// A PDF is declined for a reason worth stating: the shell's viewer is a
// separate screen, so drawing one would take the reader out of the app and
// lose the reading session. The WebView path stays.
if (request.mode === 'document') return false;
try {
return bridge.renderPage(serialisePage(request)) === true;
} catch {
// A bridge that throws must degrade to the WebView path, not blank the
// reading view. This is the only place that decides that, on purpose.
return false;
}
},
hide(): void {
try {
bridge.hidePage();
} catch {
// Nothing to undo.
}
},
};
}
/**
* JSON with bytes carried as base64, which is all a JS interface can accept.
*
* `mode` is passed through rather than inferred from the media type: the client
* decided what this section *is*, and re-deriving it on the other side of the
* bridge is how two implementations of the same rule start to disagree.
*/
export function serialisePage(request: NativePageRequest): string {
const payload: Record<string, unknown> = {
sectionId: request.sectionId,
mode: request.mode,
// Carried across so the native view honours the reader's preference rather
// than guessing: a page drawn "contain" after the reader chose "fit width"
// looks like the setting is broken.
fit: request.fit,
};
if (request.path) payload['path'] = request.path;
if (request.mediaType) payload['mediaType'] = request.mediaType;
if (request.bytes && request.bytes.byteLength > 0) payload['bytes'] = toBase64(request.bytes);
return JSON.stringify(payload);
}
function toBase64(bytes: Uint8Array): string {
let binary = '';
const chunk = 0x8000;
// Chunked: `String.fromCharCode(...bytes)` on a multi-megabyte page image
// overflows the argument list and throws a RangeError, which would look like
// "the native renderer does not work on big pages".
for (let offset = 0; offset < bytes.length; offset += chunk) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk));
}
return btoa(binary);
}
function shellAtLeast(bridge: AndroidBridge, version: number): boolean {
try {
return bridge.shellVersion() >= version;
} catch {
return false;
}
}
+18 -1
View File
@@ -1,6 +1,6 @@
import { BookArchive, extensionOf } from './zip.ts';
import { sortByName } from './natural-sort.ts';
import type { BookDoc, LoadContext, Section } from './types.ts';
import type { BookDoc, LoadContext, RenderMode, Section } from './types.ts';
/**
* Comic loader: CBZ archives and loose image directories.
@@ -64,12 +64,17 @@ export async function buildComicDoc(
label: pageLabel(sections.length + 1, path),
image: { mediaType, bytes },
depth: 0,
render: 'image',
// The page's own address, so a native host can fetch and decode it
// directly instead of receiving a buffer it has to be handed.
path,
});
}
return {
format,
layout: 'fixed',
render: comicRenderMode(),
// Manga is right-to-left, but detecting it from the archive is unreliable
// and guessing wrong reverses the book. Default left-to-right and expose a
// toggle; the reader can fix it in one tap, and the setting persists.
@@ -115,3 +120,15 @@ export function mediaTypeForImage(path: string): string {
export function isImagePath(path: string): boolean {
return IMAGE_EXTENSIONS.has(extensionOf(path));
}
/**
* Render mode for a comic document. Always `image`.
*
* A comic page is a picture, so a host with a native image pipeline should use
* it: a WebView would decode the same bytes into a renderer-process bitmap and
* run a layout pass to place it, for a result that is identical on screen and
* worse in memory.
*/
export function comicRenderMode(): RenderMode {
return 'image';
}
+31 -2
View File
@@ -14,15 +14,44 @@ import type { BookFormat } from './types.ts';
const ZIP_MAGIC = [0x50, 0x4b]; // "PK"
const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46]; // "%PDF"
/** "GIF8", and the two variants of the PNG signature's first half. */
const IMAGE_MAGIC: Array<{ magic: number[]; label: BookFormat }> = [
{ magic: [0x89, 0x50, 0x4e, 0x47], label: 'image' }, // PNG
{ magic: [0xff, 0xd8, 0xff], label: 'image' }, // JPEG
{ magic: [0x47, 0x49, 0x46, 0x38], label: 'image' }, // GIF
{ magic: [0x42, 0x4d], label: 'image' }, // BMP
];
/** MP3/MP4-family containers are audio/video, never a book. Detected to refuse. */
const MEDIA_MAGIC: number[][] = [
[0x49, 0x44, 0x33], // ID3
[0x1a, 0x45, 0xdf, 0xa3], // Matroska / WebM
];
export function detectFormat(fileName: string, bytes?: Uint8Array): BookFormat {
const extension = extensionOf(fileName);
if (bytes && bytes.length >= 4) {
// Magic bytes first, and *only* magic bytes for the formats that are
// unambiguous. Self-hosted libraries are full of misnamed files — a `.txt`
// that is really an EPUB from a batch conversion tool, a `.cbz` that is
// actually a JPEG somebody renamed, an `.epub` that is a PDF. Being
// confident about a format whose signature admits no alternative is how
// those files become readable instead of producing a confusing parse error.
if (matches(bytes, PDF_MAGIC)) return 'pdf';
for (const entry of IMAGE_MAGIC) {
if (matches(bytes, entry.magic)) return entry.label;
}
if (MEDIA_MAGIC.some((magic) => matches(bytes, magic))) {
// Not a book at all. Naming it `unknown` is the honest answer: the reader
// gets "this file is not a book" rather than a text decoder chewing on an
// MP3 and producing fifty thousand replacement characters.
return 'unknown';
}
if (matches(bytes, ZIP_MAGIC)) {
// A ZIP can be an EPUB or a CBZ. The extension decides, and when it does
// not, the presence of the EPUB container document does.
// A ZIP admits two answers, so here the extension does get a vote — but
// only as the tie-breaker between two formats that are both plausible for
// the same bytes. When the extension is neither, the container document
// inside decides.
if (extension === 'cbz' || extension === 'cbr') return 'cbz';
if (extension === 'epub') return 'epub';
return looksLikeEpub(bytes) ? 'epub' : 'cbz';
+3
View File
@@ -103,6 +103,9 @@ export async function loadEpub(ctx: LoadContext): Promise<BookDoc> {
return {
format: 'epub',
layout: 'reflowable',
// Reflow cannot be delegated: the WebView is the only engine here that can
// lay out the book's own CSS, and that is the product's core claim.
render: 'reflowable',
direction: pkg.direction,
sections,
toc,
+13 -1
View File
@@ -1,4 +1,4 @@
import type { BookDoc } from './types.ts';
import type { BookDoc, RenderMode } from './types.ts';
/**
* PDF loader.
@@ -22,6 +22,7 @@ export async function loadPdf(): Promise<BookDoc> {
return {
format: 'pdf',
layout: 'fixed',
render: pdfRenderMode(),
direction: 'ltr',
// A PDF is one document, not a list of sections: the browser viewer owns
// navigation inside it. One section keeps the progress model uniform.
@@ -39,3 +40,14 @@ export async function loadPdf(): Promise<BookDoc> {
orderedByBook: true,
};
}
/**
* Render mode for a PDF. Always `document`.
*
* A PDF is one document, and both the browser and Android already ship a viewer
* for it that is better than anything this project would write. The host is told
* so rather than being handed bytes to guess from.
*/
export function pdfRenderMode(): RenderMode {
return 'document';
}
+356
View File
@@ -0,0 +1,356 @@
/**
* 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 };
+113 -21
View File
@@ -6,9 +6,22 @@
* declaration. Decoding those as UTF-8 produces replacement characters on
* nearly every line — the file becomes unreadable rather than slightly wrong.
*
* The strategy is validate-then-fallback, not guessing: a byte sequence that
* decodes as strict UTF-8 is by construction almost never valid GB18030 text of
* this kind, so the check is reliable in the direction that matters.
* Two decisions run through this file:
*
* 1. **Strict UTF-8 first, always.** A byte sequence that decodes as strict
* UTF-8 is by construction almost never valid GB18030 text of this kind, so
* the check is reliable in the direction that matters. Every other encoding
* is only considered after it fails.
*
* 2. **Between GB18030 and Big5, pick the better decode rather than a fixed
* order.** Most legacy Chinese TXT is Simplified (GB18030), but not all of
* it, and the two codecs overlap heavily: the same bytes usually decode
* successfully as both, so "try GB18030, then Big5" never reaches the second
* candidate. The bytes that expose the difference are the ones that land in
* each decoder's gap — they come out as private-use or replacement
* characters, or as kana out of a page of Han text. Scoring both decodes on
* that evidence and taking the cleaner one is what makes Traditional Big5
* readable without giving up the Simplified majority.
*/
const GB18030 = 'gb18030';
@@ -99,18 +112,106 @@ export function decodeText(bytes: Uint8Array): DecodedText {
return { text: decodeStrict(typed, 'utf-8'), encoding: 'utf-8', guessed: false };
}
// No BOM and not valid UTF-8: it is one of the CJK legacy encodings. Try
// Simplified Chinese first (by far the most common for these files), then
// Traditional, then give up on a lenient decode.
for (const encoding of [GB18030, BIG5]) {
const text = decodeStrict(typed, encoding);
if (text && countReplacementChars(text) === 0) {
return { text, encoding, guessed: true };
}
}
// No BOM and not valid UTF-8: it is one of the CJK legacy encodings.
const best = chooseLegacyEncoding(typed);
if (best) return { text: best.text, encoding: best.encoding, guessed: true };
// Nothing decodes cleanly. Return *something* — a book with a few mangled
// characters beats an error screen — and mark it guessed so the UI offers the
// manual override, which is the only thing that can rescue a genuinely odd
// file.
return { text: decodeLenient(typed, 'utf-8'), encoding: 'utf-8', guessed: true };
}
/**
* Picks between GB18030 and Big5 by decoding with both and scoring the result.
*
* Returns `null` when neither survives, leaving the caller to fall back rather
* than committing to a decode that is visibly broken.
*/
function chooseLegacyEncoding(bytes: Uint8Array): { text: string; encoding: string } | null {
const candidates: Array<{ text: string; encoding: string; score: number }> = [];
for (const encoding of [GB18030, BIG5]) {
const text = decodeStrict(bytes, encoding);
if (!text) continue;
const score = scoreDecode(text);
if (score === null) continue;
// GB18030 wins ties. It is both the far more common source for these files
// and the superset of the two, so an ambiguous byte sequence is more likely
// to be Simplified than Traditional.
candidates.push({ text, encoding, score: score + (encoding === GB18030 ? 1 : 0) });
}
if (candidates.length === 0) return null;
candidates.sort((left, right) => right.score - left.score);
const best = candidates[0]!;
// A clean decode scores exactly zero. Requiring a positive score would reject
// the best possible answer, so the bar is "at least as clean as no damage at
// all" — anything below that shows damage the manual override can do better.
return best.score >= 0 ? { text: best.text, encoding: best.encoding } : null;
}
/**
* Scores a decode by how much damage it shows. Higher is better; `null` means
* "give up on this candidate".
*
* The penalties are ordered by how strong the evidence is:
*
* - Replacement characters mean the decoder hit an invalid sequence. One is
* fatal evidence that this is the wrong codec.
* - Private-use characters are GB18030's escape hatch for bytes it defines but
* does not map to a real character. A page of Chinese text containing them
* is a mis-decode.
* - Fullwidth/halfwidth compatibility forms (`﹍`, `A`) come from the same
* corner of the CJK blocks and almost never appear in book prose.
* - Kana in a wall of Han text is the tell that Big5 bytes are being read as
* GB18030: several Big5 sequences land in the hiragana block. A Japanese
* book is legitimate, but it would also be valid UTF-8 in practice, so it
* never reaches this function.
*
* A candidate is dropped outright when its damage exceeds a proportion of its
* length, rather than an absolute count: a 4 MB novel with a handful of
* genuinely malformed bytes is still the right decode, while a 200-character
* chapter with one is a wrong one.
*/
function scoreDecode(text: string): number | null {
if (text.length === 0) return null;
let score = 0;
let damage = 0;
for (const char of text) {
const code = char.codePointAt(0)!;
if (code === 0xfffd) {
damage += 5;
} else if (inPrivateUse(code)) {
damage += 3;
} else if (isCompatibilityForm(code)) {
damage += 2;
} else if (isKana(code)) {
damage += 1;
}
}
if (damage > CONCERNING_DAMAGE_RATIO * text.length) return null;
score -= damage;
return score;
}
function inPrivateUse(code: number): boolean {
return (code >= 0xe000 && code <= 0xf8ff) || (code >= 0xf0000 && code <= 0x10fffd);
}
function isCompatibilityForm(code: number): boolean {
return code >= 0xfe30 && code <= 0xfe4f;
}
function isKana(code: number): boolean {
return (code >= 0x3040 && code <= 0x309f) || (code >= 0x30a0 && code <= 0x30ff);
}
/**
* Share of a text that may look like damage before the decode is rejected
* outright, so a wrong codec is never preferred merely because it is long.
*/
const CONCERNING_DAMAGE_RATIO = 0.01;
function decodeStrict(bytes: Uint8Array, encoding: string): string {
try {
return new TextDecoder(encoding, { fatal: true }).decode(bytes);
@@ -133,13 +234,4 @@ function decodeLenient(bytes: Uint8Array, encoding: string): string {
}
}
function countReplacementChars(text: string): number {
let count = 0;
for (const char of text) {
if (char === '\uFFFD') count += 1;
if (count > 8) return count;
}
return count;
}
export { hasUtf8Bom };
+1
View File
@@ -127,6 +127,7 @@ export function loadTxt(ctx: LoadContext, options: TxtOptions = {}): TxtSplitRes
doc: {
format: 'txt',
layout: 'reflowable',
render: 'reflowable',
direction: 'ltr',
sections,
toc,
+64
View File
@@ -34,6 +34,23 @@ export interface Section {
imageUrl?: string;
/** Depth in the navigation tree, 0 for top level. */
depth: number;
/**
* How this section should be drawn, when the host can choose.
*
* Omitted means "let the host decide from the document as a whole": the value
* is set per section only where sections within one book can differ, which
* today means nowhere — the book-level default below covers it. It is per
* section rather than per book because mixed-layout EPUBs (a fixed page of
* plates inside a reflowable novel) are a real thing this model has to be able
* to express later without changing the contract.
*/
render?: RenderMode;
/**
* Path this section's bytes can be fetched from, when the section is one
* addressable file. This is what lets a native host fetch a page itself
* instead of being handed a buffer by the web layer.
*/
path?: string;
}
export interface TocEntry {
@@ -44,10 +61,37 @@ export interface TocEntry {
export type Layout = 'reflowable' | 'fixed';
/**
* What a host is being asked to render for one section.
*
* This exists for exactly one host: the Android shell. The product decision is
* that a WebView renders EPUB (faithful reflow is the differentiator, and it has
* to be one implementation), while fixed-layout pages — comic images, PDF — are
* better served by the platform's own decoders. Handing a 4K scan to a WebView
* means a full browser layout pass and a bitmap in the renderer process for what
* is, in the end, "show this image"; the native path skips both.
*
* Declared here rather than in the bridge so that both hosts agree on the shape,
* and so a browser host can ignore it entirely.
*
* - `reflowable` — a chapter of markup; only a WebView can lay it out.
* - `image` — a single page image. Native where available.
* - `document` — a whole document the platform can open itself (PDF).
*/
export type RenderMode = 'reflowable' | 'image' | 'document';
export interface BookDoc {
format: BookFormat;
/** Reflowable pages can be re-paginated; fixed ones are one screen per section. */
layout: Layout;
/**
* Default render mode for this book's sections.
*
* `reflowable` for anything whose layout only a browser engine can produce —
* EPUB, TXT. `image`/`document` for fixed-layout books, which is how the
* Android shell knows it may render natively instead of paying for a WebView.
*/
render: RenderMode;
/** Reading direction, from the EPUB spine / comic metadata. */
direction: 'ltr' | 'rtl';
sections: Section[];
@@ -59,6 +103,26 @@ export interface BookDoc {
orderedByBook: boolean;
}
/**
* A book whose section bodies are fetched on demand.
*
* The reader drives this rather than fetching the whole file, which is what keeps
* opening a 1200-chapter book at one chapter of bytes. It lives in the format
* layer because "which bytes are this chapter" is a format question — an EPUB
* answers it with an archive path, a comic with a page index.
*/
export interface StagedBook {
/** Load one section's body, filling in `html` or `image` in place. */
loadSection(index: number): Promise<Section | null>;
/**
* Swap in a newly fetched window.
*
* Returns the local index of `spine` within the new window, or -1 when that
* window does not contain it.
*/
loadWindow(content: unknown, spine: number): number;
}
export interface LoadContext {
/** Raw book bytes. */
bytes: Uint8Array;
+184
View File
@@ -0,0 +1,184 @@
/**
* Staged documents: a book that is read one window at a time.
*
* This is the client half of "服务端分章节" — the reader is handed the addressable
* structure (chapters, pages, volumes) and fetches one unit at a time, so opening
* a 1200-chapter omnibus costs one chapter of bytes and never the file.
*
* The design has three load-bearing parts.
*
* ### 1. A staged document is a `BookDoc`, not a second interface
*
* Everything downstream — pagination, gestures, progress, the table of contents —
* is written against `BookDoc`. A parallel type would fork all of it. So a
* `StagedDoc` *is* a `BookDoc` whose sections are placeholders, plus the two
* things the reader needs to drive it: `loadSection` and a complete `toc`.
*
* ### 2. Locators are section ids, and section ids are the server's refs
*
* A window is a transport boundary; a locator must survive crossing one. The
* server's `href` is stable across windows (`xhtml:OEBPS/ch1.xhtml`), so it is
* what a saved position is matched on — never a spine index, which means a
* different chapter in a different window.
*
* ### 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.
*/
import type { BookContent, ContentItem } from '../net/api.ts';
import { RemoteZip, type RangeSource } from './remote-zip.ts';
import type { BookDoc, Section, TocEntry } from './types.ts';
/**
* How a staged section's bytes are obtained.
*
* `read` returns the chapter's markup already rewritten so its own relative
* resource references resolve — the server does that rewriting for EPUB, and a
* client-side staged EPUB has to do the same thing for itself.
*/
export interface SectionLoader {
/** Section markup for a reflowable section, or a page's bytes. */
read(item: ContentItem): Promise<{ html?: string; image?: { mediaType: string; bytes: Uint8Array } }>;
}
export interface StagedDocOptions {
kind: BookContent['kind'];
/** Every chapter of the book, so the table of contents is complete. */
toc: TocEntry[];
/** The window that is loaded right now. */
content: BookContent;
/** Fetches a window by group index, for a jump outside the loaded one. */
windowFor?(group: number): Promise<BookContent>;
loader: SectionLoader;
/** True when section order comes from the book rather than the file name. */
orderedByBook?: boolean;
}
/** A `BookDoc` whose section bodies are fetched on demand. */
export interface StagedDoc extends BookDoc {
/** Load one section's body, filling in its `html`/`image` in place. */
loadSection(index: number): Promise<Section | null>;
/**
* Replace the loaded window.
*
* Returns the local index of `spine` within the new window, or -1 when the new
* window does not contain it.
*/
setWindow(content: BookContent, spine: number): number;
/** Whole-book index of the loaded window's first section. */
windowOffset(): number;
}
/**
* Build a staged document from a windowed manifest.
*
* `sections` is exactly the window's items; `toc` is the whole book. Keeping
* those two separate is what stops the transport boundary leaking into the
* navigation UI — a table of contents that read "第 1 章 – 第 40 章" because that
* was the size of a window is the bug this split exists to prevent.
*/
export function createStagedDoc(options: StagedDocOptions): StagedDoc {
const sections: Section[] = options.content.items.map((item) => ({
id: item.href,
label: item.title,
depth: 0,
render: item.kind === 'page' ? 'image' : 'reflowable',
path: item.href,
}));
const base: BookDoc = {
format: formatFor(options.kind),
layout: options.kind === 'paged' || options.kind === 'single-image' ? 'fixed' : 'reflowable',
render:
options.kind === 'paged' || options.kind === 'single-image'
? 'image'
: options.kind === 'document'
? 'document'
: 'reflowable',
direction: 'ltr',
sections,
toc: options.toc,
styles: [],
resources: new Map(),
orderedByBook: options.orderedByBook ?? true,
};
let offset =
options.content.groups[options.content.group ?? 0]?.offset ?? options.content.items[0]?.seq ?? 0;
const doc: StagedDoc = {
...base,
async loadSection(index: number): Promise<Section | null> {
const section = doc.sections[index];
const item = currentItems()[index];
if (!section || !item) return null;
// Already loaded: a re-style or a resize asks for the same section twice
// and must not re-fetch it.
if (section.html !== undefined || section.image !== undefined) return section;
const loaded = await options.loader.read(item);
if (loaded.html !== undefined) section.html = loaded.html;
if (loaded.image !== undefined) section.image = loaded.image;
return section;
},
setWindow(content: BookContent, spine: number): number {
// The offset comes from the group the response actually carries. A
// response that omits `group` (a server that chose not to window, or a
// hand-built one in a test) still has to answer correctly, so the offset
// is derived from the items' own `seq` rather than assumed to be zero.
const index = content.group ?? 0;
const group = content.groups[index];
offset = group?.offset ?? content.items[0]?.seq ?? 0;
current = content;
const items = currentItems();
doc.sections.length = 0;
for (const item of items) {
doc.sections.push({
id: item.href,
label: item.title,
depth: 0,
render: item.kind === 'page' ? 'image' : 'reflowable',
path: item.href,
});
}
const local = items.findIndex((item) => item.seq === spine);
return local;
},
windowOffset(): number {
return offset;
},
};
let current = options.content;
function currentItems(): ContentItem[] {
return current.items;
}
return doc;
}
function formatFor(kind: BookContent['kind']): BookDoc['format'] {
switch (kind) {
case 'paged':
return 'cbz';
case 'text':
return 'txt';
case 'document':
return 'pdf';
case 'single-image':
return 'image';
default:
return 'epub';
}
}
/** Whether a manifest's kind can be read a window at a time by a client. */
export function isStagedKind(kind: BookContent['kind'] | undefined): boolean {
return kind === 'reflowable' || kind === 'paged' || kind === 'text';
}
export { RemoteZip };
export type { RangeSource };
+70
View File
@@ -0,0 +1,70 @@
import type { RenderMode } from '../formats/types.ts';
/**
* The seam a native host uses to draw a fixed-layout page itself.
*
* Present because of a product decision with a measurable cost: in a WebView, a
* comic page or a scanned PDF page costs a decode into the renderer process, a
* full style/layout pass, and a bitmap that lives as long as the page does. On
* Android the platform already has a decoded-image pipeline and a PDF renderer,
* so the same page can be drawn without either. EPUB stays in the WebView — its
* layout is the differentiator and cannot be reproduced natively.
*
* The interface is deliberately narrow and the failure mode is explicit: a host
* either draws the page and says so, or declines and the DOM path runs. That is
* what keeps this an optimisation rather than a second renderer with its own
* set of bugs — there is exactly one implementation of every layout, and the
* native path only ever replaces "show these bytes as a picture".
*/
export interface NativePageRequest {
sectionId: string;
/** What the section is, so the host can refuse the modes it cannot draw. */
mode: RenderMode;
/** Address of the section's bytes, when it has one. Preferred over `bytes`. */
path?: string;
/** Media type, when known. */
mediaType?: string;
/**
* Bytes already resolved by the web layer.
*
* For a comic inside a CBZ there is no URL the native side could fetch — the
* page lives inside an archive — so the resolved buffer is passed instead of a
* path. It is a copy across the bridge, which still costs less than a WebView
* layout pass, and it is the only option that works for archives.
*/
bytes?: Uint8Array;
/**
* How the page should sit in the viewport: the whole page, or fitted to width.
*
* Passed rather than re-derived natively because it is a *reader preference*
* (`SettingsStore.fit`), not a property of the page. A native renderer that
* guessed would ignore the setting the reader just changed.
*
* Only `contain` is drawn natively. `fit width` is handled by the caller
* declining before it asks — see `ReaderView` — because cropping a page
* silently loses panels and a native scrolling surface would be a second
* renderer.
*/
fit: 'contain' | 'width';
}
export interface NativePageHost {
/** Draws the page. Resolves false when the host declines and the caller must. */
show(request: NativePageRequest): Promise<boolean>;
/** Removes a previously drawn page. Safe to call when none is shown. */
hide(): void;
}
/**
* Wraps the Android bridge as a page host.
*
* Kept here rather than in `android-platform.ts` because the view is the only
* consumer, and the translation between "section" and "native view" belongs next
* to the code that knows what a section is.
*/
export function createNativePageHost(invoke: (request: NativePageRequest) => Promise<boolean>, onHide: () => void): NativePageHost {
return {
show: (request) => invoke(request),
hide: onHide,
};
}
+189 -3
View File
@@ -1,7 +1,7 @@
import { ApiError } from '../api/errors.ts';
import type { ReaderApi } from '../api/client.ts';
import type { Book, Manifest, Note } from '../api/types.ts';
import { loadBook, isImagePath, extensionOf, type BookDoc } from '../formats/index.ts';
import { loadBook, isImagePath, extensionOf } from '../formats/index.ts';
import type { OfflineStore } from '../store/offline.ts';
import type { SyncEngine } from '../core/sync.ts';
import type { Platform } from '../core/platform.ts';
@@ -9,6 +9,21 @@ import { ReaderView, type Position, type ViewSettings } from './reader-view.ts';
import { attachGestures } from './gestures.ts';
import { clear, el, percent } from './dom.ts';
import type { AppSettings } from '../store/settings.ts';
import { createStagedDoc, isStagedKind } from '../formats/windowed.ts';
import type { BookDoc } from '../formats/types.ts';
import type { TocEntry } from '../api/types.ts';
/**
* Chapters per window, matching the server's own constant.
*
* Duplicated rather than fetched because the client needs it to *compute* which
* window holds a chapter, and a round trip to learn a constant would defeat the
* purpose. It matches `CHAPTER_WINDOW` in the server's epub handler; a mismatch
* costs one extra request, never a wrong chapter, because the response is always
* the authority on what it contains.
*/
const CHAPTER_WINDOW = 40;
import type { NativePageHost } from './native-page.ts';
export interface ReaderScreenOptions {
api: ReaderApi;
@@ -16,6 +31,14 @@ export interface ReaderScreenOptions {
sync: SyncEngine;
platform: Platform;
settings: AppSettings;
/**
* Native renderer for fixed-layout pages, when the host provides one.
*
* Optional because the browser has none and must not be given a stub that
* pretends otherwise: `undefined` means "draw everything here", which is the
* behaviour the H5 build has always had.
*/
pageHost?: NativePageHost;
onBack(): void;
onSettingsChange(patch: Partial<AppSettings>): void;
onSignedOut(): void;
@@ -132,6 +155,21 @@ export class ReaderScreen {
try {
this.manifest = await this.options.api.manifest(book.id);
if (token !== this.loadingToken) return;
// A staged book is read through the windowed contract and never downloads
// the file. That is the whole answer to "客户端需要下载完整的书籍,那消耗太大":
// this manifest already carries the first window, so opening the book has
// cost exactly one window of metadata.
const staged = await this.openStaged(book, token);
if (staged) {
this.doc = staged.doc;
this.buildView();
this.hideStatus();
await this.restorePosition(book, token);
return;
}
const bytes = await this.fetchBookBytes(book, this.manifest);
if (token !== this.loadingToken) return;
@@ -150,6 +188,80 @@ export class ReaderScreen {
}
}
/**
* Open the book through the windowed contract, when the server offers one.
*
* Returns null when the book has no addressable structure (an old server, a
* format that exposes none), and the caller falls back to downloading the
* file. That fallback is not a courtesy: it is what keeps this client working
* against a server that predates the endpoint, which self-hosted users do not
* upgrade on time.
*
* An EPUB is the case where the window is worth the most — a 1200-chapter
* omnibus would otherwise be a 50MB download to show one page of chapter one.
*/
private async openStaged(book: Book, token: number): Promise<{ doc: BookDoc } | null> {
const manifest = this.manifest;
const content = manifest?.content;
if (!content || !isStagedKind(content.kind)) return null;
let toc: TocEntry[];
try {
toc = await this.options.api.toc(book.id);
} catch {
// The manifest's own items are a worse table of contents but a usable one,
// and refusing to open the book because a contents list failed would be
// the wrong trade.
toc = (content.items ?? []).map((item) => ({
href: item.href,
title: item.title,
level: 0,
spine: item.seq,
}));
}
if (token !== this.loadingToken) return null;
const doc = createStagedDoc({
kind: content.kind,
toc: toc.map((entry) => ({ id: entry.href, label: entry.title, depth: entry.level })),
content,
orderedByBook: true,
loader: {
read: (item) => this.readStagedSection(book, item),
},
});
// The view drives window changes through this hook, so a jump to chapter 900
// of a 1200-chapter book loads window 22 rather than doing nothing.
(doc as BookDoc & { staged?: unknown }).staged = doc;
return { doc };
}
/**
* One section's bytes, by the server's own reference.
*
* `ref` is opaque and passed through unchanged: the client must not build it
* from an index, because the whole reason it is a reference is that indices
* mean different chapters in different windows.
*/
private async readStagedSection(
book: Book,
item: { href: string; kind: string; mediaType: string },
): Promise<{ html?: string; image?: { mediaType: string; bytes: Uint8Array } }> {
const cacheKey = `section:${book.id}:${item.href}`;
const cached = await this.options.platform.blobs.get(cacheKey);
const blob = cached ? new Blob([toArrayBuffer(cached)]) : await this.options.api.asset(book.id, item.href);
if (!cached) {
// Cached per section, not per book, so a partially read book is partially
// readable offline and re-opening one does not re-fetch what was read.
await this.options.platform.blobs.put(cacheKey, new Uint8Array(await blob.arrayBuffer()));
}
if (item.kind === 'page') {
return { image: { mediaType: item.mediaType, bytes: new Uint8Array(await blob.arrayBuffer()) } };
}
return { html: await blob.text() };
}
dispose(): void {
this.loadingToken += 1;
this.flushProgress();
@@ -162,6 +274,10 @@ export class ReaderScreen {
this.view = null;
this.doc = null;
this.manifest = null;
// A native page view is a sibling of the WebView's content, so it does not
// go away with the DOM this screen owns. Leaving it would put a comic page
// on top of the shelf.
this.options.pageHost?.hide();
this.element.remove();
}
@@ -246,6 +362,7 @@ export class ReaderScreen {
this.view = new ReaderView({
container: this.stage,
doc,
...(this.options.pageHost ? { pageHost: this.options.pageHost } : {}),
onPositionChange: (position) => this.onPosition(position),
onChapterChange: (_index, section) => {
this.chapterLabel.textContent = section.label;
@@ -429,16 +546,42 @@ export class ReaderScreen {
if (!this.settingsPanel.hidden) this.tocPanel.hidden = true;
}
/**
* The contents panel.
*
* Fetched from `/toc` rather than read off the manifest, because the manifest
* is windowed and a table of contents assembled from one window lists exactly
* that window — a 1200-chapter book showed "第 1 章 – 第 40 章". The server
* keeps the two apart on purpose (see docs/api.md); this is the client half of
* that arrangement.
*
* A book whose TOC cannot be fetched falls back to the window's own labels,
* which is worse but not useless, and never leaves the panel empty when the
* book does have chapters.
*/
private async renderToc(): Promise<void> {
clear(this.tocList);
const entries = this.view?.chapterLabels() ?? [];
const book = this.book;
if (!book) return;
let entries: Array<{ id: string; label: string; depth: number }> = [];
try {
const toc = await this.options.api.toc(book.id);
entries = toc.map((entry) => ({ id: entry.href, label: entry.title, depth: entry.level }));
} catch {
entries = this.view?.chapterLabels() ?? [];
}
if (entries.length === 0) entries = this.view?.chapterLabels() ?? [];
for (const entry of entries) {
const button = el('button', {
text: entry.label,
attrs: { type: 'button', 'data-section': entry.id },
on: {
click: () => {
void this.view?.openLocator(`${entry.id}:0`);
// A contents entry may point at a chapter outside the loaded
// window, so the jump has to go through the whole-book path rather
// than resolve a local index — that is what `goToChapterRef` adds.
void this.goToChapterRef(entry.id);
this.tocPanel.hidden = true;
this.setChromeVisible(false);
},
@@ -454,6 +597,36 @@ export class ReaderScreen {
}
}
/**
* Jump to a contents entry, loading the window that holds it when necessary.
*
* A position inside the loaded window is a local index change; one outside it
* means fetching that window first. Without this, tapping chapter 900 of a
* 1200-chapter book does nothing at all, which is how a windowed reader turns
* from fast into broken.
*/
private async goToChapterRef(ref: string): Promise<void> {
if (this.view?.openLocator(`${ref}:0`)) {
await this.view.openLocator(`${ref}:0`);
return;
}
const book = this.book;
if (!book) return;
try {
// The TOC's `spine` is the whole-book index the windowing is keyed on.
const toc = await this.options.api.toc(book.id);
const spine = toc.find((entry) => entry.href === ref)?.spine;
if (spine === undefined) return;
const group = Math.floor(spine / CHAPTER_WINDOW);
const content = await this.options.api.items(book.id, group);
if (this.view?.loadWindow(content, spine)) {
await this.view.openLocator(`${ref}:0`);
}
} catch {
this.setStatus('error', '无法跳到这一章');
}
}
private highlightToc(sectionId: string): void {
for (const item of this.tocList.querySelectorAll('li')) {
const matches = item.dataset['section'] === sectionId;
@@ -630,6 +803,19 @@ function addDebouncedListener(
};
}
/**
* Copies bytes into a fresh ArrayBuffer.
*
* `Blob` will not take a `Uint8Array` view over a larger buffer, and a fetch
* response's bytes are exactly that — passing the view directly would store the
* whole response behind a section-sized slice.
*/
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const copy = new ArrayBuffer(bytes.byteLength);
new Uint8Array(copy).set(bytes);
return copy;
}
function hasTextSelection(root: HTMLElement): boolean {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return false;
+74 -1
View File
@@ -1,4 +1,6 @@
import type { BookDoc, Section } from '../formats/types.ts';
import type { BookDoc, Section, StagedBook } from '../formats/types.ts';
import type { BookContent } from '../net/api.ts';
import type { NativePageHost } from './native-page.ts';
import { ResourceResolver, hydrateResources } from './resources.ts';
import { createBookHost, extractBody, extractInlineStyles, sanitiseInjectedContent, type BookShadowHost } from './shadow.ts';
import { bookPercentage, formatLocator, parseLocator } from './locator.ts';
@@ -9,6 +11,15 @@ export interface ReaderViewOptions {
/** Called on every position change, throttled by the caller via the view. */
onPositionChange?: (position: Position) => void;
onChapterChange?: (index: number, section: Section) => void;
/**
* Native renderer for fixed-layout pages, when the host has one.
*
* Absent in a browser, where the WebView *is* the only renderer. Present on
* Android, where a comic page or a PDF is better drawn by the platform. The
* view does not care which: it asks the host to draw a section and falls back
* to its own DOM path whenever the host declines.
*/
pageHost?: NativePageHost;
}
export interface Position {
@@ -54,6 +65,8 @@ export class ReaderView {
private readonly container: HTMLElement;
private readonly host: BookShadowHost;
private readonly resolver: ResourceResolver;
/** Set when the book is read a window at a time; see `loadWindow`. */
private readonly staged: StagedBook | null;
private settings: ViewSettings;
private sectionIndex = 0;
private sectionOffset = 0;
@@ -65,6 +78,9 @@ export class ReaderView {
constructor(options: ReaderViewOptions) {
this.options = options;
this.doc = options.doc;
// Set by the reader screen once it has built a staged document; absent for a
// book that is read whole.
this.staged = (options.doc as BookDoc & { staged?: StagedBook }).staged ?? null;
this.container = options.container;
this.settings = { ...DEFAULT_SETTINGS, direction: options.doc.direction };
this.host = createBookHost();
@@ -79,6 +95,17 @@ export class ReaderView {
return { ...this.settings };
}
/**
* The shadow host holding the book's content.
*
* Exposed for tests and for the host that wants to measure the reading
* surface; the content itself stays inside the shadow root, which is the
* point.
*/
get elementHost(): BookShadowHost {
return this.host;
}
get sectionCount(): number {
return this.doc.sections.length;
}
@@ -105,10 +132,28 @@ export class ReaderView {
requestAnimationFrame(() => this.restoreOffset());
}
/**
* Replace the loaded window of a staged book.
*
* Optional: a book read whole (the fallback when a server has no addressable
* structure) simply never calls it. Returns false when the book is not staged
* or the new window does not hold `spine`, so a caller can report honestly
* rather than silently landing on the wrong chapter.
*/
loadWindow(content: BookContent, spine: number): boolean {
if (!this.staged) return false;
return this.staged.loadWindow(content, spine) >= 0;
}
/** Opens a section by index, optionally at a fractional offset within it. */
async open(index: number, offset = 0): Promise<void> {
const section = this.doc.sections[index];
if (!section) return;
// A staged section has no body until it is asked for. Doing it here, rather
// than in the loader, is what keeps "opening a book" and "showing a chapter"
// from both needing to know about windows.
if (this.staged) await this.staged.loadSection(index).catch(() => null);
this.sectionIndex = index;
this.sectionOffset = Math.min(1, Math.max(0, offset));
this.releaseObjectUrl();
@@ -201,6 +246,7 @@ export class ReaderView {
// ---- rendering per layout ----
private async renderReflowable(section: Section): Promise<void> {
this.options.pageHost?.hide();
const raw = section.html ?? '';
const body = extractBody(raw);
const inlineStyles = extractInlineStyles(raw);
@@ -219,6 +265,33 @@ export class ReaderView {
}
private async renderFixed(section: Section): Promise<void> {
// A native host is offered the page first. It declines for PDF (the platform
// viewer is a separate screen, not an embeddable view) and for anything it
// cannot decode, and then the DOM path below runs unchanged.
// `fit width` is a request to fill the viewport horizontally, which a
// centred native image cannot do without cropping or scrolling. Both of
// those are worse than the WebView path, so it is not offered.
if (this.options.pageHost && this.settings.fit === 'contain') {
const mode = section.render ?? this.doc.render;
const drawn = await this.options.pageHost.show({
sectionId: section.id,
mode,
...(section.path ? { path: section.path } : {}),
...(section.image ? { mediaType: section.image.mediaType } : {}),
...(section.image ? { bytes: section.image.bytes } : {}),
fit: this.settings.fit,
});
if (drawn) {
// The native view is a sibling of the book host, not inside it, so the
// flow has to be emptied or the previous page stays behind it.
this.host.setContent('', []);
this.host.flow.replaceChildren();
return;
}
}
this.options.pageHost?.hide();
const wrapper = document.createElement('div');
wrapper.className = 'fixed-page';
wrapper.setAttribute('data-fit', this.settings.fit);
+32
View File
@@ -98,4 +98,36 @@ describe('detectFormat edge cases', () => {
it('detects an image by extension when there are no bytes yet', () => {
expect(detectFormat('scan.JPG')).toBe('image');
});
it('believes the bytes over an extension that cannot be right', () => {
// The realistic case, not a hypothetical one: a batch conversion tool writes
// a JPEG and names it after the source page. Trusting the extension means the
// PNG loader runs on JPEG bytes and the reader sees a broken image.
expect(detectFormat('page-012.png', jpeg())).toBe('image');
expect(detectFormat('chapter.xhtml', png())).toBe('image');
});
it('refuses a non-book that happens to have a book extension', () => {
// An audio file named `.txt` used to be handed to the text decoder, which
// produced fifty thousand replacement characters rather than "this is not a
// book". Naming it unknown is the honest answer.
expect(detectFormat('track.txt', new Uint8Array([0x49, 0x44, 0x33, 0x03]))).toBe('unknown');
expect(detectFormat('video.epub', new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]))).toBe('unknown');
});
it('still lets the extension break the tie between two plausible zip formats', () => {
// The ZIP signature admits EPUB and CBZ, so this is the one case where the
// name is still evidence rather than noise.
const zipMagic = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
expect(detectFormat('some-comic.cbz', zipMagic)).toBe('cbz');
expect(detectFormat('some-book.epub', zipMagic)).toBe('epub');
});
});
function png(): Uint8Array {
return new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
}
function jpeg(): Uint8Array {
return new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
}
+31 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { decodeText, isValidUtf8 } from '../src/formats/text-encoding.ts';
import { gb18030Bytes, utf8 } from './helpers/fixtures.ts';
import { big5Bytes, gb18030Bytes, utf8 } from './helpers/fixtures.ts';
/**
* Encoding detection is the difference between a readable TXT and a screen of
@@ -71,6 +71,36 @@ describe('decodeText', () => {
expect(result.guessed).toBe(true);
});
it('chooses Big5 over GB18030 when GB18030 produces damage and Big5 does not', () => {
// The whole reason the two candidates are scored instead of tried in order:
// these bytes also decode "successfully" as GB18030, so a fixed order would
// never reach Big5.
const bytes = big5Bytes('第一章 測試內容這是一段中文文本用來判斷亂碼');
expect(isValidUtf8(bytes)).toBe(false);
// Guard the premise of the test: GB18030 does decode them without throwing.
expect(new TextDecoder('gb18030').decode(bytes)).not.toBe('');
const result = decodeText(bytes);
expect(result.encoding).toBe('big5');
expect(result.text).toBe('第一章 測試內容這是一段中文文本用來判斷亂碼');
expect(result.guessed).toBe(true);
});
it('still prefers GB18030 for Simplified bytes, which also decode as Big5', () => {
// The other half of the same overlap: Simplified GB18030 bytes are accepted
// by the Big5 decoder too, and are only distinguishable by the damage they
// produce there. If the scoring were wrong in the other direction the
// majority of Chinese TXT libraries would break.
const bytes = gb18030Bytes('第一章 起始');
const result = decodeText(bytes);
expect(result.encoding).toBe('gb18030');
expect(result.text).toBe('第一章 起始');
});
it('marks the result as guessed so the UI can offer an override', () => {
const result = decodeText(gb18030Bytes('测试'));
expect(result.guessed).toBe(true);
});
it('always returns something for undecodable bytes rather than throwing', () => {
const garbage = new Uint8Array([0x81, 0x82, 0x83, 0xff, 0xfe, 0x00]);
const result = decodeText(garbage);
+29
View File
@@ -179,6 +179,35 @@ export function gb18030Bytes(text: string): Uint8Array {
return new Uint8Array(bytes);
}
/**
* Big5 byte sequences for the same reason, and for one more.
*
* Big5 is not a curiosity here: it is the encoding of a real share of
* Traditional-Chinese libraries, and — unlike GB18030 — its byte sequences
* usually *also* decode as valid GB18030. That overlap is exactly what the
* detection heuristic has to resolve, so these bytes are the test.
*/
export const BIG5_SAMPLES: Record<string, number[]> = {
// 第一章 起始
'第一章 起始': [0xb2, 0xc4, 0xa4, 0x40, 0xb3, 0xb9, 0x20, 0xb0, 0x5f, 0xa9, 0x6c],
// 測試
測試: [0xb4, 0xfa, 0xb8, 0xd5],
// 中文內容
中文內容: [0xa4, 0xa4, 0xa4, 0xe5, 0xa4, 0xba, 0xae, 0x65],
// 第一章 測試內容這是一段中文文本用來判斷亂碼
'第一章 測試內容這是一段中文文本用來判斷亂碼': [
0xb2, 0xc4, 0xa4, 0x40, 0xb3, 0xb9, 0x20, 0xb4, 0xfa, 0xb8, 0xd5, 0xa4, 0xba, 0xae, 0x65, 0xb3, 0x6f,
0xac, 0x4f, 0xa4, 0x40, 0xac, 0x71, 0xa4, 0xa4, 0xa4, 0xe5, 0xa4, 0xe5, 0xa5, 0xbb, 0xa5, 0xce, 0xa8,
0xd3, 0xa7, 0x50, 0xc2, 0x5f, 0xb6, 0xc3, 0xbd, 0x58,
],
};
export function big5Bytes(text: string): Uint8Array {
const bytes = BIG5_SAMPLES[text];
if (!bytes) throw new Error(`no Big5 fixture for ${text}`);
return new Uint8Array(bytes);
}
export function utf8(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
+1
View File
@@ -6,6 +6,7 @@ function doc(sections: Array<Partial<Section>>): BookDoc {
return {
format: 'epub',
layout: 'reflowable',
render: 'reflowable',
direction: 'ltr',
sections: sections.map((section, index) => ({
id: section.id ?? `s${index}`,
+267
View File
@@ -0,0 +1,267 @@
// @vitest-environment jsdom
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { ReaderView } from '../src/ui/reader-view.ts';
import { createNativePageHost, type NativePageRequest } from '../src/ui/native-page.ts';
import { androidPageHost, serialisePage } from '../src/core/android-platform.ts';
import type { AndroidBridge } from '../src/android-bridge.ts';
import type { BookDoc } from '../src/formats/types.ts';
/**
* The native page path.
*
* This is the one place where the Android client does something the H5 client
* cannot, and the property that makes it safe is the fallback: a host either
* draws the page or says no, and "no" has to put the page back in the WebView
* with nothing left over. Every test here is about that contract, because the
* failure mode it guards against — a blank screen where a page should be — is
* invisible to the type system and only shows up on a device.
*/
beforeAll(() => {
// jsdom has no object URLs, which the DOM fallback path uses for page images.
if (!URL.createObjectURL) {
Object.defineProperty(URL, 'createObjectURL', { value: () => 'blob:stub', writable: true });
}
if (!URL.revokeObjectURL) {
Object.defineProperty(URL, 'revokeObjectURL', { value: () => undefined, writable: true });
}
});
function fixedDoc(): BookDoc {
return {
format: 'cbz',
layout: 'fixed',
render: 'image',
direction: 'ltr',
sections: [
{ id: 'p0.png', label: '第 1 页', image: { mediaType: 'image/png', bytes: new Uint8Array([1]) }, depth: 0, render: 'image', path: 'p0.png' },
{ id: 'p1.png', label: '第 2 页', image: { mediaType: 'image/png', bytes: new Uint8Array([2]) }, depth: 0, render: 'image', path: 'p1.png' },
],
toc: [],
styles: [],
resources: new Map(),
orderedByBook: true,
};
}
function docWithPdf(): BookDoc {
return {
format: 'pdf',
layout: 'fixed',
render: 'document',
direction: 'ltr',
sections: [{ id: 'pdf', label: 'PDF', html: '', depth: 0, render: 'document' }],
toc: [],
styles: [],
resources: new Map(),
orderedByBook: true,
};
}
/**
* The reading surface lives inside a shadow root by design, so queries have to
* go through the host element. Querying the app document would pass on a
* regression that leaked the book into the shell.
*/
function flowOf(viewInstance: ReaderView): ShadowRoot {
const host = viewInstance.elementHost?.shadowRoot;
if (!host) throw new Error('no shadow root: the book content is not where it should be');
return host;
}
/**
* The reading surface, reached the way a test must: through the shadow root.
*/
function view(doc: BookDoc, pageHost?: ReturnType<typeof createNativePageHost>): ReaderView {
const container = document.createElement('div');
document.body.append(container);
return new ReaderView({
container,
doc,
...(pageHost ? { pageHost } : {}),
});
}
describe('ReaderView with a native page host', () => {
it('hands a fixed-layout page to the host and leaves the DOM flow empty', async () => {
const requests: NativePageRequest[] = [];
const host = createNativePageHost(async (request) => {
requests.push(request);
return true;
}, () => undefined);
const viewInstance = view(fixedDoc(), host);
await viewInstance.open(0, 0);
expect(requests).toHaveLength(1);
expect(requests[0]!.sectionId).toBe('p0.png');
expect(requests[0]!.mode).toBe('image');
// A stale DOM page under the native one is the bug this asserts against:
// the reader would see the previous page's markup through any transparent
// part of the new one.
expect(flowOf(viewInstance).querySelectorAll('.fixed-page')).toHaveLength(0);
});
it('draws the page in the DOM when the host declines', async () => {
const host = createNativePageHost(async () => false, () => undefined);
const viewInstance = view(fixedDoc(), host);
await viewInstance.open(0, 0);
expect(flowOf(viewInstance).querySelectorAll('.fixed-page')).toHaveLength(1);
expect(flowOf(viewInstance).querySelectorAll('.fixed-page img')).toHaveLength(1);
});
it('renders reflowable sections in the DOM without asking the native host', async () => {
const host = createNativePageHost(async () => true, () => undefined);
const show = vi.spyOn(host, 'show');
const doc: BookDoc = {
format: 'epub',
layout: 'reflowable',
render: 'reflowable',
direction: 'ltr',
sections: [{ id: 'ch1.xhtml', label: '第一章', html: '<p>正文</p>', depth: 0 }],
toc: [],
styles: [],
resources: new Map(),
orderedByBook: true,
};
const viewInstance = view(doc, host);
await viewInstance.open(0, 0);
// EPUB layout is the product's differentiator and has exactly one
// implementation; asking the shell to draw it would be a second one.
expect(show).not.toHaveBeenCalled();
});
it('does not offer a page to the host while fit width is selected', async () => {
// Native drawing centres the page; "fit width" asks for it to fill
// horizontally. A native renderer can only do that by cropping (losing
// panels) or by growing its own scroll surface (a second renderer), so the
// page stays in the WebView and the setting keeps working.
const show = vi.fn(async () => true);
const host = createNativePageHost(show, () => undefined);
const container = document.createElement('div');
document.body.append(container);
const viewInstance = new ReaderView({ container, doc: fixedDoc(), pageHost: host });
viewInstance.applySettings({ fit: 'width' });
await viewInstance.open(0, 0);
expect(show).not.toHaveBeenCalled();
expect(flowOf(viewInstance).querySelectorAll('.fixed-page img')).toHaveLength(1);
});
it('does not ask the host to draw a document, which it cannot embed', async () => {
const requests: NativePageRequest[] = [];
const host = createNativePageHost(async (request) => {
requests.push(request);
return false;
}, () => undefined);
const viewInstance = view(docWithPdf(), host);
await viewInstance.open(0, 0);
// The request is made — the view does not special-case formats — and the
// host is expected to decline, which is asserted by the DOM having drawn it.
expect(requests).toHaveLength(1);
expect(requests[0]!.mode).toBe('document');
expect(flowOf(viewInstance).querySelectorAll('.fixed-page')).toHaveLength(1);
});
it('without a host, behaves exactly like the browser build', async () => {
const viewInstance = view(fixedDoc());
await viewInstance.open(0, 0);
expect(flowOf(viewInstance).querySelectorAll('.fixed-page img')).toHaveLength(1);
});
});
describe('serialisePage', () => {
it('carries bytes as base64 so a JS interface can accept them', () => {
const payload = JSON.parse(serialisePage({
sectionId: 'p1.png',
mode: 'image',
mediaType: 'image/png',
bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
fit: 'contain',
})) as Record<string, string>;
expect(payload['sectionId']).toBe('p1.png');
expect(payload['mode']).toBe('image');
expect(payload['mediaType']).toBe('image/png');
expect(atob(payload['bytes']!)).toBe('\u0089PNG');
});
it('omits bytes rather than sending an empty payload', () => {
const payload = JSON.parse(serialisePage({ sectionId: 'p.png', mode: 'image', fit: 'contain' })) as Record<string, unknown>;
expect('bytes' in payload).toBe(false);
});
it('survives a page image larger than the argument-list limit', () => {
// `String.fromCharCode(...bytes)` throws RangeError somewhere around 100k
// arguments, which on a real device would look like "native rendering does
// not work on big pages" rather than a crash in an encoder.
const bytes = new Uint8Array(300_000).fill(7);
const payload = JSON.parse(serialisePage({ sectionId: 'big.png', mode: 'image', bytes, fit: 'contain' })) as Record<string, string>;
expect(atob(payload['bytes']!).length).toBe(300_000);
});
});
describe('androidPageHost', () => {
function bridge(overrides: Partial<AndroidBridge> = {}): AndroidBridge {
return {
shellVersion: () => 2,
deviceLabel: () => 'Pixel 7',
connectivity: () => 'online',
watchConnectivity: () => undefined,
toast: () => undefined,
hasNetwork: () => true,
viewport: () => '{}',
cacheUsage: () => '{}',
diagnostics: () => '{}',
renderPage: () => true,
hidePage: () => undefined,
canOpenDocument: () => false,
...overrides,
};
}
it('is absent on a shell that predates the renderer', () => {
// An old APK must keep working. Refusing to run would be the wrong trade for
// an optimisation.
expect(androidPageHost(bridge({ shellVersion: () => 1 }))).toBeNull();
});
it('is absent when the bridge is missing the method despite its version', () => {
const partial = bridge();
delete (partial as { renderPage?: unknown }).renderPage;
expect(androidPageHost(partial)).toBeNull();
});
it('declines reflowable and document modes without calling across the bridge', async () => {
const renderPage = vi.fn(() => true);
const host = androidPageHost(bridge({ renderPage }))!;
expect(await host.show({ sectionId: 'x', mode: 'reflowable', fit: 'contain' })).toBe(false);
expect(await host.show({ sectionId: 'x', mode: 'document', fit: 'contain' })).toBe(false);
expect(renderPage).not.toHaveBeenCalled();
});
it('treats a throwing bridge as a decline rather than an error', async () => {
const host = androidPageHost(
bridge({
renderPage: () => {
throw new Error('renderer died');
},
}),
)!;
// The reader must still see the page, drawn by the WebView.
await expect(host.show({ sectionId: 'p.png', mode: 'image', fit: 'contain' })).resolves.toBe(false);
});
it('passes an image page through and reports success', async () => {
const calls: string[] = [];
const host = androidPageHost(bridge({ renderPage: (request) => (calls.push(request), true) }))!;
expect(await host.show({ sectionId: 'p.png', mode: 'image', mediaType: 'image/png', fit: 'contain' })).toBe(true);
expect(calls).toHaveLength(1);
expect(JSON.parse(calls[0]!)['sectionId']).toBe('p.png');
});
});
+2
View File
@@ -34,6 +34,7 @@ function reflowableDoc(sectionCount: number): BookDoc {
return {
format: 'epub',
layout: 'reflowable',
render: 'reflowable',
direction: 'ltr',
sections: Array.from({ length: sectionCount }, (_value, index) => ({
id: `ch${index}.xhtml`,
@@ -56,6 +57,7 @@ function fixedDoc(pageCount: number): BookDoc {
return {
format: 'cbz',
layout: 'fixed',
render: 'image',
direction: 'ltr',
sections: Array.from({ length: pageCount }, (_value, index) => ({
id: `p${index}.png`,
+44
View File
@@ -212,6 +212,50 @@ describe('OfflineStore', () => {
expect(snapshot.serverTime).toBe(0);
});
it('keeps a position written while its own push was in flight', async () => {
// The failure this guards against is the one this product cannot have: a lost
// reading position. It happens with a "send everything, clear everything"
// outbox — the reader turns a page between the request going out and the
// response arriving, the response clears the whole set, and the new position
// is silently never sent. A dirty-set plus a timestamp check on the way back
// is what makes the window not exist.
const store = new OfflineStore(new MemoryKv());
const first = { bookId: 'b1', locator: 'r1:0.10:c1', percentage: 0.1, chapterTitle: 'c1', device: 'a', updatedAt: 1000 };
await store.setProgress(first);
// The batch that the engine is about to send.
const batch = store.outbox();
// In flight: the reader turns the page.
await store.setProgress({ ...first, locator: 'r1:0.20:c1', percentage: 0.2, updatedAt: 1100 });
await store.markDelivered(batch, 5000);
expect(store.current.dirtyProgress).toContain('b1');
expect(store.outbox().progress[0]!.percentage).toBe(0.2);
// The cursor is still adopted, so the next pull stays incremental.
expect(store.current.serverTime).toBe(5000);
});
it('clears a position that was not touched while in flight', async () => {
const store = new OfflineStore(new MemoryKv());
await store.setProgress({ bookId: 'b1', locator: 'r1:0.5:c1', percentage: 0.5, chapterTitle: 'c1', device: 'a', updatedAt: 1000 });
await store.markDelivered(store.outbox(), 1);
expect(store.current.dirtyProgress).toEqual([]);
expect(isOutboxEmpty(store.current)).toBe(true);
});
it('does not let a delivered batch clear a different book', async () => {
// Clearing by "the batch's ids" rather than by timestamps would still be
// wrong here if the ids were taken from the wrong side of the call.
const store = new OfflineStore(new MemoryKv());
await store.setProgress({ bookId: 'b1', locator: 'x', percentage: 0.1, chapterTitle: 'c', device: 'a', updatedAt: 1000 });
const batch = store.outbox();
await store.setProgress({ bookId: 'b2', locator: 'x', percentage: 0.2, chapterTitle: 'c', device: 'a', updatedAt: 1000 });
await store.markDelivered(batch, 1);
expect(store.current.dirtyProgress).toEqual(['b2']);
});
it('serialises concurrent writes so a fast reader does not lose a position', async () => {
const store = new OfflineStore(new MemoryKv());
await Promise.all(
+77
View File
@@ -0,0 +1,77 @@
/**
* The staged reader's two remaining silent-failure modes.
*
* Both of these were live on `main` and neither shows up as a crash: the first
* makes the contents panel list the window instead of the book, the second makes
* a jump to a chapter outside the window do nothing at all.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { createStagedDoc } from '../src/formats/windowed.ts';
import type { BookContent, ContentItem } from '../src/net/api.ts';
function item(seq: number): ContentItem {
return {
id: `c${seq}`,
seq,
title: `第 ${seq + 1} 章`,
kind: 'chapter',
mediaType: 'application/xhtml+xml',
href: `xhtml:ch${seq}.xhtml`,
};
}
function windowAt(offset: number, count: number, total: number): BookContent {
return {
kind: 'reflowable',
total,
groups: [{ id: `spine:${offset}`, seq: offset / count, title: '窗', count, offset }],
items: Array.from({ length: count }, (_, i) => item(offset + i)),
group: offset / count,
};
}
describe('staged toc and window jumps', () => {
test('a jump outside the loaded window resolves to the right local index', () => {
const doc = createStagedDoc({
kind: 'reflowable',
toc: [],
content: windowAt(0, 40, 1200),
loader: { async read() { return { html: '' }; } },
});
// Chapter 900 lives in the window starting at 880.
const local = doc.setWindow(windowAt(880, 40, 1200), 900);
assert.equal(local, 20, 'local index must be spine minus the window offset');
assert.equal(doc.sections[20]?.id, 'xhtml:ch900.xhtml');
assert.equal(doc.windowOffset(), 880);
});
test('a chapter that is not in the new window reports failure rather than index 0', () => {
// The failure this pins: a naive `findIndex` returns -1, and a caller that
// treats -1 as "0" silently moves the reader to the wrong chapter.
const doc = createStagedDoc({
kind: 'reflowable',
toc: [],
content: windowAt(0, 40, 1200),
loader: { async read() { return { html: '' }; } },
});
const local = doc.setWindow(windowAt(0, 40, 1200), 900);
assert.equal(local, -1);
});
test('the loaded window is replaced, not appended to', () => {
// Appending is the other way this goes wrong: the reader ends up with 80
// sections for a 40-chapter window and the progress denominator doubles.
const doc = createStagedDoc({
kind: 'reflowable',
toc: [],
content: windowAt(0, 40, 1200),
loader: { async read() { return { html: '' }; } },
});
assert.equal(doc.sections.length, 40);
doc.setWindow(windowAt(40, 40, 1200), 40);
assert.equal(doc.sections.length, 40);
assert.equal(doc.sections[0]?.id, 'xhtml:ch40.xhtml');
});
});
+292
View File
@@ -0,0 +1,292 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { createStagedDoc } 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';
/**
* Staged, one-window-at-a-time reading.
*
* The property being protected is the product's own hard requirement: opening a
* book must not download the book. These tests pin the two things that decide
* whether it holds — that a section's body is fetched only when it is displayed,
* and that a position survives crossing a window boundary.
*/
function item(index: number): ContentItem {
return {
id: `c${index}`,
seq: index,
title: `第 ${index + 1} 章`,
kind: 'chapter',
mediaType: 'application/xhtml+xml',
href: `xhtml:ch${index}.xhtml`,
};
}
function windowOf(from: number, count: number, total: number): BookContent {
return {
kind: 'reflowable',
total,
groups: [
{ id: `spine:${from}`, seq: from / count, title: `${from}..`, count, offset: from },
],
items: Array.from({ length: count }, (_, i) => item(from + i)),
group: from / count,
};
}
describe('staged documents', () => {
it('does not read a section until it is asked for', async () => {
const read: string[] = [];
const doc = createStagedDoc({
kind: 'reflowable',
toc: [{ id: 'xhtml:ch0.xhtml', label: '第一章', depth: 0 }],
content: windowOf(0, 40, 1200),
loader: {
async read(it) {
read.push(it.href);
return { html: `<p>${it.title}</p>` };
},
},
});
expect(doc.sections).toHaveLength(40);
expect(read).toEqual([]);
const section = await doc.loadSection(3);
expect(read).toEqual(['xhtml:ch3.xhtml']);
expect(section?.html).toBe('<p>第 4 章</p>');
});
it('reads a section once, however often it is displayed', async () => {
// Re-styling and resizing both re-render the current chapter, so a loader
// that re-fetched on every call would make a font-size change a network
// round trip.
let calls = 0;
const doc = createStagedDoc({
kind: 'reflowable',
toc: [],
content: windowOf(0, 4, 4),
loader: {
async read() {
calls += 1;
return { html: '<p>x</p>' };
},
},
});
await doc.loadSection(0);
await doc.loadSection(0);
await doc.loadSection(0);
expect(calls).toBe(1);
});
it('keeps the table of contents whole while the window is not', async () => {
// The failure this guards against is visible in the UI: a 1200-chapter book
// whose contents panel reads "第 1 章 – 第 40 章" because that is where the
// window ended.
const doc = createStagedDoc({
kind: 'reflowable',
toc: Array.from({ length: 1200 }, (_, i) => ({
id: `xhtml:ch${i}.xhtml`,
label: `第 ${i + 1} 章`,
depth: 0,
})),
content: windowOf(0, 40, 1200),
loader: { async read() { return { html: '' }; } },
});
expect(doc.sections).toHaveLength(40);
expect(doc.toc).toHaveLength(1200);
expect(doc.toc[1199]?.label).toBe('第 1200 章');
});
it('finds a whole-book position inside a newly loaded window', async () => {
const doc = createStagedDoc({
kind: 'reflowable',
toc: [],
content: windowOf(0, 40, 1200),
loader: { async read() { return { html: '' }; } },
});
expect(doc.windowOffset()).toBe(0);
// Jump to chapter 900: window 22 (900/40), local index 20.
const local = doc.setWindow(windowOf(880, 40, 1200), 900);
expect(local).toBe(20);
expect(doc.windowOffset()).toBe(880);
expect(doc.sections[20]?.id).toBe('xhtml:ch900.xhtml');
});
it('loads a page image for a fixed-layout window', async () => {
const doc = createStagedDoc({
kind: 'paged',
toc: [],
content: {
kind: 'paged',
total: 3,
groups: [{ id: 'v0', seq: 0, title: '卷', count: 3, offset: 0 }],
items: [
{ id: '0', seq: 0, title: '001.jpg', kind: 'page', mediaType: 'image/jpeg', href: 'page:0' },
{ id: '1', seq: 1, title: '002.jpg', kind: 'page', mediaType: 'image/jpeg', href: 'page:1' },
{ id: '2', seq: 2, title: '003.jpg', kind: 'page', mediaType: 'image/jpeg', href: 'page:2' },
],
group: 0,
},
loader: {
async read(it) {
return { image: { mediaType: 'image/jpeg', bytes: new Uint8Array([it.seq]) } };
},
},
});
expect(doc.layout).toBe('fixed');
expect(doc.render).toBe('image');
const section = await doc.loadSection(1);
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;
}
+8 -1
View File
@@ -41,6 +41,13 @@ export default defineConfig({
// file from one process, or vice versa, silently produces "no test suite
// found". Excluding them here keeps each runner to the files it can actually
// execute, instead of a green-looking run that quietly skipped half the suite.
exclude: ['test/api.test.ts', 'test/asset-url.test.ts', 'test/paginator.test.ts', 'test/window.test.ts', 'node_modules/**'],
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/**',
],
},
});