diff --git a/README.md b/README.md index a1d279d..6150826 100644 --- a/README.md +++ b/README.md @@ -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 即分页生效 +``` + ### 项目结构 ``` diff --git a/docs/api.md b/docs/api.md index 6708b63..2d10d60 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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` 是**不透明**的格式私有引用,客户端只应把它从 `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=`。** 章节里的 ``、`` 是浏览器 +自己发起的,没法带 `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 | 这本书没有封面 | diff --git a/docs/architecture.md b/docs/architecture.md index ee12c40..41dd585 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 / 漫画目录 / 单图) | `` 翻页器 | 原生 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` 影响不到脚注和引用。 + +**书必须是真文档。** 章节里的 `` 和 `` 是浏览器 +自己去请求的,没法带 `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=` — 单个资源,流式,支持 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、漫画)不安全: