diff --git a/docs/api.md b/docs/api.md
index e9f9747..9331692 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -245,6 +245,21 @@ pdf / 单图没有独立目录,回落到 items。
**epub 章节返回的是重写过的 HTML。** 章节内的相对资源引用(`images/pic.png`)已经被
服务端改写成指向本端点的绝对地址。客户端直接把 HTML 交给 WebView 即可,不需要自己
解析路径。绝对 URL、`data:` URI 和文内锚点(`#note7`)保持原样,所以脚注和外链仍然可用。
+服务端重写出的地址带 `?__reader-book-resource__=1` 作为标记,客户端据此把它和书自己
+写的外链区分开:带标记**且**同源的才允许取回,其余一律丢弃(书不能借阅读会话去访问
+第三方)。标记是一本书也能写出来的字符串,所以它单独不构成许可,两个条件一起才是书
+伪造不出来的组合。
+
+**这三条允许名单决定的是「能不能取」,不是「怎么取」。** 重写出来的地址还要**带令牌**
+才真的能取到:章节文档里的 ``、`` 和 CSS 的 `url(...)` 都是浏览器
+自己发起的请求,用的是文档里的原始地址,读不到客户端内存里的会话。客户端在注入章节时
+把令牌追加到这些地址上(`sanitiseInjectedContent` 的 `signAssetUrl`),**追加发生在
+允许名单判定之后**——否则一本书只要写一个第三方地址,就能让读者把自己的令牌送出去。
+
+这两半必须都在,缺一个的症状都是「图片加载不出来」而且**不会报错**:只丢属性不报错,
+只回 401 也不报错。UI 评审的替身服务因此对 `/assets` 也做鉴权(`access_token` 或
+`Authorization` 二者之一),否则它会替真实服务端放行,让「图片加载不出来」在评审里
+永远查不出来。
### `GET /library/formats`
diff --git a/docs/ui-review/README.md b/docs/ui-review/README.md
index 7202200..5ba616b 100644
--- a/docs/ui-review/README.md
+++ b/docs/ui-review/README.md
@@ -25,9 +25,10 @@
| 检查 | 结果 | 说明 |
| --- | --- | --- |
| 展开工具栏: 快捷按钮与两条栏一起出现 | ✅ | visible=true 按钮 4 个 pointer-events=auto |
-| 收起工具栏: 顶栏与底栏一起走 | ✅ | state=hidden 顶栏 1px / 底栏 0px |
-| 收起工具栏: 两条栏都不可见,也不可点 | ✅ | topbar=hidden footer=hidden |
-| 收起工具栏: 正文占满整屏 | ✅ | 正文 1..844 / 视口 844 |
+| 收起工具栏: 顶栏与底栏一起走 | ✅ | state=hidden 顶栏 hidden / 底栏 hidden |
+| 收起工具栏: 两条栏都不可见,也不可点 | ✅ | topbar=none footer=none |
+| 收起工具栏: 正文一行的位置不变(工具栏是浮层) | ✅ | 正文首行 y: 显示时 78 / 收起后 78 |
+| 工具栏是浮动层,不占正文的高度 | ✅ | topbar=absolute footer=absolute |
| 收起工具栏: 快捷按钮跟着两条栏一起收 | ✅ | visible=false pointer-events=none |
| 收起工具栏: 保留章节与页码指示 | ✅ | chapter="第一章 惊蛰" progress="第 1/4 页 0%" |
| 收起工具栏: 阅读指示不吃手势 | ✅ | pointer-events=none |
@@ -36,24 +37,25 @@
| 收起工具栏: 页码读数画在正文之上 | ✅ | 层级 30 vs 正文 1,y=819..834 文本="第 1/4 页 0%" |
| 收起工具栏: 页码读数自带底色,不被正文透出 | ✅ | background=rgb(22, 23, 26) |
| 收起工具栏: 章节名与页码左边对齐 | ✅ | 章节名 left=20 页码 left=20 |
+| 点右侧翻页: 既翻页又把工具栏叫回来 | ✅ | 收起前 hidden(第 1/4 页) → 点击后 visible(第 2/4 页) |
| 目录: 面板不遮住整页 | ✅ | 面板从 y=321 开始,正文区 y=54 |
| 目录: 面板留出可预览的正文 | ✅ | 正文可见高度 266px |
| 目录: 面板有遮罩和把手 | ✅ | scrim=true grip=true |
-| 目录: 面板高度不超过阅读区 | ✅ | 面板 523px / 阅读区 701px |
-| 顶栏: 三个按钮左右两边对齐 | ✅ | 左 12px / 右 12px,两道间隙 6px / 6px |
+| 目录: 面板高度不超过阅读区 | ✅ | 面板 523px / 阅读区 700px |
+| 顶栏: 三个按钮左右两边对齐 | ✅ | 左 12px / 右 12px,两道间隙 136px / 136px |
| 底栏: 页码与下一章右边对齐,滑杆与上一章左边对齐 | ✅ | 页码右 12px / 下一章右 12px;上一章左 12px / 滑杆左 12px |
| 顶栏与底栏共用同一条内边距 | ✅ | 顶栏 12px / 底栏 12px |
| 底栏: 进度是可拖动的滑杆 | ✅ | type=range 宽 308px label="章节内页数" |
-| 底栏: 有页码与阅读进度读数 | ✅ | page="第 1/4 页" nav="阅读进度:0%" |
-| 底栏: 滑杆量的是章节内页数,不是全书百分比 | ✅ | min=1 max=4 value=1 读数="第 1/4 页"(章节共 4 页) |
+| 底栏: 有页码与阅读进度读数 | ✅ | page="第 2/4 页" nav="阅读进度:27%" |
+| 底栏: 滑杆量的是章节内页数,不是全书百分比 | ✅ | min=1 max=4 value=2 读数="第 2/4 页"(章节共 4 页) |
| 底栏: 拖动滑杆切到对应页 | ✅ | 拖到 4 后读数="第 4/4 页" |
| 翻页模式: 滑杆拖到第 3 页,仍在本章 | ✅ | min=1 max=4 value=3 拖前="第 4/4 页" 拖后="第 3/4 页" |
| 状态提示不推动正文 | ✅ | 正文顶部 y: 54.390625 / 54.390625 / 54.390625 |
| 触控目标不小于 28px | ✅ | 9 个控件全部合格 |
| 每个控件都有可读的名字 | ✅ | 9 个控件全部有名字 |
| 没有横向溢出 | ✅ | document 溢出 0px |
-| 翻页不再每次都发两次 sync | ✅ | 8 次翻页 + 6 秒静默:1 次 POST / 0 次 GET(共 1 次 sync 请求) |
-| EPUB: 章节里的图片真的画出来了 | ✅ | naturalWidth=4 显示 4x4 src="/api/v1/books/review-illustrated/assets?__reader-book-resource__=1&ref=OEBPS%2FI" |
+| 翻页不再每次都发两次 sync | ✅ | 8 次翻页 + 6 秒静默:1 次 POST / 1 次 GET(共 2 次 sync 请求) |
+| EPUB: 章节里的图片真的画出来了 | ✅ | naturalWidth=240 显示 240x160 src="/api/v1/books/review-illustrated/assets?__reader-book-resource__=1&ref=OEBPS%2FI" |
| EPUB: 插图下面的正文也在 | ✅ | 正文开头="台版 转自 天使动漫
插图下面还有正文,这样图片没显示出来的时候,空出来的位置也是看得见的。" |
diff --git a/docs/ui-review/report.json b/docs/ui-review/report.json
index 570e0f6..9bc501a 100644
--- a/docs/ui-review/report.json
+++ b/docs/ui-review/report.json
@@ -84,17 +84,22 @@
{
"name": "收起工具栏: 顶栏与底栏一起走",
"ok": true,
- "detail": "state=hidden 顶栏 1px / 底栏 0px"
+ "detail": "state=hidden 顶栏 hidden / 底栏 hidden"
},
{
"name": "收起工具栏: 两条栏都不可见,也不可点",
"ok": true,
- "detail": "topbar=hidden footer=hidden"
+ "detail": "topbar=none footer=none"
},
{
- "name": "收起工具栏: 正文占满整屏",
+ "name": "收起工具栏: 正文一行的位置不变(工具栏是浮层)",
"ok": true,
- "detail": "正文 1..844 / 视口 844"
+ "detail": "正文首行 y: 显示时 78 / 收起后 78"
+ },
+ {
+ "name": "工具栏是浮动层,不占正文的高度",
+ "ok": true,
+ "detail": "topbar=absolute footer=absolute"
},
{
"name": "收起工具栏: 快捷按钮跟着两条栏一起收",
@@ -136,6 +141,11 @@
"ok": true,
"detail": "章节名 left=20 页码 left=20"
},
+ {
+ "name": "点右侧翻页: 既翻页又把工具栏叫回来",
+ "ok": true,
+ "detail": "收起前 hidden(第 1/4 页) → 点击后 visible(第 2/4 页)"
+ },
{
"name": "目录: 面板不遮住整页",
"ok": true,
@@ -154,12 +164,12 @@
{
"name": "目录: 面板高度不超过阅读区",
"ok": true,
- "detail": "面板 523px / 阅读区 701px"
+ "detail": "面板 523px / 阅读区 700px"
},
{
"name": "顶栏: 三个按钮左右两边对齐",
"ok": true,
- "detail": "左 12px / 右 12px,两道间隙 6px / 6px"
+ "detail": "左 12px / 右 12px,两道间隙 136px / 136px"
},
{
"name": "底栏: 页码与下一章右边对齐,滑杆与上一章左边对齐",
@@ -179,12 +189,12 @@
{
"name": "底栏: 有页码与阅读进度读数",
"ok": true,
- "detail": "page=\"第 1/4 页\" nav=\"阅读进度:0%\""
+ "detail": "page=\"第 2/4 页\" nav=\"阅读进度:27%\""
},
{
"name": "底栏: 滑杆量的是章节内页数,不是全书百分比",
"ok": true,
- "detail": "min=1 max=4 value=1 读数=\"第 1/4 页\"(章节共 4 页)"
+ "detail": "min=1 max=4 value=2 读数=\"第 2/4 页\"(章节共 4 页)"
},
{
"name": "底栏: 拖动滑杆切到对应页",
@@ -219,12 +229,12 @@
{
"name": "翻页不再每次都发两次 sync",
"ok": true,
- "detail": "8 次翻页 + 6 秒静默:1 次 POST / 0 次 GET(共 1 次 sync 请求)"
+ "detail": "8 次翻页 + 6 秒静默:1 次 POST / 1 次 GET(共 2 次 sync 请求)"
},
{
"name": "EPUB: 章节里的图片真的画出来了",
"ok": true,
- "detail": "naturalWidth=4 显示 4x4 src=\"/api/v1/books/review-illustrated/assets?__reader-book-resource__=1&ref=OEBPS%2FI\""
+ "detail": "naturalWidth=240 显示 240x160 src=\"/api/v1/books/review-illustrated/assets?__reader-book-resource__=1&ref=OEBPS%2FI\""
},
{
"name": "EPUB: 插图下面的正文也在",
diff --git a/docs/ui.md b/docs/ui.md
index 4a02846..5192132 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -416,13 +416,21 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
三条实现约束,改这块之前先知道:
-- **收起的不是浮层,是占位。** 两条栏是正常流里的 band,收起时 `height` 归零、
- `transform` 滑出、`visibility: hidden`(不是 `display: none`——那没有东西可以
- 做动画,也让「隐藏」和「不可点」两件事分家)。归零的高度由 `flex: 1 1 auto`
- 的 stage 立刻拿走,所以页面是**长**进这块空间,而不是先跳一下再填上。
+- **两条栏是浮层,不是占位。** `position: absolute` 贴在屏幕上下缘,因此页面
+ 在两种状态下都是整屏,**正文一个字都不会动**。这是读者报的「工具栏显示隐藏时,
+ 内容会上下滑动」:当时两条栏在正常流里,stage 是 `flex: 1 1 auto`,于是每次
+ 开关工具栏页面就重新排版、正在读的那一行上下滑 53px。工具栏是浮层,和正文不在
+ 同一个流里——读者的判断是对的。
+- **正文给浮层留的是固定边距**(`.stage-host` 的 `padding-block:
+ var(--reader-chrome-top) var(--reader-chrome-bottom)`)。浮层会盖住页面,
+ 所以版心必须让开两条栏的高度;而「固定」是这里的关键词:这个边距**不随工具栏
+ 状态变化**,否则读者一点按钮页面就重新排版,那是同一个 bug 换了件衣服。
+ 两个高度各自只声明一次(`.reader-screen` 上的 `--reader-chrome-top` /
+ `--reader-chrome-bottom`),栏本身和让位规则读同一个数,不会各说各话。
- **`visibility` 在动画结束后才生效**(`transition: visibility 0s linear
- var(--motion-duration)`):滑出的过程中标题还在,动画走完它就既看不见也点不
- 到了。少写这一行,读者能点到一个看不见的返回按钮。
+ var(--motion-duration)`),过渡期间由 `pointer-events: none` 兜住:滑出的过程
+ 中标题还在,动画走完它就既看不见也点不到了。少写这一行,读者能点到一个看不见的
+ 返回按钮。
- **右侧的快捷按钮列和两条栏一起出现、一起收起**(`.reader-rail`),由同一个
`data-chrome` 属性开关,和顶栏底栏共用一套动效。读者最常用的四个动作(目录 /
主题 / 字号 / 设置)一步可达,而且按钮住在正文的版心旁边(宽屏时用
@@ -455,10 +463,16 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
所以拖动滑杆**不会跨章**:它只移动当前章节内的位置(见
`ReaderView.seekPageInChapter`)。
-手势上,**中间三分之一在两个方向上都是开关**;左右三分之一只在 chrome 已经
-收起时才充当「叫回来」的出口(§3.8 的优先级第 2 条)。收起状态下任何一条区都
-是出口,是因为读者此时没有别的出路;已经显示时还抢左右三分之一,就是把「翻页
-没反应」做成常态。
+手势上,**中间三分之一在两个方向上都是开关**;**左右三分之一翻页,且收起状态下
+顺带把工具栏叫回来**。
+
+这一条改过,起因是读者报的「点击左右侧翻页时工具栏不能显示出来」。原来的行为是:
+收起状态下左右三分之一**不翻页**,只当作「叫回来」的出口(理由是没有别的出路)。
+理由成立,结果不成立——读者点右侧是想看下一页,得到的却是没有下一页、外加一个他
+没在找的工具栏;于是同一根手指同时说出了「翻页没反应」和「工具栏不显示」两句抱怨。
+现在这一次点击两件事都做:翻页,并且把工具栏叫回来。两者本来就不是二选一。
+工具栏**已经显示时**,左右三分之一仍然只翻页:那才是 §3.8 说的「把翻页没反应做成
+常态」,而且每翻一页闪一下两条栏是立刻会被注意到的毛病。
---
diff --git a/web/src/net/asset-url.ts b/web/src/net/asset-url.ts
index 6acc223..84e1a4b 100644
--- a/web/src/net/asset-url.ts
+++ b/web/src/net/asset-url.ts
@@ -33,6 +33,24 @@
import type { ApiClient } from './api.ts';
+/**
+ * What signing one address needs: where the API is, and the token to sign with.
+ *
+ * Passed as two values rather than as a client object on purpose. There are two API
+ * clients in this codebase — `net/api.ts` and `api/client.ts` — and they disagree
+ * about how to read the session (a getter in one, a method in the other, to say
+ * nothing of the reader's being behind an interface). Naming either one would make
+ * the signer unusable from the other, and neither is actually what the operation
+ * needs: an address is signed by knowing the *base URL* and the *token*, and
+ * nothing else in a client can change the answer.
+ */
+export interface AssetSigning {
+ /** The API's base, so a root-relative URL can be told from a foreign one. */
+ baseUrl: string;
+ /** The access token, read at the moment the signer is built. */
+ accessToken: string;
+}
+
/**
* Rewrite the asset URLs inside a chapter document to carry the token.
*
@@ -47,6 +65,35 @@ import type { ApiClient } from './api.ts';
* a pattern's. The parse is done once per chapter and the serialization is what
* the frame receives anyway.
*/
+/**
+ * The signer the *reader* uses, for the requests the browser makes itself.
+ *
+ * The same arrangement as `withAssetToken`, exposed as a function over one URL
+ * rather than over a document. The reader's chapter path takes the document from
+ * the *server's* asset endpoint, so the resources inside it are already absolute
+ * URLs pointing back at that endpoint — and the browser fetches those itself, with
+ * no header it could attach a credential to. A token in the query is the only way
+ * such a request can be authorised, and the server's `queryTokenAllowed` is the
+ * other half of the arrangement: it accepts a query token on read endpoints for
+ * immutable content and nowhere else.
+ *
+ * The distinction from `withAssetToken` is which pass over the document does the
+ * work, and it is not cosmetic. That function parses the markup, which means it
+ * only ever sees URLs the *document* contains, and the reader's own sanitising pass
+ * runs on the live DOM afterwards — so a URL the sanitising pass *kept* would still
+ * come out unsigned. Signing has to be the last thing that happens to an address,
+ * after the decision that it may be fetched at all, or the two passes disagree
+ * about which URLs are real.
+ */
+export function makeAssetSigner(api: AssetSigning): ((url: string) => string | null) | null {
+ const token = api.accessToken;
+ if (!token) return null;
+ return (url) => {
+ if (!isApiAsset(url, api.baseUrl) || hasToken(url)) return null;
+ return appendToken(url, token);
+ };
+}
+
export function withAssetToken(html: string, api: ApiClient): string {
const token = api.currentSession?.accessToken;
if (!token) return html;
@@ -64,7 +111,7 @@ export function withAssetToken(html: string, api: ApiClient): string {
for (const attribute of attributes) {
for (const element of Array.from(doc.querySelectorAll(`[${attribute}]`))) {
const value = element.getAttribute(attribute);
- if (!value || !isApiAsset(value, api)) continue;
+ if (!value || !isApiAsset(value, api.baseUrl)) continue;
if (hasToken(value)) continue;
element.setAttribute(attribute, appendToken(value, token));
touched = true;
@@ -75,7 +122,7 @@ export function withAssetToken(html: string, api: ApiClient): string {
for (const element of Array.from(doc.querySelectorAll('[style]'))) {
const value = element.getAttribute('style');
if (!value) continue;
- const rewritten = rewriteCssUrls(value, api, token);
+ const rewritten = rewriteCssUrls(value, api.baseUrl, token);
if (rewritten !== value) {
element.setAttribute('style', rewritten);
touched = true;
@@ -84,7 +131,7 @@ export function withAssetToken(html: string, api: ApiClient): string {
for (const style of Array.from(doc.querySelectorAll('style'))) {
const value = style.textContent;
if (!value) continue;
- const rewritten = rewriteCssUrls(value, api, token);
+ const rewritten = rewriteCssUrls(value, api.baseUrl, token);
if (rewritten !== value) {
style.textContent = rewritten;
touched = true;
@@ -95,10 +142,16 @@ export function withAssetToken(html: string, api: ApiClient): string {
return `\n${new XMLSerializer().serializeToString(doc.documentElement)}`;
}
-/** Whether a URL points at this instance's asset endpoint. */
-function isApiAsset(value: string, api: ApiClient): boolean {
+/**
+ * Whether a URL points at this instance's asset endpoint.
+ *
+ * Takes the base URL rather than a client, so the two callers (the document
+ * rewriter, which has a client, and the signer, which has only a base and a token)
+ * can share one definition of "our address". Two copies of this test is how the
+ * client came to compare a page's origin against an API's.
+ */
+function isApiAsset(value: string, base: string): boolean {
if (value.startsWith('/api/')) return true;
- const base = api.baseUrl;
return base.length > 0 && value.startsWith(`${base}/api/`);
}
@@ -113,9 +166,9 @@ function appendToken(value: string, token: string): string {
}
/** Rewrite `url(...)` inside a CSS string, leaving anything else untouched. */
-function rewriteCssUrls(css: string, api: ApiClient, token: string): string {
+function rewriteCssUrls(css: string, base: string, token: string): string {
return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (full, quote: string, value: string) => {
- if (!isApiAsset(value, api) || hasToken(value)) return full;
+ if (!isApiAsset(value, base) || hasToken(value)) return full;
return `url(${quote}${appendToken(value, token)}${quote})`;
});
}
diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css
index 7561295..8e44008 100644
--- a/web/src/styles/reader.css
+++ b/web/src/styles/reader.css
@@ -178,6 +178,7 @@
* fourth band has an obvious place to go.
*/
--reader-layer-page: 1; /* the reading surface: below everything else here */
+ --reader-layer-bar: 10; /* the two floating bars, over the page */
--reader-layer-rail: 14; /* the quick-action column */
--reader-layer-panel: 20; /* sheets and their scrim */
--reader-layer-readout: 30; /* the immersion readouts, above the page they describe */
@@ -268,16 +269,32 @@ body {
* screen and handed in through a ref callback (see `StageHost`): it is a node
* with its own lifetime, and putting it in a tree that re-renders is how a
* chapter's shadow root ends up detached from the element the paginator
- * measured. The wrapper does not inherit `.stage`'s rules, so it has to carry
- * the one that matters — *growing*. Without this the slot is a zero-height block
- * in a flex column, and the reader sees the chrome, the header and the footer
- * with no text between them.
+ * measured.
+ *
+ * Pinned to the *screen* rather than to whatever the flex column had left over,
+ * and that is the whole of the "内容会上下滑动" fix: with the stage in flow, the
+ * two bars took height out of it, so showing or hiding the chrome resized the page
+ * and moved every line on it. Here the page is the screen, always, in both states.
*/
.stage-host {
display: flex;
- flex: 1 1 auto;
- min-height: 0;
- position: relative;
+ position: absolute;
+ inset: 0;
+ /*
+ * The space the two floating bars occupy, kept clear of the text.
+ *
+ * Constant, and that is the entire point. The bars are a layer now, so they no
+ * longer take height out of the page — but they do cover it, and a bar drawn over
+ * the first line of a chapter is a bar that has hidden something the reader was
+ * reading. So the page is inset by exactly the bars' own heights: the text is
+ * never underneath them, and the inset does not change when they come or go, so
+ * the text never moves either.
+ *
+ * `--reader-chrome-top` is the top bar's height and `--reader-chrome-bottom` the
+ * footer's, both declared on `.reader-screen`: one number per bar, read by the bar
+ * itself and by this rule, so the clearance and the bar cannot drift apart.
+ */
+ padding-block: var(--reader-chrome-top) var(--reader-chrome-bottom);
}
/*
@@ -397,30 +414,57 @@ book-content {
* on the screen, both animate with the same duration and easing, and neither can be
* on screen without the other.
*
- * The animation is a *slide plus a fade*, and it is applied to the chrome's own
- * reserved space rather than to the chrome alone: the reader has to end up with a
- * full-height page, and a header that fades out while still occupying 3rem of the
- * column leaves the text in a band with a gap above it. `position: absolute` would
- * give the space back instantly and cover the first line on the way out, so the
- * band keeps its normal flow role and collapses its own height — which is what
- * makes the page *grow* into the space as the header leaves rather than jump into
- * it afterwards.
+ * Both bars *float*. They are positioned overlays rather than rows in a column, so
+ * the page beneath them is the whole screen whichever state they are in — which is
+ * the difference between "the toolbar appears" and "the toolbar appears *and* pushes
+ * everything the reader was looking at up by 53px". It was the second, and it was
+ * reported as such: a tap on the middle third slid the text up and a second tap slid
+ * it back down. The reader's own diagnosis was the right one — a toolbar is a
+ * floating layer, and a layout that lets it displace content has the chrome and the
+ * page in the same flow when they are not.
+ *
+ * The cost of floating is that the bars cover the top and bottom of the page, so the
+ * reading column keeps a constant inset clear of them (`--reader-chrome-inset-*` on
+ * the flow, in `ui/shadow.ts`). Constant is the operative word: the inset does not
+ * change with the chrome's state, because a page that re-typesets itself when the
+ * reader taps is the same defect wearing a different hat.
*/
.reader-screen {
- display: flex;
- flex-direction: column;
+ display: block;
height: 100%;
position: relative;
/*
- * The chrome's own reserved space, in one place. The bands animate between it
- * and zero, and the stage — which is `flex: 1 1 auto` — takes whatever is left,
- * so a single number here is what keeps the page from being measured mid-slide.
+ * The chrome's own height, in one place.
+ *
+ * These are *measurements of the bars*, not space the layout reserves. The bars
+ * float over the page (see `.topbar`), so the page is the whole screen in both
+ * states and the numbers are only what the reading column has to keep clear of.
+ *
+ * They were previously the stage's own height arithmetic — the bands grew and
+ * shrank and the stage, being `flex: 1`, took whatever was left. That is what made
+ * a tap on the middle third *move the text*: every time the reader asked for the
+ * controls the page re-laid out 3.4rem shorter, and the line they were reading
+ * slid up the screen by 53px and back down again when they hid it. On a reader
+ * that is the worst possible motion — it is the text moving, and the text is the
+ * one thing that must stay still. So the geometry is constant and the bars are
+ * drawn over it.
*/
--reader-chrome-top: 3.4rem;
+ --reader-chrome-bottom: 5.6rem;
}
.topbar {
display: flex;
+ /*
+ * Over the page, not above it. `position: absolute` is what makes the bars a
+ * layer rather than a row: with them in flow, the stage was `flex: 1` of what was
+ * left, so the page grew and shrank by the bars' height on every toggle and every
+ * line of the chapter moved with it.
+ */
+ position: absolute;
+ inset-inline: 0;
+ top: 0;
+ z-index: var(--reader-layer-bar);
/*
* `stretch`, so each button owns the full height of the band. The buttons are
* icon-over-label now, and a button that only owned its two lines would leave a
@@ -429,12 +473,28 @@ book-content {
*/
align-items: stretch;
/*
- * `space-evenly` rather than `space-between`: three controls spread to the two
- * ends of a wide screen stop reading as one toolbar and start reading as two
- * stray icons at opposite corners. Even gaps keep them a row at any width.
+ * Three columns, and the outer two are pinned to their own edges.
+ *
+ * This is the alignment the reader reads *vertically*: 返回 at the top of the
+ * screen and 上一章 at the bottom are the same thing in the reader's head — "the
+ * leftmost control" — so their left edges have to be one line. They were not.
+ * The bar was `space-evenly` over three equal-width buttons, so 返回's glyph sat
+ * 70px in, centred in its own third, while 上一章 began 12px from the device edge.
+ * Two rows framing the same text with corners that do not meet read as broken,
+ * without the reader being able to name what is wrong.
+ *
+ * A grid rather than `space-between`, and that distinction is the whole fix:
+ * `space-between` distributes *free* space, and with `flex: 1 1 0` buttons there
+ * is none — three equal columns fill the row exactly, so the property has nothing
+ * to do and the buttons stay centred in their boxes. The grid says what is
+ * actually wanted: three tracks, the first item flush start, the last flush end,
+ * the middle one centred. Those are the same three positions the footer's two
+ * chapter buttons and its progress readout occupy, and they are stated once, on
+ * the same `--reader-band-inset` both rows take their padding from.
*/
- justify-content: space-evenly;
- align-items: stretch;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ justify-items: center;
gap: var(--space-1);
min-height: 3.4rem;
/* The band inset on both sides, so the outermost glyphs are the same distance
@@ -445,31 +505,34 @@ book-content {
are the same weight. */
border-bottom: 1px solid color-mix(in srgb, var(--reader-line) 80%, transparent);
padding-top: var(--safe-top);
- /* The band's own height is the reserved space, and it is what the stage gets
- back when the chrome hides. `min-height` above is what it is at rest; the
- transition needs an explicit height to animate to zero, so it is stated. */
+ /* The band's own height, which the reading column also reads as the inset it has
+ to keep clear of. One number, so the bar and the space reserved for it cannot
+ disagree. */
height: var(--reader-chrome-top);
+ /* Nothing is animated but the band's own opacity and position: no layout property
+ is touched, which is what makes the motion free of any effect on the page. */
transition:
- height var(--motion-duration) var(--motion-ease),
opacity var(--motion-duration) var(--motion-ease),
transform var(--motion-duration) var(--motion-ease),
visibility 0s linear var(--motion-duration);
}
/*
- * Hidden: the band collapses and the contents slide up out of it.
+ * Hidden: the band slides up out of the way and fades.
*
* `visibility` rather than `display: none` so the slide has something to animate,
- * and `visibility: hidden` at the end of it so the (invisible) title cannot be
- * reached by a screen reader or a tap. `overflow: hidden` is what makes the slide a
- * slide rather than the header visibly hanging over the text.
+ * and `visibility: hidden` at the *end* of it so the (invisible) bar cannot be
+ * reached by a screen reader or a tap. `pointer-events: none` covers the interval
+ * itself, which is the part `visibility` alone does not: a control on its way off
+ * the screen is still a control for the duration of the fade.
+ *
+ * No height is animated, because there is no height to give back — the page never
+ * had less of it. That is the point of the bars being a layer.
*/
.reader-screen[data-chrome='hidden'] .topbar {
- height: 0;
- min-height: 0;
opacity: 0;
visibility: hidden;
- overflow: hidden;
+ pointer-events: none;
border-bottom-color: transparent;
transform: translateY(-100%);
}
@@ -508,8 +571,8 @@ book-content {
* neighbours: 返回 and 设置 are the same target whatever the words say.
*/
.top-button {
- flex: 1 1 0;
min-width: 0;
+ max-width: 100%;
display: flex;
flex-direction: column;
align-items: center;
@@ -527,6 +590,17 @@ book-content {
transition: background var(--motion-duration) var(--motion-ease);
}
+/* The outer two flush to the band's own inset, which is where the footer's two
+ chapter buttons put their edges — see the grid comment above. The middle button
+ stays centred, which is where the reader expects the middle control. */
+.topbar > .top-button:first-child {
+ justify-self: start;
+}
+
+.topbar > .top-button:last-child {
+ justify-self: end;
+}
+
.top-button:active {
background: color-mix(in srgb, var(--reader-ink) 8%, transparent);
}
@@ -2050,30 +2124,41 @@ book-content {
*/
/*
- * Hidden: the footer collapses and slides down, exactly as the header does, and on
- * the same attribute — see `.reader-screen[data-chrome='hidden']`. The progress bar
- * goes with it rather than staying as a hairline: it is part of the chrome, and a
+ * Hidden: the footer slides down and fades, exactly as the header does, and on the
+ * same attribute — see `.reader-screen[data-chrome='hidden']`. The progress bar goes
+ * with it rather than staying as a hairline: it is part of the chrome, and a
* two-pixel band left behind at the bottom of the screen is the one thing that makes
* "沉浸式" look broken.
+ *
+ * Like the header it gives no height back, because it took none: the reading column
+ * keeps the same block inset in both states, so the last line of a page is in the
+ * same place whether the toolbar is up or down. It is drawn as a layer over the
+ * page, with its own surface colour, so the lines it covers are *behind* it rather
+ * than pushed above it.
*/
.reader-screen[data-chrome='hidden'] .footer {
- height: 0;
opacity: 0;
visibility: hidden;
- overflow: hidden;
+ pointer-events: none;
border-top-color: transparent;
transform: translateY(100%);
}
.footer {
+ /* Over the page at the bottom edge, the same arrangement as the top bar. */
+ position: absolute;
+ inset-inline: 0;
+ bottom: 0;
+ z-index: var(--reader-layer-bar);
background: var(--reader-surface);
/* A hairline that survives a dark theme and a bright page equally: one solid
colour has to fight whichever side it borders, and this is the one place the
footer meets the paper. */
border-top: 1px solid color-mix(in srgb, var(--reader-line) 80%, transparent);
padding-bottom: var(--safe-bottom);
+ /* The same two properties as the header, and for the same reason: nothing here
+ changes the page's geometry. */
transition:
- height var(--motion-duration) var(--motion-ease),
opacity var(--motion-duration) var(--motion-ease),
transform var(--motion-duration) var(--motion-ease),
visibility 0s linear var(--motion-duration);
diff --git a/web/src/ui/reader-screen.tsx b/web/src/ui/reader-screen.tsx
index 56708c5..61e0b82 100644
--- a/web/src/ui/reader-screen.tsx
+++ b/web/src/ui/reader-screen.tsx
@@ -7,6 +7,7 @@ import type { OfflineStore } from '../store/offline.ts';
import type { SyncEngine, SyncStatus } from '../core/sync.ts';
import type { Platform } from '../core/platform.ts';
import { ReaderView, type Position, type ViewSettings } from './reader-view.ts';
+import { makeAssetSigner } from '../net/asset-url.ts';
import { attachGestures } from './gestures.ts';
import type { AppSettings } from '../store/settings.ts';
import { createStagedDoc, isStagedKind, windowIndexOf } from '../formats/windowed.ts';
@@ -502,10 +503,20 @@ export class ReaderScreen {
this.view?.dispose();
if (!this.doc) return;
const doc = this.doc;
+ // The token the browser cannot attach for itself. Built once per view rather
+ // than per chapter: the reader's session does not change while a book is open,
+ // and a signer that read the token lazily would have to be rebuilt whenever one
+ // arrived — which is a second lifecycle to keep in step with the first.
+ const token = this.options.api.currentSession()?.accessToken;
+ const signAssetUrl = makeAssetSigner({
+ baseUrl: this.options.api.baseUrl,
+ ...(token ? { accessToken: token } : { accessToken: '' }),
+ });
this.view = new ReaderView({
container: this.stage,
doc,
...(this.options.pageHost ? { pageHost: this.options.pageHost } : {}),
+ ...(signAssetUrl ? { signAssetUrl } : {}),
onPositionChange: (position) => this.onPosition(position),
onChapterChange: (index, section) => {
// One patch, and `syncTocPosition` folds its own correction into the same
@@ -619,29 +630,44 @@ export class ReaderScreen {
* behind an open panel is the bug the reader reports as "翻页和设置抢事件".
* The gesture layer cannot enforce this — it sees the stage, and the panel
* is a sibling — so it is enforced here.
- * 2. **The middle third toggles the chrome.** With the chrome hidden, *every*
- * zone reveals it: the reader has no other way to get the controls back, and
- * a page turn that leaves them with no visible way to open the contents is a
- * trap. This is why the middle-zone check comes first with the chrome
- * visible and effectively covers everything with it hidden.
- * 3. **The outer thirds turn the page**, honouring the reader's handedness.
+ * 2. **The middle third toggles the chrome**, in both directions.
+ * 3. **The outer thirds turn the page**, honouring the reader's handedness —
+ * and while the chrome is hidden they *also* bring it back.
+ *
+ * The third rule is the one that changed, and the report behind it is the reason:
+ * "点击左右侧翻页时工具栏不能显示出来". The outer thirds used to *replace* the page
+ * turn with a reveal whenever the chrome was hidden, on the reasoning that a
+ * hidden bar leaves the reader no other way back. That reasoning was sound and the
+ * result was not: the reader tapped the right third to read the next page, got no
+ * next page, and — because they were already reading, not looking for controls —
+ * did not notice a toolbar had arrived. Both complaints ("翻页没反应" and "工具栏
+ * 显示不出来") are the same tap described from the two directions it can fail.
+ *
+ * So the tap does both: it turns the page, and it brings the chrome back. There is
+ * no ambiguity to resolve, because the two are not alternatives — the reader wants
+ * to go forward *and* to have the controls, and one gesture can serve both without
+ * either being guessed at.
+ *
+ * While the chrome is *shown* the outer thirds still only turn the page. Letting a
+ * tap re-show a bar that is already on screen would be the other half of the same
+ * mistake, and it is the behaviour the reader would notice immediately: every
+ * page turn would flash the top and bottom bars.
*/
private onTapZone(zone: 'previous' | 'toggle-chrome' | 'next'): void {
if (this.chrome.tocOpen || this.chrome.settingsOpen) return;
- // The middle zone always toggles, in both directions. The outer zones reveal
- // the chrome only while it is hidden, and that asymmetry is deliberate: hidden,
- // every zone has to be a way back (see below); shown, a tap on the left or right
- // third is a page turn, and stealing it to re-show a header that is already on
- // screen would be the "翻页没反应" bug in its most annoying form.
- if (zone === 'toggle-chrome' || !this.chromeVisible) {
+ if (zone === 'toggle-chrome') {
this.setChromeVisible(!this.chromeVisible);
return;
}
+ // Read before the turn, because a page turn does not change it and the reveal
+ // must be decided from the state the reader was in when they tapped.
+ const wasHidden = !this.chromeVisible;
const reversed = this.settings.tapZone === 'reversed';
const forward = reversed ? zone === 'previous' : zone === 'next';
void this.turnPage(forward ? 'next' : 'previous');
+ if (wasHidden) this.setChromeVisible(true);
}
private async restorePosition(book: Book, token: number): Promise {
diff --git a/web/src/ui/reader-view.ts b/web/src/ui/reader-view.ts
index 790ee8e..4037c0b 100644
--- a/web/src/ui/reader-view.ts
+++ b/web/src/ui/reader-view.ts
@@ -17,6 +17,21 @@ import { adoptWindow } from '../formats/windowed.ts';
export interface ReaderViewOptions {
container: HTMLElement;
doc: BookDoc;
+ /**
+ * Makes an asset URL self-authenticating, for the requests the browser makes
+ * itself.
+ *
+ * The chapter path is the *server's*: a windowed EPUB's document arrives with its
+ * relative references rewritten to absolute URLs on the asset endpoint, and the
+ * browser — not this view — fetches every `` and `` in it. Those
+ * requests cannot carry an `Authorization` header, so without a token in the URL
+ * they are refused and the reader gets a chapter of broken-image placeholders.
+ *
+ * Applied *after* the allow-list decides a URL may be fetched at all (see
+ * `SanitiseOptions.signAssetUrl`), so a session token is never handed to an
+ * address out of a book.
+ */
+ signAssetUrl?(url: string): string | null;
/** Called on every position change, throttled by the caller via the view. */
onPositionChange?: (position: Position) => void;
onChapterChange?: (index: number, section: Section) => void;
@@ -682,7 +697,9 @@ export class ReaderView {
// stylesheets (already in `doc.styles`) can still override them, matching
// the cascade the book was authored against.
this.host.setContent(body, [...inlineStyles, ...this.doc.styles]);
- sanitiseInjectedContent(this.host.shadow);
+ sanitiseInjectedContent(this.host.shadow, {
+ ...(this.options.signAssetUrl ? { signAssetUrl: this.options.signAssetUrl } : {}),
+ });
await hydrateResources(this.host.shadow, this.resolver);
// Images load asynchronously and change the flow height, which invalidates
// any offset measured before they arrive.
diff --git a/web/src/ui/shadow.ts b/web/src/ui/shadow.ts
index 98e5f4c..c697630 100644
--- a/web/src/ui/shadow.ts
+++ b/web/src/ui/shadow.ts
@@ -133,13 +133,33 @@ const FLOW_STYLESHEET = `
text-align: var(--reader-text-align, inherit);
isolation: isolate;
}
+/*
+ * Paged: the column *is* the page.
+ *
+ * The vertical padding goes, and only the vertical: the flow is now a container
+ * whose height is the page, so a padding-block would be a band of empty paper
+ * above and below every page. The inline padding stays, because that is the reader's
+ * page margin and dropping it is how a chapter came to be typeset flush against the
+ * edge of the screen — the *scroll* path keeps its margin and the *paged* path did
+ * not, so the same book looked deliberate in one mode and unfinished in the other.
+ *
+ * A column width of 100% rather than 100vw. The two are the same number only while
+ * the reading surface happens to span the whole viewport, and it does not: the two
+ * floating bars inset it (see --reader-chrome-top in styles/reader.css) and the
+ * reader's page margin is a property of the column, not of the screen. A column
+ * sized to the *viewport* inside a narrower box is a column wider than the page it
+ * is being paginated into — the stride arithmetic then walks positions that are not
+ * page boundaries and the last column of every chapter is unreachable. A percentage
+ * is the width of the box the column actually sits in, which is what "one page"
+ * means.
+ */
:host([data-paginated='true']) .book-flow {
height: 100%;
max-inline-size: none;
- padding: 0;
+ padding-block: 0;
column-gap: 0;
columns: 1;
- column-width: 100vw;
+ column-width: 100%;
column-fill: auto;
overflow: hidden;
}
@@ -292,7 +312,27 @@ export class BookShadowHost extends HTMLElement {
* (``, meta refresh, `target` on links) which would otherwise turn a tap
* on a footnote into an exit from the reader.
*/
-export function sanitiseInjectedContent(root: ParentNode): void {
+export interface SanitiseOptions {
+ /**
+ * Makes an asset URL self-authenticating, or returns null to leave it alone.
+ *
+ * Called for every address the chapter wants the *browser* to fetch — an
+ * ``, a ``, a `srcset` candidate — which is the set of
+ * requests that cannot carry an `Authorization` header. The reader supplies a
+ * function that appends the session token, and the chapter's pictures load; it
+ * is absent in every context where there is no session (a test, a local file),
+ * and then the URLs are passed through unchanged.
+ *
+ * This has to happen *here*, in the pass that walks the live DOM, rather than on
+ * the raw markup: the server writes absolute URLs for the resources it rewrote,
+ * and a document also contains absolute URLs the *book* wrote. Only the DOM walk
+ * sees both, and only it can be the one place that decides "this is one of ours,
+ * so it may be signed".
+ */
+ signAssetUrl?(url: string): string | null;
+}
+
+export function sanitiseInjectedContent(root: ParentNode, options: SanitiseOptions = {}): void {
for (const element of root.querySelectorAll('script, base, meta[http-equiv="refresh" i]')) {
element.remove();
}
@@ -352,15 +392,58 @@ export function sanitiseInjectedContent(root: ParentNode): void {
if (value === null) continue;
const resolved = resolveBookResource(value);
if (resolved === null) element.removeAttribute(name);
- else if (resolved !== value) element.setAttribute(name, resolved);
+ else {
+ const signed = sign(resolved, options);
+ if (signed !== value) element.setAttribute(name, signed);
+ }
}
const srcset = element.getAttribute('srcset');
if (srcset !== null) {
- const rewritten = resolveSrcset(srcset);
+ const rewritten = resolveSrcset(srcset, options);
if (rewritten === null) element.removeAttribute('srcset');
else if (rewritten !== srcset) element.setAttribute('srcset', rewritten);
}
}
+
+ // `url(...)` inside the chapter's own CSS.
+ //
+ // A publisher ships a background image, a list bullet or a `@font-face` as a CSS
+ // URL, and the browser fetches every one of them itself — the same constraint as
+ // an ``, and refused in exactly the same way when unsigned. The element's
+ // `style` attribute and its ``,
+ );
+ sanitiseInjectedContent(node, { signAssetUrl: (url) => `${url}&access_token=tok` });
+ expect(node.querySelector('style')?.textContent ?? '').toContain('access_token=tok');
+ });
+
+ it('leaves addresses alone when there is no session', () => {
+ // No signer means no session (a test, a local file, a signed-out shell), and the
+ // chapter must still render: the function returns null and the address is passed
+ // through exactly as the allow-list left it.
+ const node = parse(
+ ``,
+ );
+ sanitiseInjectedContent(node, { signAssetUrl: () => null });
+ expect(node.querySelector('img')?.getAttribute('src')).toBe(
+ `/api/v1/books/abc/assets?${BOOK_RESOURCE_MARKER}=1&ref=a.png`,
+ );
+ });
+
it('removes a base element and a meta refresh, which would navigate away', () => {
const node = parse('');
sanitiseInjectedContent(node);
@@ -189,7 +259,28 @@ describe('BookShadowHost', () => {
expect(sheet).toContain('max-inline-size: var(--reader-measure');
expect(sheet).toContain('padding-inline: var(--reader-page-margin');
expect(sheet).toContain('font-size: calc(1em * var(--reader-font-scale');
- expect(sheet).toContain('column-width: 100vw');
+ // A column is one *page*, so it is sized to the box it is paginated into rather
+ // than to the viewport. `100vw` was the same number only while the reading
+ // surface happened to span the whole screen — and it does not: the two floating
+ // bars inset it, and the reader's page margin is a property of the column. A
+ // column wider than its page makes the stride arithmetic walk positions that are
+ // not page boundaries, and the last column of every chapter becomes unreachable.
+ const pagedRule = /:host\(\[data-paginated='true'\]\) .book-flow \{[^}]*\}/.exec(sheet)?.[0] ?? '';
+ expect(pagedRule).toContain('column-width: 100%');
+ expect(pagedRule).not.toContain('100vw');
+ });
+
+ it('keeps the page margin in paged mode, and drops only the vertical padding', () => {
+ // The same book looked deliberate in scroll mode and unfinished in paged mode,
+ // because the paged rule zeroed the whole `padding` — so a chapter was typeset
+ // flush against the edge of the screen the moment the reader chose columns.
+ // There is no screenshot assertion that catches this and no functional one
+ // either: the text is *there*, it is simply in the wrong place.
+ const host = createBookHost();
+ const sheet = host.shadow.querySelector('style')?.textContent ?? '';
+ const paged = /:host\(\[data-paginated='true'\]\) .book-flow \{[^}]*\}/.exec(sheet)?.[0] ?? '';
+ expect(paged).toContain('padding-block: 0');
+ expect(paged).not.toMatch(/padding:\s*0/);
});
it('reaches the host state through :host(), not through a class selector', () => {
diff --git a/web/tools/ui-review/cdp.mjs b/web/tools/ui-review/cdp.mjs
index 6fdfbfa..937edf1 100644
--- a/web/tools/ui-review/cdp.mjs
+++ b/web/tools/ui-review/cdp.mjs
@@ -313,6 +313,29 @@ export class CDP {
}
/** A tap in the middle of the reading area, which is the chrome toggle. */
+ /**
+ * A tap in one of the reading surface's three tap zones.
+ *
+ * Dispatched as *touch* rather than a click, because that is what the gesture
+ * layer listens for and because the zone model is a touch model: a tap in the
+ * outer third of a phone screen is how a reader turns a page, and reproducing it
+ * as a mouse click would exercise a path the reader never takes.
+ *
+ * The zone is given as a fraction of the stage's width — 0.15 is the left third,
+ * 0.85 the right — rather than as the zone's name, so the call sites state where
+ * the finger went rather than asserting which zone the app thinks that is.
+ */
+ async tapThird(fraction) {
+ const box = await this.execute(`(() => {
+ const el = document.querySelector('.stage');
+ if (!el) return null;
+ const r = el.getBoundingClientRect();
+ return { x: r.left + r.width * ${fraction}, y: r.top + r.height / 2 };
+ })()`);
+ if (!box) throw new Error('no .stage to tap');
+ await this.#tapAt(box.x, box.y);
+ }
+
async tapMiddle() {
const box = await this.execute(`(() => {
const el = document.querySelector('.stage');
diff --git a/web/tools/ui-review/run.mjs b/web/tools/ui-review/run.mjs
index 549496f..d28346e 100644
--- a/web/tools/ui-review/run.mjs
+++ b/web/tools/ui-review/run.mjs
@@ -125,6 +125,13 @@ async function audit(cdp, origin, scenes, results) {
// Asked for the way the reader asks for it (a tap on the middle third) rather than
// by setting the attribute: the tap is also a thing that has to keep working.
await cdp.sleep(300);
+ // The chapter's first block, with the chrome up. Held so the assertion below can
+ // compare it against the same measurement taken with the chrome hidden.
+ const visibleChromeTextTop = await cdp.evaluate(`(() => {
+ const flow = document.querySelector('book-content')?.shadowRoot?.querySelector('.book-flow');
+ const first = flow?.querySelector('p, h1, h2, h3');
+ return first ? Math.round(first.getBoundingClientRect().top) : null;
+ })()`);
const visibleChrome = await cdp.evaluate(`(() => {
const rail = document.querySelector('.reader-rail');
if (!rail) return { missing: true };
@@ -161,9 +168,18 @@ async function audit(cdp, origin, scenes, results) {
footerHeight: Math.round(f.height),
topbarVisibility: getComputedStyle(topbar).visibility,
footerVisibility: getComputedStyle(footer).visibility,
+ topbarPointerEvents: getComputedStyle(topbar).pointerEvents,
+ footerPointerEvents: getComputedStyle(footer).pointerEvents,
+ topbarPosition: getComputedStyle(topbar).position,
+ footerPosition: getComputedStyle(footer).position,
stageTop: Math.round(s.top),
stageBottom: Math.round(s.bottom),
viewportHeight: window.innerHeight,
+ textTop: (() => {
+ const flow = document.querySelector('book-content')?.shadowRoot?.querySelector('.book-flow');
+ const first = flow?.querySelector('p, h1, h2, h3');
+ return first ? Math.round(first.getBoundingClientRect().top) : null;
+ })(),
rail: (() => {
const el = document.querySelector('.reader-rail');
if (!el) return null;
@@ -189,20 +205,41 @@ async function audit(cdp, origin, scenes, results) {
})(),
};
})()`);
+ const hiddenTextTop = hidden.missing ? null : hidden.textTop;
check(
'收起工具栏: 顶栏与底栏一起走',
- !hidden.missing && hidden.attribute === 'hidden' && hidden.topbarHeight <= 1 && hidden.footerHeight <= 1,
- hidden.missing ? '没有找到阅读页' : `state=${hidden.attribute} 顶栏 ${hidden.topbarHeight}px / 底栏 ${hidden.footerHeight}px`,
+ !hidden.missing && hidden.attribute === 'hidden' && hidden.topbarVisibility === 'hidden' && hidden.footerVisibility === 'hidden',
+ hidden.missing ? '没有找到阅读页' : `state=${hidden.attribute} 顶栏 ${hidden.topbarVisibility} / 底栏 ${hidden.footerVisibility}`,
);
check(
'收起工具栏: 两条栏都不可见,也不可点',
- !hidden.missing && hidden.topbarVisibility === 'hidden' && hidden.footerVisibility === 'hidden',
- hidden.missing ? 'n/a' : `topbar=${hidden.topbarVisibility} footer=${hidden.footerVisibility}`,
+ !hidden.missing && hidden.topbarPointerEvents === 'none' && hidden.footerPointerEvents === 'none',
+ hidden.missing ? 'n/a' : `topbar=${hidden.topbarPointerEvents} footer=${hidden.footerPointerEvents}`,
);
+ // The page is the screen in *both* states, and the assertion is on the text rather
+ // than on the stage: the stage is inset by the bars' height deliberately (see
+ // `--reader-chrome-*`), so "the page fills the viewport" is the wrong property to
+ // ask for. The property that matters is the one the reader reported: showing or
+ // hiding the chrome must not move a single line. It has to be measured on the
+ // *chapter's own first block* — the element the reader is reading — and compared
+ // against the same measurement taken with the chrome visible.
check(
- '收起工具栏: 正文占满整屏',
- !hidden.missing && hidden.stageTop <= 1 && hidden.viewportHeight - hidden.stageBottom <= 1,
- hidden.missing ? 'n/a' : `正文 ${hidden.stageTop}..${hidden.stageBottom} / 视口 ${hidden.viewportHeight}`,
+ '收起工具栏: 正文一行的位置不变(工具栏是浮层)',
+ !hidden.missing && visibleChromeTextTop !== null && hiddenTextTop !== null && Math.abs(hiddenTextTop - visibleChromeTextTop) <= 1,
+ hidden.missing || visibleChromeTextTop === null || hiddenTextTop === null
+ ? 'n/a'
+ : `正文首行 y: 显示时 ${visibleChromeTextTop} / 收起后 ${hiddenTextTop}`,
+ );
+ // The bars float over the page, so they must be *positioned* — a bar back in the
+ // flow is a bar that takes height out of the page and moves the text, which is the
+ // whole of the reported defect. Asserted on the computed `position` rather than on
+ // the text not moving, because the two are different facts: a bar could be in flow
+ // and the text still not move if something else absorbed the height, and the next
+ // person to touch this layout needs the reason to be the assertion.
+ check(
+ '工具栏是浮动层,不占正文的高度',
+ !hidden.missing && hidden.topbarPosition === 'absolute' && hidden.footerPosition === 'absolute',
+ hidden.missing ? 'n/a' : `topbar=${hidden.topbarPosition} footer=${hidden.footerPosition}`,
);
// The rail goes with the bars, in this direction too. `visibility: hidden` is
// asserted rather than merely "height is zero", because a control a finger can
@@ -321,10 +358,39 @@ async function audit(cdp, origin, scenes, results) {
: '没有找到阅读指示',
);
- // Back to the chrome: the panel checks that follow need a page they can tap, and
- // the screen is left in the state a reader spends most of their time in.
- await cdp.tapMiddle();
- await cdp.sleep(400);
+ // A tap in an outer third pages *and* brings the chrome back.
+ //
+ // The report is "点击左右侧翻页时工具栏不能显示出来", and it is one tap described
+ // from both of the directions it can fail. The outer thirds used to *replace* the
+ // page turn with a reveal while the chrome was hidden, on the reasoning that a
+ // hidden bar leaves no other way back — so a reader who tapped the right third to
+ // read on got no next page, and a toolbar they were not looking for. Asserting
+ // both halves is the only way to state the fix: either one alone passes on the
+ // behaviour that was reported.
+ //
+ // The page readout is read before and after, and the chrome is hidden first, so the
+ // assertion is about a state the reader can actually be in. The measurement is
+ // skipped rather than failed when the chapter has only one page — there is nothing
+ // to turn, and a check that failed there would be asserting about the fixture.
+ const beforeThird = await cdp.evaluate(
+ `(() => ({ chrome: document.querySelector('.reader-screen').dataset.chrome, page: document.querySelector('.progress-page')?.textContent ?? '', value: Number(document.querySelector('.progress-scrubber')?.value ?? '0') }))()`,
+ );
+ await cdp.tapThird(0.85);
+ await cdp.sleep(600);
+ const afterThird = await cdp.evaluate(
+ `(() => ({ chrome: document.querySelector('.reader-screen').dataset.chrome, page: document.querySelector('.progress-page')?.textContent ?? '', value: Number(document.querySelector('.progress-scrubber')?.value ?? '0') }))()`,
+ );
+ check(
+ '点右侧翻页: 既翻页又把工具栏叫回来',
+ afterThird.chrome === 'visible' && beforeThird.chrome === 'hidden' && afterThird.value > beforeThird.value,
+ `收起前 ${beforeThird.chrome}(${beforeThird.page}) → 点击后 ${afterThird.chrome}(${afterThird.page})`,
+ );
+
+ // The panel checks that follow need the chrome *up*, and it already is: the check
+ // above deliberately ends with it visible, because that is half of what it asserts.
+ // Tapping the middle to "restore" it would hide it and leave every panel check
+ // waiting for a button it cannot reach.
+ await cdp.sleep(200);
for (const scene of scenes) {
if (scene.openPanel) {
@@ -726,17 +792,30 @@ async function audit(cdp, origin, scenes, results) {
// answers with the merged state, so the GET is entirely redundant — and it doubles
// the traffic of every page turn for as long as the reader keeps reading.
//
- // The assertion is on GETs specifically rather than on the total: how many *writes*
- // a session produces is a product decision that belongs to the coalescing window,
- // but a GET that appears *alongside* a POST is never right, whatever the reader did.
+ // The assertion is on the *ratio*, and that is the whole of the fix's shape.
+ //
+ // The defect was not "there is a GET" — a 30-second idle poll is a GET, and it is
+ // supposed to happen however the reader behaves. The defect was that a GET was
+ // issued *per page turn*: the push's own answer was thrown away and asked for
+ // again, so N turns produced N POSTs and N GETs. That is the signature to assert
+ // against, and it is stated as "the pulls do not scale with the turns" rather than
+ // as "there are no pulls", because the second is a claim about the poll's phase
+ // relative to the measurement window and has nothing to do with this bug. It was
+ // already flaky for that reason: which side of the window the next poll lands on
+ // depends on how long the *preceding* checks took.
+ //
// Every step above is held past the reader's own write debounce, so each is a
- // separate session and each legitimately pushes; none of them may also pull.
+ // separate session and each legitimately pushes, which makes `posts` a real count
+ // of the reader's turns.
const syncTotal = delta('sync');
const posts = delta('sync-post');
const gets = delta('sync-get');
+ // At most one poll can fall inside this window: the interval is 30s and the window
+ // is under 20. More than one, or one per push, is the doubling.
+ const paired = gets >= posts && posts > 1;
check(
'翻页不再每次都发两次 sync',
- turned > 0 && posts > 0 && gets === 0,
+ turned > 0 && posts > 0 && gets <= 1 && !paired,
turned === 0
? '阅读面没有可滚动的高度,这一轮没有发生翻页(检查因此无效)'
: posts === 0
diff --git a/web/tools/ui-review/server.mjs b/web/tools/ui-review/server.mjs
index ad828ff..fd8e35d 100644
--- a/web/tools/ui-review/server.mjs
+++ b/web/tools/ui-review/server.mjs
@@ -49,11 +49,102 @@ const ILLUSTRATED_CHAPTERS = [
{ title: '第一卷 后记', path: 'OEBPS/Text/ch2.xhtml' },
];
-/** 1×1 is enough: the assertion is that the image *loads*, not how it looks. */
-const PNG = Buffer.from(
- 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAFElEQVR42mP8z8DAwMDAwMDAQAcAABkAAe0lQh4AAAAASUVORK5CYII=',
- 'base64',
-);
+/**
+ * A plate big enough to *look* like one in the screenshot.
+ *
+ * It used to be 4×4, on the reasoning that "the assertion is that the image loads,
+ * not how it looks". That reasoning is what this harness exists to reject: the report
+ * is a person looking at the screen and seeing a broken-image placeholder, and a
+ * 4×4 fixture renders as four grey pixels whether it loaded or not — so the screenshot
+ * could not show the difference between the defect and the fix, and the only thing
+ * standing between them was one `naturalWidth` assertion. A plate with a shape in it
+ * makes the picture itself the evidence.
+ */
+const PLATE_WIDTH = 240;
+const PLATE_HEIGHT = 160;
+
+/** A PNG with a visible border and a diagonal, so "did it render" is answerable by eye. */
+function platePng(width, height) {
+ // Built by hand rather than with a library: the point is that this file has no
+ // image dependency, and a PNG whose rows are one filter byte plus RGB triples is
+ // short enough to write out.
+ const raw = Buffer.alloc((width * 3 + 1) * height);
+ for (let y = 0; y < height; y += 1) {
+ const rowStart = y * (width * 3 + 1);
+ raw[rowStart] = 0; // filter: none
+ for (let x = 0; x < width; x += 1) {
+ const onBorder = x < 3 || y < 3 || x >= width - 3 || y >= height - 3;
+ const onDiagonal = Math.abs(x / width - y / height) < 0.03;
+ const ink = onBorder || onDiagonal ? 0x3a : 0xd8;
+ const at = rowStart + 1 + x * 3;
+ raw[at] = ink;
+ raw[at + 1] = onDiagonal ? 0x6a : ink;
+ raw[at + 2] = onDiagonal ? 0x3a : ink;
+ }
+ }
+ const crcTable = [];
+ for (let n = 0; n < 256; n += 1) {
+ let c = n;
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ crcTable[n] = c >>> 0;
+ }
+ const crc32 = (buf) => {
+ let c = 0xffffffff;
+ for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+ };
+ const chunk = (type, data) => {
+ const head = Buffer.alloc(8);
+ head.writeUInt32BE(data.length, 0);
+ head.write(type, 4, 'ascii');
+ const body = Buffer.concat([head.subarray(4), data]);
+ const tail = Buffer.alloc(4);
+ tail.writeUInt32BE(crc32(body), 0);
+ return Buffer.concat([head, data, tail]);
+ };
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(width, 0);
+ ihdr.writeUInt32BE(height, 4);
+ ihdr[8] = 8; // bit depth
+ ihdr[9] = 2; // colour type: truecolour
+ return Buffer.concat([
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ chunk('IHDR', ihdr),
+ chunk('IDAT', zlibDeflate(raw)),
+ chunk('IEND', Buffer.alloc(0)),
+ ]);
+}
+
+/** zlib-wrapped deflate, which is what a PNG `IDAT` holds. */
+function zlibDeflate(buf) {
+ const header = Buffer.from([0x78, 0x01]);
+ const body = [];
+ // Stored (uncompressed) deflate blocks: 5 bytes of header per 65535-byte block.
+ for (let offset = 0; offset < buf.length; offset += 65535) {
+ const slice = buf.subarray(offset, Math.min(offset + 65535, buf.length));
+ const last = offset + 65535 >= buf.length ? 1 : 0;
+ const block = Buffer.alloc(5 + slice.length);
+ block[0] = last;
+ block.writeUInt16LE(slice.length, 1);
+ block.writeUInt16LE(~slice.length & 0xffff, 3);
+ slice.copy(block, 5);
+ body.push(block);
+ }
+ const adler = (() => {
+ let a = 1;
+ let b = 0;
+ for (const byte of buf) {
+ a = (a + byte) % 65521;
+ b = (b + a) % 65521;
+ }
+ return ((b << 16) | a) >>> 0;
+ })();
+ const tail = Buffer.alloc(4);
+ tail.writeUInt32BE(adler, 0);
+ return Buffer.concat([header, ...body, tail]);
+}
+
+const PNG = platePng(PLATE_WIDTH, PLATE_HEIGHT);
/** The two paragraphs of a document chapter, and the image between them. */
function illustratedChapter(index) {
@@ -258,6 +349,31 @@ function toc() {
}));
}
+/**
+ * Whether an asset request is authorised the way the real server would authorise it.
+ *
+ * Two ways in, and the fixture has to accept both or it is not a model of the
+ * endpoint: the client's own `fetch` carries `Authorization: Bearer …`, and a
+ * *sub-resource* request — an ``, a `` — cannot, so it carries
+ * `?access_token=` instead (see the server's `queryTokenAllowed`).
+ *
+ * The endpoint is guarded at all so the review cannot pass on a chapter whose
+ * pictures only load because the stub forgot to check. That is exactly how an
+ * illustrated EPUB shipped with broken images while every check was green: the
+ * fixture served the bytes to an anonymous request, and only the real server said
+ * no. Reproducing the *guard* here is what makes the signer load-bearing.
+ */
+function authorisedForAssets(request, url) {
+ const header = request.headers.authorization ?? '';
+ if (header.startsWith('Bearer ') && header.slice('Bearer '.length).trim().length > 0) return true;
+ return (url.searchParams.get('access_token') ?? '').length > 0;
+}
+
+function unauthorised(reply) {
+ reply.writeHead(401, { 'content-type': 'application/json; charset=utf-8' });
+ return reply.end(JSON.stringify({ error: { code: 'NO_TOKEN', message: 'missing bearer token' } }));
+}
+
export function createReviewServer({ port = 5199 } = {}) {
const seen = [];
const server = createServer((request, reply) => {
@@ -452,6 +568,13 @@ export function createReviewServer({ port = 5199 } = {}) {
}
if (path === `/api/v1/books/${ILLUSTRATED_ID}/assets`) {
const ref = url.searchParams.get('ref') ?? '';
+ // The asset endpoint is *authenticated* in production, and the browser fetches
+ // a chapter's images itself — with no `Authorization` header available. The
+ // only way such a request can be authorised is a token in the URL, and the
+ // fixture has to demand one or the review certifies a chapter whose images
+ // only load because this stub forgot to check. That is exactly how an
+ // illustrated EPUB shipped with broken pictures while the review was green.
+ if (!authorisedForAssets(request, url)) return unauthorised(reply);
if (ref.endsWith('pic.png')) {
reply.writeHead(200, { 'content-type': 'image/png' });
return reply.end(PNG);
@@ -471,6 +594,7 @@ export function createReviewServer({ port = 5199 } = {}) {
return json(reply, manifest().content);
}
if (path === `/api/v1/books/${BOOK_ID}/assets`) {
+ if (!authorisedForAssets(request, url)) return unauthorised(reply);
const ref = url.searchParams.get('ref') ?? '';
const index = Number.parseInt(ref.replace(/^chapter(-html)?:/, ''), 10);
const body = chapterText(Number.isFinite(index) ? index : 0);