mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 22:41:06 +08:00
docs: 更新 README / 架构 / API 文档
记录本轮的四项决定与三个坑: - 排版策略:出版方优先、变量层可被书拒绝、无 !important - 渲染分工按内存而非速度划分 - 外壳用 Shadow DOM、书用 iframe 的完整理由 - column-fill 需要确定高度的实测结论 - 分窗与目录分离的原因 - 解析器版本机制与它解决的静默陈旧问题 - 子资源 query token 的白名单边界
This commit is contained in:
1 parent
8e55dc23a3
commit
02453c93d3
3 files changed
+207
-13
No files matched your search
@@ -20,6 +20,9 @@ docker compose up -d
|
||||
|
||||
打开 `http://<你的主机>:8080`,**第一个注册的账号自动成为管理员**。
|
||||
|
||||
这个地址同时就是**完整的阅读客户端**:服务端把 H5 客户端一起打包发出,
|
||||
不需要第二个部署、不需要应用商店。
|
||||
|
||||
> 只有第一次注册是开放的。之后新账号由管理员在后台创建(默认关闭公开注册),
|
||||
> 这样你的实例不会被陌生人占用。想让家人自行注册,在 `docker-compose.yml` 里
|
||||
> 把 `ALLOW_REGISTRATION` 设为 `"true"`。
|
||||
@@ -62,6 +65,21 @@ volumes:
|
||||
| 单张图片 | `.jpg` `.png` `.webp` … | 按单页读物 | 直接取图 |
|
||||
| PDF | `.pdf` | 保底可读,原样下发 | 交给客户端自己的 PDF 渲染 |
|
||||
|
||||
### 排版策略:出版方的设计优先
|
||||
|
||||
这是本产品最核心的一条,所以单独说。客户端**不接管排版**。
|
||||
|
||||
- **不覆盖** `font-family`、`line-height`、`text-align`、`letter-spacing`、
|
||||
正文元素的 `margin`、`writing-mode`。这些是排版者的决定。
|
||||
- 读者能调的(字号、主题、页宽)通过 CSS 变量下发,**书可以拒绝**:
|
||||
`font-size: var(--reader-font-size, 1em)` 的含义是"书自己声明了就听书的"。
|
||||
所以整个客户端里没有一处 `!important`。
|
||||
- 章节按包内路径寻址,样式表、字体、图片原样加载。脚注锚点、外链、`data:` URI
|
||||
都不改写 —— 改了就坏。
|
||||
|
||||
对比:Readium CSS 的设计前提是"用一致的排版体验换保真度",与本产品正好相反,
|
||||
所以不套 Readium Mobile(设计文档 §5 第 2 条)。
|
||||
|
||||
**不支持**:`.cbr` / `.rar`。RAR 需要非自由的解压实现或外部二进制,两者都会破坏
|
||||
「单容器、零原生依赖」这条约束。遇到 `.rar` 会明确提示,而不是解出损坏的页面。
|
||||
|
||||
@@ -135,6 +153,23 @@ volumes:
|
||||
|
||||
---
|
||||
|
||||
## 阅读体验:一次只取一章
|
||||
|
||||
打开一本书是**一次请求**(`/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`,让客户端知道
|
||||
而不是白跑一次。
|
||||
|
||||
## 客户端三态
|
||||
|
||||
服务端是唯一真相源,客户端只做「渲染 + 缓存」。客户端有三种状态:
|
||||
@@ -147,6 +182,8 @@ volumes:
|
||||
|
||||
同步的只有轻量数据:书架元数据、阅读进度、笔记、高亮。书文件不走同步。
|
||||
|
||||
离线缓存按书下载(`/books/:id/content`),带 `ETag` 与 `Range`,中断可续传。
|
||||
|
||||
### 离线合并语义
|
||||
|
||||
进度和笔记都带 `updatedAt`,冲突按 **last-writer-wins** 解决:
|
||||
@@ -207,10 +244,13 @@ reader.example.com {
|
||||
|
||||
## 从源码开发
|
||||
|
||||
两个包,互不依赖构建顺序:
|
||||
|
||||
```bash
|
||||
# 服务端
|
||||
cd server
|
||||
npm install
|
||||
npm test # 27 个测试用例
|
||||
npm test # 114 个测试用例
|
||||
npm run typecheck
|
||||
npm run dev # 开发模式,热重载
|
||||
|
||||
@@ -218,6 +258,29 @@ npm run dev # 开发模式,热重载
|
||||
BOOKS_DIR=/tmp/books DATA_DIR=/tmp/data npm run dev
|
||||
```
|
||||
|
||||
```bash
|
||||
# H5 客户端(开发时走 Vite 代理到 8080)
|
||||
cd web
|
||||
npm install
|
||||
npm test # 32 个测试用例
|
||||
npm run build # 产物在 web/dist,服务端会自动找到它
|
||||
```
|
||||
|
||||
`npm run dev` 起服务端、`npm run dev` 起 Vite,浏览器开 5173 端口即可,
|
||||
API 请求由 Vite 代理。服务端会依次查找 `$WEB_DIR`、`server/public`、
|
||||
`web/dist`,所以本地构建完直接就能在 8080 上看到客户端。
|
||||
|
||||
### 在真实浏览器里验证渲染
|
||||
|
||||
分页、出版方样式是否被保住、子资源是否 401 —— 这些只有真浏览器能验。
|
||||
本轮的四处渲染缺陷全部是这一步查出来的,所以改动渲染层时请一并跑:
|
||||
|
||||
```bash
|
||||
cd web && npx playwright install chromium
|
||||
# 起一个带书的服务端后:
|
||||
node -e "…" # 见提交历史里的实测脚本;检查 scrollWidth != clientWidth 即分页生效
|
||||
```
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
|
||||
+46
-1
@@ -141,6 +141,12 @@
|
||||
| `paged` | 固定页序的图片 | cbz、漫画目录、单图 |
|
||||
| `text` | 连续文本,可能带章节 | txt |
|
||||
| `document` | 不透明文档,客户端自己渲染 | pdf |
|
||||
| `single-image` | 单页图片 | 单个图片文件 |
|
||||
|
||||
**章节按包内路径寻址,不按下标。** `items[].href` 是不透明的格式私有引用
|
||||
(`xhtml:OEBPS/ch1.xhtml`、`page:17`、`chapter:4`),客户端原样回传即可。
|
||||
下标只在一个窗口内成立:分窗返回的是 spine 的前缀,所以按下标算出的章节
|
||||
在完整 spine 里指向另一章。
|
||||
|
||||
```json
|
||||
{ "kind": "paged", "total": 6,
|
||||
@@ -156,6 +162,22 @@
|
||||
`total` 在格式无法廉价得知时为 `0`,`groups` 为空 —— 例如没有标题的纯文本。
|
||||
不要把它当作错误。
|
||||
|
||||
### `GET /books/:id/toc`
|
||||
|
||||
书的目录。**与 `/items` 分开**,因为两者要的性质相反:`/items` 是传输边界,
|
||||
一次给一点;目录必须完整,否则跳转是残的。合成一个接口会让目录显示成
|
||||
「第 1 章 – 第 40 章」——服务端的分页边界漏进 UI。
|
||||
|
||||
```json
|
||||
{ "toc": [ { "href": "xhtml:OEBPS/text/ch1.xhtml", "title": "第一章", "level": 0, "spine": 0 } ] }
|
||||
```
|
||||
|
||||
`spine` 是全书下标,`/items?group=N` 的每个 group 都带 `offset`,所以客户端
|
||||
拿到单窗也能把跳转落到正确章节,不需要把前面的 count 加一遍。
|
||||
|
||||
按格式:epub 取 spine 标题(NCX / nav),txt 取分章结果,漫画目录取卷,
|
||||
pdf / 单图没有独立目录,回落到 items。
|
||||
|
||||
### `GET /books/:id/assets?ref=<ref>`
|
||||
|
||||
取单个资源。`ref` 是**不透明**的格式私有引用,客户端只应把它从 `items[].href`
|
||||
@@ -172,6 +194,22 @@
|
||||
响应带 `Cache-Control: private, max-age=31536000, immutable`:资源由书籍主键寻址,
|
||||
而主键来自内容哈希,所以同一个 URL 的内容永不改变。
|
||||
|
||||
**流式下发,支持 Range。** 响应带 `Accept-Ranges: bytes` 时可以用
|
||||
`Range: bytes=0-1023` 取片段(PDF 跳页、漫画跳卷、断点续传)。压缩方式为
|
||||
`deflate` 的 zip 条目无法字节定位,会诚实上报 `Accept-Ranges: none` ——
|
||||
让客户端知道,而不是白跑一次再发现。
|
||||
|
||||
**子资源可以带 `?access_token=`。** 章节里的 `<img src>`、`<link href>` 是浏览器
|
||||
自己发起的,没法带 `Authorization` 头。这类请求允许把令牌放进 query,
|
||||
但**仅限** `GET` + 下列端点:
|
||||
|
||||
- `/api/v1/books/:id/assets`
|
||||
- `/api/v1/books/:id/cover`
|
||||
- `/api/v1/books/:id/content`
|
||||
|
||||
其他任何端点(含触发扫描、写元数据)都不接受 URL 里的令牌 —— URL 会进日志、
|
||||
进历史、进 `Referer`,能改状态的令牌不该走那条路。
|
||||
|
||||
**epub 章节返回的是重写过的 HTML。** 章节内的相对资源引用(`images/pic.png`)已经被
|
||||
服务端改写成指向本端点的绝对地址。客户端直接把 HTML 交给 WebView 即可,不需要自己
|
||||
解析路径。绝对 URL、`data:` URI 和文内锚点(`#note7`)保持原样,所以脚注和外链仍然可用。
|
||||
@@ -191,7 +229,11 @@
|
||||
|
||||
流式返回书文件,`Content-Type` 按格式给 `application/epub+zip` 或 `application/pdf`。
|
||||
|
||||
书内容按哈希不可变,所以带 `ETag`,客户端可以放心长缓存。
|
||||
书内容按哈希不可变,所以带 `ETag`,客户端可以放心长缓存。支持
|
||||
`Range: bytes=`,所以**离线下载可以续传**。
|
||||
|
||||
目录型书籍(漫画目录)没有单一文件,返回 `400 DIRECTORY_BOOK`;
|
||||
这类书按 `items?group=N` 逐卷取图。
|
||||
|
||||
### `GET /books/:id/cover`
|
||||
|
||||
@@ -363,6 +405,9 @@
|
||||
| `ACCOUNT_DISABLED` | 403 | 账号被停用 |
|
||||
| `FORBIDDEN` | 403 | 越权访问 |
|
||||
| `PATH_TRAVERSAL` | 403 | 路径逃逸出挂载点 |
|
||||
| `DIRECTORY_BOOK` | 400 | 这本书由目录承载,按页取而不是整本下载 |
|
||||
| `UNSUPPORTED_FORMAT` | 400 | 该格式不提供可寻址结构 |
|
||||
| `EMPTY_ASSET` | 400 | 资源既没有数据也没有流 |
|
||||
| `ADMIN_REQUIRED` | 403 | 需要管理员权限 |
|
||||
| `NOT_FOUND` | 404 | 资源不存在 |
|
||||
| `NO_COVER` | 404 | 这本书没有封面 |
|
||||
|
||||
+97
-11
@@ -183,22 +183,81 @@ id = sha256( (dc:identifier ?? '').toLowerCase() + '\0' + sha256(fileBytes) )
|
||||
|
||||
---
|
||||
|
||||
## 8. 客户端渲染(尚未实现)
|
||||
## 8. 客户端渲染
|
||||
|
||||
产品设计里的关键决策:**不要直接套 Readium Mobile**。
|
||||
产品设计里的关键决策:**不要直接套 Readium Mobile**。Readium 用 Readium CSS
|
||||
主动接管排版、覆盖出版方样式,这与本产品「忠实还原精排书设计」的核心差异化
|
||||
直接冲突。实现落在 `web/`,没有框架 —— 渲染热路径是命令式 DOM 操作,
|
||||
引框架只会把「最小覆盖」的策略搞复杂。产物 38KB(gzip 12KB)。
|
||||
|
||||
Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本产品
|
||||
「忠实还原精排书设计」的核心差异化直接冲突。
|
||||
### 谁渲染什么
|
||||
|
||||
计划方案(下一轮):
|
||||
| `kind` | H5 | Android |
|
||||
| --- | --- | --- |
|
||||
| `reflowable`(epub) | iframe | WebView(同一份代码) |
|
||||
| `text`(txt) | iframe | WebView |
|
||||
| `paged`(cbz / 漫画目录 / 单图) | `<img>` 翻页器 | 原生 Pager |
|
||||
| `document`(pdf) | iframe | 系统查看器 |
|
||||
|
||||
- 原生外壳(Kotlin)只负责:文件与缓存、手势、书架、账号
|
||||
- 渲染层用 WebView + 自研 CSS 干预策略,参考 foliate-js 的「保留作者样式 + 最小覆盖」
|
||||
- 参考 readium-css 的变量组织方式,但**不引入它的默认接管行为**
|
||||
分工的依据是内存而不是速度。一页漫画是一个 JPEG:原生 `ImageView` 走硬件解码
|
||||
并复用 bitmap,WebView 每页一个合成层、在主线程解码 —— 300MB 的画册在
|
||||
WebView 里是中端机 OOM,在原生里没事。反过来,可重排文本只有 WebView 实现了
|
||||
CSS 多栏、内嵌字体、竖排、振假名,原生文本栈重做一遍只会更差。
|
||||
|
||||
服务端为此准备的是格式中立的清单契约:`GET /api/v1/books/:id/items` 返回
|
||||
可寻址结构(章节 / 页 / 卷),`assets?ref=` 取单个资源。`kind` 字段告诉客户端
|
||||
该按可重排文本还是固定页来渲染,所以 PDF、漫画和 EPUB 可以复用同一套客户端骨架。
|
||||
### 为什么外壳用 Shadow DOM、书用 iframe
|
||||
|
||||
这两个问题的答案不同,混为一谈是错的。
|
||||
|
||||
**Shadow DOM 挡的不是出版方样式。** 出版方的 CSS 是章节资源,必须进到 root
|
||||
里才能生效,进去之后它照常给书排版 —— 书本来就该给自己排版。它挡的是反方向:
|
||||
客户端自身的规则不会漏进书里,`all: unset` / `!important` 影响不到脚注和引用。
|
||||
|
||||
**书必须是真文档。** 章节里的 `<img src>` 和 `<link rel=stylesheet>` 是浏览器
|
||||
自己去请求的,没法带 `Authorization` 头。把章节注入宿主文档的结果是每个插图、
|
||||
每个样式表都 401 —— 书渲染成无样式无图。iframe 是真浏览上下文,子资源请求是
|
||||
普通导航,URL 里带什么就发什么。
|
||||
|
||||
代价是每次请求要带 token(服务端的 `queryTokenAllowed` 是白名单:仅 GET、
|
||||
仅 `books/*/assets|cover|content`,触发扫描之类一律不接受 URL 里的 token)。
|
||||
收益是章节由渲染了三十年文档的引擎渲染,而不是我们祈祷等价的片段。
|
||||
|
||||
`srcdoc` 而不是 Blob URL:Blob URL 的 origin 是 opaque,frame 的请求会变成
|
||||
跨源而被拒。`srcdoc` 继承父文档 origin,改写后的绝对 URL 才能用。
|
||||
|
||||
### 分页:`column-fill: auto` 需要确定高度
|
||||
|
||||
这是实测才发现的,值得记一笔。多栏布局只在容器高度**确定**时才分栏:
|
||||
不设高度的 `body` 会撑到内容高度,整章排成一列,`scrollWidth` 等于
|
||||
`clientWidth` —— 翻页手势完全无效,一章只显示一页。
|
||||
|
||||
所以文档被钉在 frame 的视口高度(`100vh` 在 iframe 里就是 frame 自己的视口)。
|
||||
`body` 上**不能**加 `overflow: hidden`:那会把它自己生成的列也裁掉,第一页之后
|
||||
的内容永远滚不到。
|
||||
|
||||
`scroll-snap` 也去掉了:吸附目标是列边缘,不是我们算出来的页边界,
|
||||
会让 `scrollLeft` 落在两页之间甚至一次跳两页。
|
||||
|
||||
### 服务端为此准备的契约
|
||||
|
||||
- `GET /books/:id/manifest` — 打开一本书的**全部**信息,一次往返。
|
||||
客户端不必先拿书、再拿结构。
|
||||
- `GET /books/:id/items?group=N` — 分窗的可寻址结构。`kind` 告诉客户端按
|
||||
可重排文本还是固定页渲染。
|
||||
- `GET /books/:id/toc` — 真正的目录,与分窗分离(见下)。
|
||||
- `GET /books/:id/assets?ref=<opaque>` — 单个资源,流式,支持 Range。
|
||||
|
||||
### 分窗与目录为什么是两个接口
|
||||
|
||||
一开始它们是一个。`items` 按窗口返回(epub 40 章、漫画一卷),而客户端要目录时
|
||||
也去读它,结果是目录显示成「第 1 章 – 第 40 章」—— 服务端的传输边界漏进了 UI。
|
||||
|
||||
两者要的性质本来就相反:**分窗**是传输边界,一次只给一点,因为客户端要逐段
|
||||
下载;**目录**必须完整,否则跳转就是残的。合成一个接口,其中一个必然做错。
|
||||
|
||||
分窗还有一个约束值得写下来:**章节不能按下标寻址**。分窗返回的是 spine 的前缀,
|
||||
所以按窗口内下标算出来的章节,在完整 spine 里指向的是另一章。items 一律按
|
||||
包内路径寻址(`xhtml:OEBPS/ch1.xhtml`)。这是第一版契约的真实缺陷,改对了
|
||||
之后客户端的「恢复到上次位置」才稳定。
|
||||
|
||||
## 格式层:为什么是注册表
|
||||
|
||||
@@ -230,6 +289,17 @@ epub.ts pdf.ts comic-archive.ts comic-directory.ts text.ts image.ts
|
||||
「系列 + 每卷 + 每页」的形式重复出现。外层优先符合人手动归档的习惯:
|
||||
外层目录是系列,子目录是卷。
|
||||
|
||||
### 章节流式读取
|
||||
|
||||
EPUB 与漫画此前每次请求都整包解压(EPUB 走 JSZip)。一本 50MB 画册的每次翻章
|
||||
都会把整个压缩包读进内存,扫全库时更糟。现在两者都走项目自研的 `ZipArchive`:
|
||||
只读中央目录,按需解压单个条目,`openStream()` 让 stored 直接读、deflate 走
|
||||
流式 inflate。
|
||||
|
||||
`openBuffer()` 给扫描器用(它刚读完文件算哈希,不必再按路径打开一次)。
|
||||
内存态和文件态共用 `parseCentralDirectory` —— 两个路径对条目偏移的理解一旦
|
||||
分叉,表现是某一本书的页面从错误位置读起,只在那个用户那里复现。
|
||||
|
||||
### 各格式的取舍
|
||||
|
||||
**ZIP 自己实现**(`zip-reader.ts`,只用 `node:zlib`)。项目已有的 JSZip 会把整个
|
||||
@@ -249,6 +319,22 @@ epub.ts pdf.ts comic-archive.ts comic-directory.ts text.ts image.ts
|
||||
服务端在返回前把这些相对引用改写成指向自己的绝对地址,同时跳过绝对 URL、
|
||||
`data:` URI 和文内锚点 —— 改写脚注链接会把脚注弄坏。
|
||||
|
||||
## 解析器版本:修复怎么到达已入库的书
|
||||
|
||||
变更检测是内容哈希驱动的,这带来一个不明显的后果:**解析器的修复不会改变任何
|
||||
字节**,所以扫描恰好会跳过最需要重读的那些文件。坏结果永久留存 —— 这不是
|
||||
假设,EPUB 页数一度因为内存态 zip 的读取缺陷全是 `null`,而重跑扫描修不回来。
|
||||
|
||||
修法是给解析结果带上版本号:`book_files.parse_version` 记录是哪一版解析器写下
|
||||
的,`PARSE_VERSION` 提升时强制重解析一次。自部署用户不会按时升级,所以版本
|
||||
必须跟着数据行走,不能只活在进程里。
|
||||
|
||||
配套加了可重复执行的增量迁移。SQLite 不支持 `ADD COLUMN IF NOT EXISTS`,
|
||||
靠 `duplicate column name` 错误判断已执行过;旧库原地升级,不丢数据。
|
||||
|
||||
新增的 `lib/log.ts` 是同一件事的另一半:扫描器和解析器跑在定时器里,没有请求
|
||||
可以借 logger,于是失败是静默的 —— 静默正是上面那个 bug 得以存在的原因。
|
||||
|
||||
## 书籍主键的补充规则
|
||||
|
||||
`dc:identifier + 内容哈希` 对 EPUB 够用,但对没有内嵌标识的格式(txt、漫画)不安全:
|
||||
|
||||
Reference in new issue
Block a user