hectorqin and Claude Opus 5.5
9bd3187179
perf(web): dragging a reader slider re-measures once per frame, brightness never
...
A slider sends one settings change per step. Each change measured the reading
position and queued its own restore, so a drag through the font-size range
re-measured a half-reflowed page many times a frame and could lose the place.
The position is now measured once before the first change of a frame and
restored once after the last; brightness, an overlay, skips both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-30 04:14:15 +00:00
hectorqin and Claude Opus 5.5
52b757bbd3
fix(web): clean web-page markup out of TXT chapters before typesetting
...
Novels saved from a site keep the page's <div>, <br/> and in the
TXT. Escaped, the tags were drawn as text and the chapter ran together
as one paragraph. cleanTextMarkup turns block tags into line breaks,
drops inline tags, decodes common entities and treats whitespace-only
lines as blank, before paragraphs and the chapter heading are inferred.
Only real tag names match; other markup is still escaped as text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com >
2026-10-29 05:51:18 +00:00
Kiro Agent
858a7479bf
feat(theme): V3.1 semantic tokens, four themes and a reading theme
...
Implements docs/prototypes/Reader V3 主题语义令牌.md (v3.1):
- 暖陶 (default), 雾蓝, 青绿 app themes plus the reading-only 黑白灰, each
light and dark. Components read --color-* tokens from
styles/theme-tokens.css, selected by data-theme + data-mode; the
TypeScript mirror lives in shared/theme/theme.ts.
- Brand is split per the spec: brand/ch-* only fill (with on-brand/on-ch on
top), brand-text/ch-*-text for text. --reader-accent/--media-accent are now
brand text; --reader-fill/--media-fill are brand fills. Selected segmented
controls use brand-soft + brand-text.
- One preference per server+account (reader.theme.v2): app theme, mode
(system/light/dark), the follow-system light/dark pair, and the reading
theme. V3 choices migrate per spec §8 (rose falls back to 暖陶 light).
- 外观与主题 rebuilt from prototype 23: follow-system switch with the
light/dark pair, app-theme cards with light/dark previews, and the
reading theme (跟随应用 / 黑白灰).
- Reader: 黑白灰 is set on the reader container only; the appearance panel
offers 跟随应用/黑白灰 and 明暗; day/night sets the mode; the e-ink preset
uses 黑白灰 light.
- Video and music players wear the current theme's dark values (§4.7).
- Removed the legacy light/sepia/green/dark palettes and the
<html data-theme> writes from reader settings, which would now collide
with data-theme="green".
- Tests: theme-tokens.test.ts keeps CSS and TS values identical and checks
the §5 contrast pairs for all 8 palettes; theme.test.tsx covers migration,
resolution, the controller and the settings page.
2026-10-20 04:05:35 +00:00
hectorqin
09a7d4df5d
fix(web): 书内目录链接当章节跳转,朗读条可停止,页面铺满且工具栏浮动
2026-09-19 23:04:03 +08:00
hectorqin
b8fda56c34
fix(web,server): 底部滑杆量章节内页数,快捷列随工具栏收放,chapter-full 改名
2026-09-19 08:17:46 +08:00
hectorqin
1c2d07ba4c
fix(web,server): 让服务端只交章节,并去掉成对的 sync 请求
...
服务端仍在做排版:`chapter-html:<n>` 自己断段、自己提升标题、自己吐
`<p>`。于是 reader 设置里的缩进和段间距只能作用于服务端已经渲染好的那一份,
流式那条路(`chapter:<n>`)拿不到段落,同一本书因为走哪条传输而看起来不一样。
客户端的 `retypePlainText` 于是把服务端断好的段落再拆一遍——同一个判断被两个
地方执行,两次的结果就会分歧。
`POST /api/v1/sync` 的响应本来就是合并后的状态(服务端在两个接口上返回同一份
`pull`),但客户端把它丢掉,然后又为同一份数据发一个 `GET`。读者每翻一页都发
一个 POST 加一个 GET,网络面板里成对出现。另外 `setState` 每次调用都广播,而
阅读页的状态监听会重绘整棵 chrome 树,一个同步循环通知三次。
现在服务端只把整章字符交出来(`text/plain`,`chapter-html:` 不截断),断段、
去段前空格、提升标题全部由 `web/src/formats/segments.ts` 一处决定;push 的返回
值直接喂给 `applyPulled`,空闲轮询才真的发 GET;`setState` 值未变就不广播。
「这一章是不是纯文本」改由 manifest 声明(item 的 `format: 'html'` →
`Section.plainText` → `isPlainText()`),不再靠 `.txt-body` 嗅探:服务端现在发
裸字符,嗅探对一章节全是纯文本的章节会答「不是」,读者会看到一本没有段落、
没有缩进、样式表也没挂上的小说。`.txt-body` 标记降级为旧服务端/旧缓存的兜底。
放弃的方案:保留服务端渲染、只在客户端覆盖样式。缩进和段间距是阅读偏好,服务端
渲染就等于把每次调整变成一次往返,而这个往返正是问题本身。也考虑过让
`chapter-html:` 直接返回服务端渲染的裸 `<p>`(不带 wrapper),但那仍然有两个
地方在判断「一段是什么」。
Ref: #34
2026-09-18 23:39:55 +08:00
hectorqin
7b9c786c83
feat(web): 纯文本排版改在客户端,并让顶栏底栏一起收放
...
现状问题。TXT 的段落和缩进原本由服务端决定:`chapter-html:<n>` 把章节渲染成
带 `<p>` 的片段,客户端只是把它塞进 shadow root。于是三件事同时不对。一是缩进
设定只对服务端已经渲染好的那份生效,windowed 路径拿到的 `chapter:<n>` 是纯字符
没有段落,同一个读者同一本书会因为走哪条传输而长得不一样。二是抓取来的文件里
用来当缩进的段前空格被原样留下,和读者的缩进叠加,恰好在最需要这个设定的文件
上看起来像没生效。三是这些设定是每台设备各自的阅读偏好,烧进响应就等于把每次
调整变成一次往返。另一件事是顶栏和底栏可以各自隐藏,读者收起导航后底栏仍压着
每一页的最后两行。
方案依据。把「一段是什么」收敛到客户端的唯一定义(`formats/segments.ts`),
本地加载、windowed、流式三条路都走它,服务端的职责缩小为交出完整章节和在原文
上断段。`ReaderView` 在渲染前对纯文本章节再过一遍这个拆分(按块读回、拼回空行
再拆,所以服务端断得对不对不影响观感),缩进、段间距、去掉段前空格因此无需请求
即可作用于两条路径。顶栏与前底栏由 `.reader-screen` 上的一个 `data-chrome` 统一
开关,收起时两条栏一起归零高度并滑出、正文长满整屏,同时右侧浮出一列快捷按钮,
否则收起工具栏会变成「必须离开才能操作」的状态。
放弃方案。把服务端的 `chapter-html` 直接删掉:`href` 同时是阅读位置的标识,且
`format: 'html'` 字段已是既有契约,删掉会让旧客户端与旧位置一起失效,所以保留
作为传输,只去掉它承担的排版职责。让缩进靠服务端属性(`data-indent`)继续驱动:
那正是「设定是响应的属性」这一问题的根源。收起态用两个独立开关:写法上就能只藏
一半,而这恰是读者报的那个 bug。
改动点。新增 `web/src/formats/segments.ts`(分段、去段前空格、HTML/节点两种形
式、跨 chunk 的 carry 判定);`reader-view.ts` 增加 `retypePlainText` 并去掉旧
排版表里读服务端属性的规则;`render/text.ts` 按 `carry` 拼接跨块段落;服务端
`text-html.ts` 去掉 `indent` 选项、不再截断;`reader-chrome.tsx` 增加侧边快捷列
与设置面板顶部的「快捷」一行;`reader.css` 增加两条栏的收放与按钮列样式;图标
集新增 `text-indent` / `text-type` / `spacing`;`docs/ui.md` 补 §3.7 的边界说明
与 §3.8 沉浸式阅读。
验证方式。`cd web && npm test`(369 条)、`cd server && npm test`(205 条)全绿;
`npm run build` 通过;`npm run ui:review` 全绿,并新增「收起工具栏: 顶栏与底栏一
起走 / 都不|不可见 / 正文占满整屏 / 右侧仍有快捷按钮」四条尺寸断言(截图见
`docs/ui-review/03-reader-no-chrome.png`),评审夹具改用全角空格缩进,使「段前
空格被去掉」在截图上可见。
Ref: #34
2026-09-18 19:09:51 +08:00
hectorqin
ecb2ef4806
fix(web): 修复阅读页章节跳转失灵,补齐上一章/下一章
2026-09-17 23:52:11 +08:00
cnb.dQC5J6ohAGA
75718e9fb7
merge: 合并 main 并解决阅读界面与分窗读取的冲突
2026-09-16 23:11:10 +08:00
cnb.dQC5J6ohAGA
058bee57a5
feat(web): 丰富阅读界面设置并支持系统 TTS 朗读
2026-09-16 22:55:58 +08:00
cnb.dQC5J6ohAGA
c18664f1e3
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: 契约双向校验 + 渲染模式词表校验通过
2026-09-16 19:51:38 +08:00
CodeBuddy
78bfe557c3
feat: 客户端 Android 与 H5 双端,含精排渲染层
...
按产品设计 §5「原生外壳 + WebView 渲染」落地客户端,Android 与浏览器共用同一份
渲染层;同时把服务端的格式范围从 epub + pdf 扩展到 txt 与漫画。
## 为什么两端共用一份渲染层
Android 端不重写渲染。它是一个 Kotlin 原生外壳,只负责文件缓存、手势、账号与书架,
渲染复用 web/ 构建出的同一份产物。原因很直接:排版策略一旦有第二份实现就一定会分叉,
而「忠于精排」正是这个产品的差异点。
共用的抽象在 web/src/core/platform.ts(传输、键值存储、二进制存储、连通性)。
外壳只覆盖 WebView 做不到或做不好的三件事:
- 连通性:WebView 里的 navigator.onLine 只要有网卡就报「在线」,连上没有出口的
Wi-Fi 时会骗人,客户端于是转圈而不是读缓存。改用平台的 NET_CAPABILITY_VALIDATED。
- 稳定的设备名:UA 里的型号会随 Chrome 升级变化,作为「上次在哪个设备读的」标签不可靠。
- 原生提示:一行 Kotlin 的 Toast,不用在 Web 层造通知 UI。
跨语言的接缝(Kotlin 桥接 ↔ TypeScript 声明)没法互相类型检查,因此加了
android/scripts/check-shell.sh 做双向契约校验,并接进 CI。
## 精排 CSS 策略
这是整个项目最产品化的一块 CSS,所以规则是写明的而不是凑出来的。
三条原则:不设任何属性除非不设会坏;每个可调项都是默认 inherit 的可继承自定义属性
(「关」的意思是「不动它」,不是「用我们的默认值」);任何改变作者意图的行为都是
用户主动开启且持久化。
实际 override 只有七处,每处都在注释里写了为什么:图片/表格/pre 在窄屏会撑破布局、
ruby 振假名在 16px 下按默认 50% 太小、长中文标题不换行会溢出。除此之外不动。
被刻意删掉的做法:reset 样式表、统一的「阅读主题」、给 p 加边距。它们能让排版
「看起来一致」,而一致性恰恰是这里要避免的。
书的样式装进 shadow root。书里一条 p { color: red } 不能改到书架,App 的 .topbar
也不能被书撞上。用 iframe 也能隔离,但每次换章要重建文档,会丢滚动位置并在 Android
上多花几倍内存。关键是**不往 shadow root 里注入任何 reset**。
## 格式层
服务端只负责流式给出原始文件,拆包、编码、章节与分页都是渲染层的事——
这些恰好是产品要自己拿主意的部分。
- EPUB:OPF/spine/nav 解析,作者样式全部保留,相对引用改写成内部 reader-res: 协议,
绝对 URL 原样保留交给 CSP 挡掉。加载器按文档逐个容错,一个坏章节不会毁掉整本书。
- TXT:先严格校验 UTF-8(自己实现,不用宽松模式——宽松模式永远「成功」,于是永远
发现不了解错了),失败再依次试 GB18030 与 Big5,并把选择告诉用户、允许覆盖。
章节只能从标题推断,策略是宁可少认不要错认:800 字的段落以「第三章」开头不算标题,
正文里提到「他在第二章读过」也不算。两条都有测试固定。
- CBZ / 图片目录:自然序排页(1,2,10 而不是 1,10,2),一页一个 fixed-layout section。
排错不是观感问题,是故事被静默打乱。
- 检测以魔数优先:本地库里 .txt 其实是 EPUB、.cbz 里其实是 PDF 的情况很多,
按扩展名开错加载器只会得到一个看不懂的报错。
服务端扫描器相应扩展:txt/cbz 直接索引;图片目录按文件夹识别为一本书,
文件夹路径作为主键锚点,所以**往漫画文件夹加一页不会丢进度**——这条有测试。
## 客户端三态与离线队列
离线队列记的是**脏集合**(哪些记录还没送到服务端),而不是「当前全部状态」。
两者只在一种情况下结果不同,而这种情况一定会发生:记录在请求发出之后、响应到达之前
被修改了。脏集合下这次更新仍是脏的,要等它自己的确认才清除;「全发」模式下它会被
那个更早的响应顺手清掉,然后永远送不到服务端——一个丢失的阅读位置,是这个产品
最不能有的失败。
阅读位置格式 r1:<偏移>:<章节 id>。三点都是踩过之后定的:版本前缀(服务端把 locator
当不透明字符串长期保存,客户端将来改格式必须能认出「这不是我认识的格式」)、
章节 id 放最后(它是文件路径,会含冒号,只从前两个冒号切才不会丢数据)、
偏移是章节内的比例而非像素或列号(列号会随字号变化,于是读者一调字号位置就跳)。
TXT、漫画、PDF 的支持也一并接上,PDF 用浏览器自带阅读器保底可读。
## 服务端
- GET /books/:id/file?path=… :按页取图。path 必须是这本书已登记的 rel_path,
服务端按表精确匹配,不用 query 拼路径,所以穿越只会匹配到空。
- CORS 白名单(CORS_ORIGINS,默认回显任意来源)+ H5 静态托管(WEB_DIR)。
托管在同一容器里,这才保住「一条命令跑起来」。
- Android 用 WebViewAssetLoader 从 https://appassets.androidplatform.net/ 提供
打包好的客户端,而不是 file://。后者发出的 fetch 是不透明的跨源请求,所有访问
服务端的调用都会被拦,而通常的绕法是把 web security 关掉——那等于把 WebView
变成一扇敞开的门。用资产加载器拿到的是正常安全源,请求按普通 CORS 走,
isSecureContext 为真,IndexedDB 等行为与浏览器一致。这一个选择是「一份 bundle
两个宿主」能成立的前提。
## 过程中发现并修掉的问题
- **ETag 里塞了中文路径**:HTTP 头值必须是 ISO-8859-1,漫画页码路径是中文时会抛
ERR_INVALID_CHAR,把一个正常的取图请求变成 500。改成对路径取散列。
- **漫画文件夹的主键用了页集合散列**:注释写的是「加一页不产生新书」,实现却是散列
页集合,加一页就换 id、进度全丢。改成用文件夹路径作为锚点,和书用 dc:identifier
作锚点是同一个道理。测试固定住了。
- **locator 解析与写出不对称**:写出是两段、解析却按三段切,导致位置根本存不回来。
章节 id 含冒号时更糟。
- **离线队列的清理条件**:原本按「清空全部」处理,会把请求在途时产生的新位置一并
抹掉。改成只清那一批、且只清时间戳吻合的条目。
- **测试自身的错误**:并发刷新去重的用例里,我一开始用 resolveRefresh?.() 调一个
类型已收窄成 never 的变量,断言等于什么都没测;「outbox 为空时不推」的用例里把
登录的 POST 也数了进去,断言恒不成立。两处都是测试写错了。
- **凭据用错了**:一个权限用例里我用 createAsAdmin 建账号却拿另一组凭据登录,
401 被当成「越权被拒」而通过。这也是测试写错了。
## 验证方式
```
cd server && npm run typecheck && npm test && npm run build # 37/37 通过
cd web && npm run typecheck && npm test && npm run build # 147/147 通过
sh android/scripts/check-shell.sh # 桥接契约双向校验通过
```
另用真实 HTTP 请求端到端验证过:注册 → 扫描 → 书架(四种格式全部可见)→ 漫画按页
取图(含中文路径 ETag)→ 列表页 → 进度同步 → 继续阅读;H5 静态托管、SPA 回落、
CORS 回显、路径穿越拒绝均按预期。
Android 壳本轮**没有真机编译验证**:环境里没有 JDK 和 Android SDK。Kotlin 侧做了
静态校验(包名与目录一致、项目内 import 可解析、桥接方法与 web 侧声明双向对齐),
并接进了 CI。第一次真机构建可能需要微调 Gradle 版本或 AGP 版本。
## 说明
- docker-compose.yml 与 Dockerfile 已改为从仓库根构建并打包 H5 产物;
build 上下文变了,如果之前用了 `build: ./server` 需要改成根目录。
- iOS 未做设计(按设计文档 §7,等有赞助再说)。
- 移动端批注输入仍未做(先保证读,后补写)。
Ref: #1
2026-09-16 14:49:11 +08:00