mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
fix(web): 插图收进版心,快捷列补齐并可滚动,点击区按布局几何
This commit is contained in:
11 files changed
+674
-35
No files matched your search
@@ -18,13 +18,18 @@
|
||||
| `11-library.png` | 书库 | 面包屑、行、「书籍」徽章、不在书架的书带「不在书架」标记,以及书架/书库之间的切换 |
|
||||
| `12-library-paged.png` | 书库 · 第二页 | 同一个分页器组件;当前页与首尾页码 |
|
||||
| `13-reader-paged-scrub.png` | 阅读页 · 滑杆拖到本章第 3 页 | 分栏模式下拖动底部滑杆:读数与画面都停在本章内,不换章 |
|
||||
| `14-reader-epub-image.png` | EPUB · 插图 | 章节文档里的图片真的画出来了,而不是浏览器的破图占位符 |
|
||||
| `14-reader-epub-image.png` | EPUB · 插图 | 章节文档里的图片真的画出来了,而不是浏览器的破图占位符;比页面宽的那张也在栏内 |
|
||||
|
||||
## 自动测量
|
||||
|
||||
| 检查 | 结果 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 展开工具栏: 快捷按钮与两条栏一起出现 | ✅ | visible=true 按钮 4 个 pointer-events=auto |
|
||||
| 展开工具栏: 快捷按钮与两条栏一起出现 | ✅ | visible=true 按钮 7 个 pointer-events=auto |
|
||||
| 快捷列: 整组控件都在页内 | ✅ | 列 y=186..534 / 屏高 844 |
|
||||
| 快捷列: 装不下的控件有滚动条,说明还有更多 | ✅ | 溢出 否 overflow-y=true 滚动条 0px |
|
||||
| 快捷列: 一列里含整组快捷操作,而不是只露出前几个 | ✅ | 按钮 7 个:目录 / 日间 / 字号 / 行距 / 页边距 / 听书 / 阅读设置 |
|
||||
| 快捷列: 听书在列内 | ✅ | 列内控件:目录 / 日间 / 字号 / 行距 / 页边距 / 听书 / 阅读设置 |
|
||||
| 快捷列(420px 短屏): 列在页内,超出的控件能滚到 | ✅ | 列底 322 / 屏高 420,滚动条 0px,滚到底后末个控件可见=true |
|
||||
| 收起工具栏: 顶栏与底栏一起走 | ✅ | state=hidden 顶栏 hidden / 底栏 hidden |
|
||||
| 收起工具栏: 两条栏都不可见,也不可点 | ✅ | topbar=none footer=none |
|
||||
| 收起工具栏: 正文一行的位置不变(工具栏是浮层) | ✅ | 正文首行 y: 显示时 78 / 收起后 78 |
|
||||
@@ -51,13 +56,17 @@
|
||||
| 底栏: 拖动滑杆切到对应页 | ✅ | 拖到 4 后读数="第 4/4 页" |
|
||||
| 翻页模式: 滑杆拖到第 3 页,仍在本章 | ✅ | min=1 max=4 value=3 拖前="第 4/4 页" 拖后="第 3/4 页" |
|
||||
| 状态提示不推动正文 | ✅ | 正文顶部 y: 54.390625 / 54.390625 / 54.390625 |
|
||||
| 触控目标不小于 28px | ✅ | 9 个控件全部合格 |
|
||||
| 每个控件都有可读的名字 | ✅ | 9 个控件全部有名字 |
|
||||
| 触控目标不小于 28px | ✅ | 12 个控件全部合格 |
|
||||
| 每个控件都有可读的名字 | ✅ | 12 个控件全部有名字 |
|
||||
| 没有横向溢出 | ✅ | document 溢出 0px |
|
||||
| 翻页不再每次都发两次 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: 插图下面的正文也在 | ✅ | 正文开头="台版 转自 天使动漫
|
||||
|
||||
插图下面还有正文,这样图片没显示出来的时候,空出来的位置也是看得见的。" |
|
||||
| EPUB: 比页面宽的插图被收进栏内 | ✅ | 出版方写的是 1400px,画出来 342px / 栏宽 342px(布局 342x39,dpr=2) |
|
||||
| EPUB: 收窄插图没有改变它的比例 | ✅ | 原图 8.750 / 画出来 8.752 |
|
||||
| EPUB: 宽插图没有让页面横向溢出 | ✅ | document 溢出 0px / 阅读面溢出 0px |
|
||||
| 搜索框: 放大镜与输入框中线对齐 | ✅ | 放大镜中线 97.7 / 输入框中线 98.1(偏差 -0.4px,字形 16px,框高 44.203125px) |
|
||||
|
||||
全部通过。
|
||||
@@ -72,14 +72,39 @@
|
||||
{
|
||||
"name": "14-reader-epub-image",
|
||||
"label": "EPUB · 插图",
|
||||
"what": "章节文档里的图片真的画出来了,而不是浏览器的破图占位符"
|
||||
"what": "章节文档里的图片真的画出来了,而不是浏览器的破图占位符;比页面宽的那张也在栏内"
|
||||
}
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"name": "展开工具栏: 快捷按钮与两条栏一起出现",
|
||||
"ok": true,
|
||||
"detail": "visible=true 按钮 4 个 pointer-events=auto"
|
||||
"detail": "visible=true 按钮 7 个 pointer-events=auto"
|
||||
},
|
||||
{
|
||||
"name": "快捷列: 整组控件都在页内",
|
||||
"ok": true,
|
||||
"detail": "列 y=186..534 / 屏高 844"
|
||||
},
|
||||
{
|
||||
"name": "快捷列: 装不下的控件有滚动条,说明还有更多",
|
||||
"ok": true,
|
||||
"detail": "溢出 否 overflow-y=true 滚动条 0px"
|
||||
},
|
||||
{
|
||||
"name": "快捷列: 一列里含整组快捷操作,而不是只露出前几个",
|
||||
"ok": true,
|
||||
"detail": "按钮 7 个:目录 / 日间 / 字号 / 行距 / 页边距 / 听书 / 阅读设置"
|
||||
},
|
||||
{
|
||||
"name": "快捷列: 听书在列内",
|
||||
"ok": true,
|
||||
"detail": "列内控件:目录 / 日间 / 字号 / 行距 / 页边距 / 听书 / 阅读设置"
|
||||
},
|
||||
{
|
||||
"name": "快捷列(420px 短屏): 列在页内,超出的控件能滚到",
|
||||
"ok": true,
|
||||
"detail": "列底 322 / 屏高 420,滚动条 0px,滚到底后末个控件可见=true"
|
||||
},
|
||||
{
|
||||
"name": "收起工具栏: 顶栏与底栏一起走",
|
||||
@@ -214,12 +239,12 @@
|
||||
{
|
||||
"name": "触控目标不小于 28px",
|
||||
"ok": true,
|
||||
"detail": "9 个控件全部合格"
|
||||
"detail": "12 个控件全部合格"
|
||||
},
|
||||
{
|
||||
"name": "每个控件都有可读的名字",
|
||||
"ok": true,
|
||||
"detail": "9 个控件全部有名字"
|
||||
"detail": "12 个控件全部有名字"
|
||||
},
|
||||
{
|
||||
"name": "没有横向溢出",
|
||||
@@ -239,7 +264,22 @@
|
||||
{
|
||||
"name": "EPUB: 插图下面的正文也在",
|
||||
"ok": true,
|
||||
"detail": "正文开头=\"台版 转自 天使动漫\n插图下面还有正文,这样图片没显示出来的时候,空出来的位置也是看得见的。\""
|
||||
"detail": "正文开头=\"台版 转自 天使动漫\n\n插图下面还有正文,这样图片没显示出来的时候,空出来的位置也是看得见的。\""
|
||||
},
|
||||
{
|
||||
"name": "EPUB: 比页面宽的插图被收进栏内",
|
||||
"ok": true,
|
||||
"detail": "出版方写的是 1400px,画出来 342px / 栏宽 342px(布局 342x39,dpr=2)"
|
||||
},
|
||||
{
|
||||
"name": "EPUB: 收窄插图没有改变它的比例",
|
||||
"ok": true,
|
||||
"detail": "原图 8.750 / 画出来 8.752"
|
||||
},
|
||||
{
|
||||
"name": "EPUB: 宽插图没有让页面横向溢出",
|
||||
"ok": true,
|
||||
"detail": "document 溢出 0px / 阅读面溢出 0px"
|
||||
},
|
||||
{
|
||||
"name": "搜索框: 放大镜与输入框中线对齐",
|
||||
|
||||
+52
-5
@@ -410,6 +410,13 @@ URL 再回落到 `showPage(page)`。这一趟往返就是页能活过 Back、For
|
||||
「可滚动的祖先拿走手势」这条规则必须排除它——否则它吃掉每一个手势,每一次
|
||||
点击都归属某个滚动容器,翻页层级正确但永远到不了。这一条是 2026-09 那轮
|
||||
「点击页面没反应」的真正原因。
|
||||
- **三段点击区是*布局*几何,不是视觉几何**。`getBoundingClientRect()` 和
|
||||
`event.clientX` 都在**视觉**视口坐标里,两者相减得到的是视觉距离;而点击区是
|
||||
版心宽度的三等分,版心宽度是**布局**像素。页面被捏合放大(或任何祖先变换)时
|
||||
两者不相等,误差可以超过判定阈值的一半——读者点中间三分之一想收起工具栏,
|
||||
结果翻了页。所以宽度取 `offsetWidth`,偏移按两个盒子的比例 `rect.width /
|
||||
offsetWidth` 换算回布局空间;无变换、无缩放时这个比例正好是 1,那一行是恒等
|
||||
变换而不是一次单位转换。
|
||||
|
||||
---
|
||||
|
||||
@@ -460,6 +467,30 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
|
||||
---
|
||||
|
||||
### 3.9.1 章节内容的最小约束(图片与非 HTML 正文)
|
||||
|
||||
读者报的是「图片缺少样式,移动端显示溢出了」——一张 1400px 宽的插图在一栏
|
||||
342px 的版心里画出来 1400px,两头都出了屏幕。它属于这一类:**没有任何断言覆盖
|
||||
过它**,因为 UI 评审的插图 fixture 只有 240px 宽,塞得进版心,于是评审一边说
|
||||
「插图能画出来」一边让真实的缺陷完全复现。
|
||||
|
||||
所以有一条独立于书的样式表(`web/src/ui/shadow.ts` 的
|
||||
`CHAPTER_CONTENT_STYLESHEET`),它只声明**作者自己也会声明**的那些属性,并且
|
||||
位置在**书自己的样式表之前**——同优先级下后写的赢,所以作者写
|
||||
`img { width: 60% }` 仍然有效。三条规则,各有各的理由:
|
||||
|
||||
- **图片收进版心**:`max-inline-size`/`max-width` 取 `min(100%, …)`,
|
||||
`height: auto`。只夹宽度会让图**压扁**,这是修第一个数字时最容易引入的缺陷,
|
||||
而它在截图里看不出来(对角线的角度变了但你没得比);`auto` 才保住原比例。
|
||||
另外给 `max-block-size`,否则一张竖图会把图注挤到下一页,两者一起变得不可读。
|
||||
- **居中显示**:`display: block` + `margin-inline: auto`。行内图片坐在文字基线上,
|
||||
底下会多出降部那一截行距;`block` 也是「比版心窄的图居中」这条能成立的前提。
|
||||
- **没有尺寸的图片留出比例**:`aspect-ratio` 让章节目录在图片到达之前就有正确的
|
||||
高度,否则读者正在读的那一段会被一张还没加载完的图往下顶,位置就丢了。
|
||||
|
||||
§3.9 的 TXT 排版走的是另一条路(`.txt-body`,只挂在纯文本上);这一节管的是
|
||||
**所有格式**的章节内容,包括 EPUB 里作者自己写的标记。
|
||||
|
||||
### 3.10 沉浸式阅读
|
||||
|
||||
**顶栏和底栏是一个状态,不是一个状态乘以二。** 读者要收起导航就是要看到正文,
|
||||
@@ -485,11 +516,22 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
中标题还在,动画走完它就既看不见也点不到了。少写这一行,读者能点到一个看不见的
|
||||
返回按钮。
|
||||
- **右侧的快捷按钮列和两条栏一起出现、一起收起**(`.reader-rail`),由同一个
|
||||
`data-chrome` 属性开关,和顶栏底栏共用一套动效。读者最常用的四个动作(目录 /
|
||||
主题 / 字号 / 设置)一步可达,而且按钮住在正文的版心旁边(宽屏时用
|
||||
`--reader-measure` 算出的天头地脚里),不压字。它**不属于沉浸态**:那四个动作
|
||||
和顶栏是同一批目的地,属于「读者要控件」这个状态;沉浸态多出一列浮在正文右
|
||||
边缘的按钮,就不是读者要的「只剩正文」。
|
||||
`data-chrome` 属性开关,和顶栏底栏共用一套动效。按钮住在正文的版心旁边
|
||||
(宽屏时用 `--reader-measure` 算出的天头地脚里),不压字。它**不属于沉浸态**:
|
||||
这些动作和顶栏是同一批目的地,属于「读者要控件」这个状态;沉浸态多出一列浮在
|
||||
正文右边缘的按钮,就不是读者要的「只剩正文」。
|
||||
- **快捷列装的是整组调整,而不是其中的几个**:目录 / 主题 / 字号 / 行距 / 页边距 /
|
||||
听书 / 设置。每个按钮是该项设置的**一档**,值和设置面板里的行取自**同一张
|
||||
阶梯**(`LINE_HEIGHT_LADDER`、`PAGE_MARGIN_*`),所以两处不会各自漂移出一个
|
||||
对方到不了的值。
|
||||
这条是读者报的「竖排工具栏显示为 主题切换、听书」逼出来的:当时这一列
|
||||
**没有高度上限也不会滚**,屏幕一矮就直接画出屏幕外——读者数得到几个就以为这一列
|
||||
只有几个,于是报告了他看得见的那两个。所以有三条实现约束:
|
||||
`max-height` 由两条栏的高度算出来(和正文让开的是同两个数),装不下时
|
||||
`overflow-y: auto`,并且**滚动条是画出来的**(3px 细线而不是
|
||||
`scrollbar-width: none`)——一个只能靠滚动找到的控件必须看起来能滚,否则
|
||||
对读者就等于不存在。`overscroll-behavior: contain` 也在,免得滑到头那一下把
|
||||
手势交给下面的正文。
|
||||
- **收起时正文一角仍留两个读数**(`.reading-indicator`):当前章节名在左上、
|
||||
页码与总进度在左下。这是「沉浸式」与「应用停止绘制」的区别——读者收起工具栏
|
||||
就是要看正文,但**仍然要知道自己在哪一页**,让他为此再点一下正是收起状态要
|
||||
@@ -601,6 +643,11 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
|
||||
且只留两个读数、面板不遮住整页且留出 ≥120px 正文、面板有遮罩和把手、滑杆量
|
||||
的是章节内页数并且拖到第 N 页就停在第 N 页、状态提示不推动正文、每个控件
|
||||
≥28px 且有名字、没有横向溢出、翻页不再成对发 sync。
|
||||
还有三条是这一轮的读者报告补进来的,每一条都先是**截图**再是断言:
|
||||
**出版方写了 1400px 的插图**收进栏内且**比例不变**(只夹宽度会把它压扁,
|
||||
所以比例必须单独量);**快捷列**在 390×844 和 390×420 两种屏高下都在页内,
|
||||
装不下的控件滚得到、滚动条画了出来(`scrollbarWidth > 0`,而不是读回
|
||||
`overflow-y`);**快捷列**里确实有「听书」。
|
||||
|
||||
它跑在**真实的生产产物**上:`npm run build` 之后由 `tools/ui-review/server.mjs`
|
||||
起一个替身 API,再让真实 Chromium 以 390×844、2x 逐屏加载。不是 storybook——
|
||||
|
||||
@@ -2382,6 +2382,31 @@ book-content {
|
||||
* the centre of the screen is where the reading is.
|
||||
*/
|
||||
top: calc(22% + var(--safe-top));
|
||||
/*
|
||||
* A ceiling, and it is the whole of why the rail can be trusted to show what it
|
||||
* contains.
|
||||
*
|
||||
* Without one the column was as tall as its buttons and simply ran off the bottom
|
||||
* of a short screen: a phone in landscape, or a phone whose status bar and
|
||||
* browser chrome leave 500px, showed the first four controls and nothing else —
|
||||
* with no scrollbar, no fade and no hint that there was more. That is the
|
||||
* standing report ("竖排工具栏显示为 主题切换、听书") described exactly: a reader who
|
||||
* counts what they can see and concludes that is what the rail does.
|
||||
*
|
||||
* `--reader-chrome-bottom` and `--reader-chrome-top` are the same two numbers the
|
||||
* reading column reserves for the bars, so the rail cannot extend under either of
|
||||
* them however many controls it holds; `max-height` rather than `height` so a rail
|
||||
* with three buttons is three buttons tall and does not float in an empty column.
|
||||
*/
|
||||
max-height: calc(100% - 22% - var(--safe-top) - var(--reader-chrome-bottom) - 0.5rem);
|
||||
overflow-y: auto;
|
||||
/*
|
||||
* `overscroll-behavior: contain`, so reaching the end of the rail does not hand
|
||||
* the rest of the gesture to the page underneath it. Without it, a flick that
|
||||
* finds the last button turns into a chapter scroll — the reader's finger was on a
|
||||
* control and the book moved.
|
||||
*/
|
||||
overscroll-behavior: contain;
|
||||
z-index: var(--reader-layer-rail);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2395,6 +2420,25 @@ book-content {
|
||||
visibility 0s linear;
|
||||
}
|
||||
|
||||
/*
|
||||
* The rail's scrollbar is *drawn*, not hidden.
|
||||
*
|
||||
* `scrollbar-width: none` is the usual treatment for a decorative strip, and it is
|
||||
* precisely wrong here: a control that can only be found by scrolling has to look
|
||||
* scrollable, or the reader never learns it is there — which is the bug above. A
|
||||
* hairline on a translucent column is also the honest amount of chrome for it: the
|
||||
* rail floats over the text, and a 12px opaque gutter would be a band of the page
|
||||
* the reader cannot see through for no information.
|
||||
*/
|
||||
.reader-rail::-webkit-scrollbar {
|
||||
inline-size: 3px;
|
||||
}
|
||||
|
||||
.reader-rail::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--reader-ink) 22%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Hidden: it slides out to the right and fades, the direction its own entry motion
|
||||
* came from, so a reader who taps twice sees one movement rather than two.
|
||||
|
||||
+38
-4
@@ -226,13 +226,47 @@ export function attachGestures(element: HTMLElement, handlers: GestureHandlers):
|
||||
|
||||
if (distance <= TAP_MAX_DISTANCE && duration <= TAP_MAX_DURATION) {
|
||||
suppressClick = true;
|
||||
// The zone is a *layout* box, so it is read from layout geometry rather than
|
||||
// from client coordinates.
|
||||
//
|
||||
// The two agree only while the page is unscaled. `getBoundingClientRect()`
|
||||
// and `event.clientX` are both in *visual viewport* units, so under a pinch
|
||||
// zoom — or any transformed ancestor — they disagree with up to half the
|
||||
// tolerance above, and a tap lands in the neighbouring zone: the reader taps
|
||||
// the middle third to hide the toolbar and turns a page instead, or taps the
|
||||
// right third to turn a page and gets nothing but a toolbar. `offsetWidth`
|
||||
// and `offsetLeft` are in the element's own untransformed pixel space, which
|
||||
// is the space the three zones are defined in.
|
||||
// Two boxes, and the reason is that they answer two different questions.
|
||||
//
|
||||
// The *content* box (`offsetWidth`) is what the three zones are a division of,
|
||||
// and `offsetWidth` is in the element's own untransformed pixel space — which
|
||||
// is the space the zones are defined in. The *border* box
|
||||
// (`getBoundingClientRect`) is where the element is on screen right now, which
|
||||
// is the space `event.clientX` is in. Dividing one by the other is exact while
|
||||
// the page is unscaled and wrong under a pinch zoom, where half a third of a
|
||||
// 390px screen is several pixels of error and the arithmetic above has already
|
||||
// accepted the tap.
|
||||
//
|
||||
// `offsetWidth` is preferred and falls back to the border box: a host with no
|
||||
// layout (jsdom, an embedded WebView before its first layout) reports zero for
|
||||
// it, and a stage with no measurable width cannot have zones — reporting a tap
|
||||
// in one would be inventing a position.
|
||||
const rect = element.getBoundingClientRect();
|
||||
const width = rect.width || element.clientWidth;
|
||||
// A stage with no measurable width cannot have zones; reporting a tap in
|
||||
// one would be inventing a position.
|
||||
const layoutWidth = element.offsetWidth;
|
||||
const width = layoutWidth > 0 ? layoutWidth : rect.width || element.clientWidth;
|
||||
if (width <= 0) return;
|
||||
// Where the tap landed, in the element's own space: on the border box's left
|
||||
// edge, less the border, less anything the element has been scrolled by.
|
||||
//
|
||||
// `rect` is in visual units and so is `clientX`, so the *difference* is a
|
||||
// visual distance; it is divided by the ratio between the two boxes to bring
|
||||
// it back into the space the zones are measured in. Without an ancestor
|
||||
// transform and without a zoom the ratio is exactly 1 and the line is a
|
||||
// no-op — which is what makes this a correction rather than a conversion.
|
||||
const scale = layoutWidth > 0 && rect.width > 0 ? rect.width / layoutWidth : 1;
|
||||
const x = (event.clientX - rect.left - element.clientLeft * scale) / scale + element.scrollLeft;
|
||||
const third = width / 3;
|
||||
const x = event.clientX - rect.left;
|
||||
if (x < third) handlers.onTapZone('previous');
|
||||
else if (x > third * 2) handlers.onTapZone('next');
|
||||
else handlers.onTapZone('toggle-chrome');
|
||||
|
||||
@@ -217,17 +217,37 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
|
||||
destinations the top bar names (目录 / 主题 / 字号 / 设置), so it belongs to
|
||||
the same "the reader has asked for the controls" state as the bars. Drawn
|
||||
while the chrome is hidden, it was the one piece of chrome that survived
|
||||
immersion — a reader who tapped to hide the navigation still had seven
|
||||
controls floating over the right edge of the text, which is not the
|
||||
"only the page" they asked for.
|
||||
It is still *not* the toolbar's duplicate: the rail holds the four
|
||||
controls a reader nudges while reading (contents, theme, size, settings)
|
||||
and is a floating column in the page margin rather than a band, so it
|
||||
covers no line of text on a wide screen.
|
||||
immersion — a reader who tapped to hide the navigation still had controls
|
||||
floating over the right edge of the text, which is not the "only the
|
||||
page" they asked for.
|
||||
It is still *not* the toolbar's duplicate: the rail holds the controls a
|
||||
reader nudges while reading and is a floating column in the page margin
|
||||
rather than a band, so it covers no line of text on a wide screen.
|
||||
Drawn unconditionally and collapsed by `data-chrome` in CSS, exactly like
|
||||
the two bars: `visibility: hidden` at the end of the fade is what takes it
|
||||
out of reach of both a finger and a screen reader, and one attribute for
|
||||
three bands is what makes "the chrome is hidden" a single fact. */}
|
||||
three bands is what makes "the chrome is hidden" a single fact.
|
||||
|
||||
## What is in it, and why the list is not shorter
|
||||
—
|
||||
The rail used to hold four controls, and the standing report was "竖排
|
||||
工具栏显示为 主题切换、听书" — which is what a reader sees when the row
|
||||
they are looking at is a *scroll container* that nothing told them was
|
||||
one. Every control past the fourth on a short phone screen was below the
|
||||
fold of a 200px column with no scrollbar, no fade and no hint that it
|
||||
continued; a rail whose contents depend on the height of the phone is a
|
||||
rail whose contents the reader cannot enumerate. Two things follow, and
|
||||
both are stated in CSS rather than left to the layout:
|
||||
- the column scrolls when it must (`.reader-rail`), and its scrollbar is
|
||||
drawn rather than hidden — a control the reader must scroll to reach
|
||||
has to look scrollable, or it is a control that does not exist;
|
||||
- the buttons are one *step* of the settings each, so the rail is the
|
||||
whole set of adjustments the top bar reaches, not a sample of them:
|
||||
目录 / 主题 / 字号 / 行距 / 页边距 / 听书 / 设置. The read-aloud control
|
||||
was the one a reader could only reach by discovering the settings sheet,
|
||||
and it is the one they reach for *without* leaving the page.
|
||||
Every one of them is a destination the screen can actually reach: a
|
||||
button that does nothing is worse than a missing one. */}
|
||||
<div
|
||||
className="reader-rail"
|
||||
role="toolbar"
|
||||
@@ -241,6 +261,22 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
|
||||
onClick={() => handlers.onSetting({ theme: nextTheme(state.theme) })}
|
||||
/>
|
||||
<RailButton icon="font" label="字号" onClick={() => handlers.onSetting({ fontScale: stepFontScale(state.fontScale, 1) })} />
|
||||
<RailButton
|
||||
icon="up-down"
|
||||
label="行距"
|
||||
onClick={() => handlers.onSetting({ lineHeight: stepLineHeight(state.lineHeight) })}
|
||||
/>
|
||||
<RailButton
|
||||
icon="indent"
|
||||
label="页边距"
|
||||
onClick={() => handlers.onSetting({ pageMargin: stepPageMargin(state.pageMargin) })}
|
||||
/>
|
||||
<RailButton
|
||||
icon={state.tts.active ? 'pause' : 'volume-high'}
|
||||
label={state.tts.active ? '暂停朗读' : '听书'}
|
||||
pressed={state.tts.active}
|
||||
onClick={handlers.onSpeechToggle}
|
||||
/>
|
||||
<RailButton icon="sliders" label="阅读设置" onClick={handlers.toggleSettings} />
|
||||
</div>
|
||||
|
||||
@@ -485,7 +521,7 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
|
||||
/>
|
||||
<SegmentedRow
|
||||
label="行距"
|
||||
options={['inherit', '1.4', '1.6', '1.8', '2.1'].map((value) => ({
|
||||
options={LINE_HEIGHT_LADDER.map((value) => ({
|
||||
value,
|
||||
label: value === 'inherit' ? '原书' : value,
|
||||
}))}
|
||||
@@ -882,9 +918,27 @@ function TopButton({ icon, label, onClick }: { icon: IconName; label: string; on
|
||||
* circles because a reader aiming at a 2.75rem disc in the margin hits it, while a
|
||||
* reader aiming at a 2.75rem square in a list of five does not.
|
||||
*/
|
||||
function RailButton({ icon, label, onClick }: { icon: IconName; label: string; onClick(): void }): JSX.Element {
|
||||
function RailButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
pressed,
|
||||
}: {
|
||||
icon: IconName;
|
||||
label: string;
|
||||
onClick(): void;
|
||||
/** Set for a control whose state the icon already shows, so it reports it too. */
|
||||
pressed?: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<button type="button" className="rail-button" aria-label={label} title={label} onClick={onClick}>
|
||||
<button
|
||||
type="button"
|
||||
className="rail-button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
{...(pressed === undefined ? {} : { 'aria-pressed': pressed })}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="icon" aria-hidden="true">{iconGlyph(icon)}</span>
|
||||
</button>
|
||||
);
|
||||
@@ -962,6 +1016,34 @@ function indentLabel(indent: number): string {
|
||||
return indent === 0 ? '无' : `${indent.toFixed(2)} 字`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Line height, cycling the settings row's own ladder.
|
||||
*
|
||||
* The value is a *string*, because "原书" (`inherit`) is one of the choices and is
|
||||
* not a number — the book's own line height is the default and the setting has to
|
||||
* be able to mean "do not touch it". So the rail steps through the same list the
|
||||
* sheet renders rather than through arithmetic on a number: a rail that computed
|
||||
* its own values would sooner or later land on one the sheet's rows do not offer,
|
||||
* and the reader would see the control jump to a position its own options do not
|
||||
* include.
|
||||
*/
|
||||
const LINE_HEIGHT_LADDER = ['inherit', '1.4', '1.6', '1.8', '2.1'];
|
||||
|
||||
function stepLineHeight(current: string): string {
|
||||
const index = LINE_HEIGHT_LADDER.indexOf(current);
|
||||
return LINE_HEIGHT_LADDER[(index + 1) % LINE_HEIGHT_LADDER.length] ?? 'inherit';
|
||||
}
|
||||
|
||||
/** Page margin, cycling the slider's own range and wrapping at both ends. */
|
||||
const PAGE_MARGIN_MIN = 0;
|
||||
const PAGE_MARGIN_MAX = 4;
|
||||
const PAGE_MARGIN_STEP = 0.25;
|
||||
|
||||
function stepPageMargin(current: number): number {
|
||||
const next = Number((current + PAGE_MARGIN_STEP).toFixed(2));
|
||||
return next > PAGE_MARGIN_MAX + 0.001 ? PAGE_MARGIN_MIN : next;
|
||||
}
|
||||
|
||||
const THEME_LADDER: Array<AppSettings['theme']> = ['light', 'sepia', 'dark'];
|
||||
|
||||
const THEME_LABELS: Record<string, string> = { light: '白', sepia: '米黄', dark: '夜间' };
|
||||
|
||||
+131
-3
@@ -120,6 +120,96 @@ const TXT_STYLESHEET = `
|
||||
}
|
||||
`;
|
||||
|
||||
/*
|
||||
* The reader's own sheet for content *inside* a chapter, applied to every format.
|
||||
*
|
||||
* ## Why this exists at all
|
||||
*
|
||||
* The promise of this product is to preserve the author's design, so this sheet is
|
||||
* the one place where that promise has to give way — and it gives way only where
|
||||
* the alternative is a broken page rather than a plainer one:
|
||||
*
|
||||
* - **An image wider than the column.** Publisher markup sizes images in the
|
||||
* book's own units (an `img` with a pixel `width` of 1400), and a screenshot-heavy novel or a
|
||||
* comic archive is full of them. Unclamped, the picture is wider than the page
|
||||
* it is paginated into: it runs off both edges (the report is a screenshot of
|
||||
* exactly that), and — worse than ugly — it changes the chapter's own geometry,
|
||||
* so the column count the footer reports is measured against content that is
|
||||
* off the screen.
|
||||
* - **A width/height attribute is a request, not a law.** A max-inline-size with
|
||||
* an `auto` height is what makes "fit the column" win over the author's pixel
|
||||
* width while keeping the aspect ratio; clamping the width alone squashes the
|
||||
* picture, which is the other way this defect is reported.
|
||||
* - **Markup that was never HTML.** A fixed-layout archive read as reflowable (a
|
||||
* photo folder, a comic whose pages the manifest does not label as fixed) hands
|
||||
* this shadow root a body of characters. A `white-space: pre-wrap` is the one
|
||||
* property that makes that body readable rather than a single squashed run —
|
||||
* but it is scoped to `.book-flow[data-raw]`, so it cannot reach an authored
|
||||
* document that must keep the author's own whitespace handling.
|
||||
*
|
||||
* Everything here is deliberately low priority in the cascade sense: it names only
|
||||
* properties a chapter that styles its own images will also name, it is emitted
|
||||
* *before* the book's stylesheet (see the constructor), and it declares no
|
||||
* `!important` — so an author who sets a width of their own still wins.
|
||||
*/
|
||||
const CHAPTER_CONTENT_STYLESHEET = `
|
||||
.book-flow img,
|
||||
.book-flow video,
|
||||
.book-flow canvas {
|
||||
/* Stated on the inline axis so it follows the writing mode, and with an absolute
|
||||
ceiling as well: a max-width of 100% inside a box that is itself the full width
|
||||
of a wide screen is still 1200px, which is wider than any page this product
|
||||
draws. */
|
||||
max-inline-size: min(100%, var(--reader-image-max, 100%));
|
||||
max-width: min(100%, var(--reader-image-max, 100%));
|
||||
/* An automatic height rather than 100%: with a pixel width of 1400 and a clamped
|
||||
box of 342, auto keeps the author's aspect ratio and a percentage would not. */
|
||||
height: auto;
|
||||
/* Never taller than the page either, or a portrait plate pushes its own caption
|
||||
onto the next page and the two become unreadable together. */
|
||||
max-block-size: var(--reader-image-max-block, 100vh);
|
||||
object-fit: contain;
|
||||
/* A centred picture rather than one flush to the left margin, which is how a book
|
||||
presents a plate that is narrower than the column. Display block, so it can be
|
||||
centred at all: an inline image sits on a text baseline and comes with the
|
||||
descender's worth of leading under it. */
|
||||
display: block;
|
||||
margin-inline: auto;
|
||||
/* A picture cut in half by a column boundary is a picture the reader cannot
|
||||
read, and one that is taller than the page cannot be kept whole — so "avoid"
|
||||
applies to the ones that fit and is harmlessly ignored by the ones that do
|
||||
not. */
|
||||
break-inside: avoid;
|
||||
}
|
||||
/* An image the book gave no dimensions to. The box is given an intrinsic ratio so
|
||||
the chapter does not reflow under the reader when the picture finishes loading:
|
||||
without it, the paragraph they are reading is pushed down by the height of an
|
||||
image that had not arrived yet, and the reader loses their place. */
|
||||
.book-flow img:not([width]):not([height]):not([style*='aspect-ratio']) {
|
||||
aspect-ratio: var(--reader-image-ratio, auto);
|
||||
}
|
||||
.book-flow figure,
|
||||
.book-flow .pic,
|
||||
.book-flow p:has(> img:only-child) {
|
||||
margin-inline: 0;
|
||||
text-align: center;
|
||||
}
|
||||
/* Characters, not markup: a body of plain text under a fixed-layout label.
|
||||
Scoped to the attribute, so an authored chapter is untouched by it. */
|
||||
.book-flow[data-raw='true'] {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* The same wrapping for a chapter whose markup contains a single unbroken run: a
|
||||
URL, a base64 blob, a line of repeated equals signs. Horizontal overflow is a
|
||||
property of the whole screen, and one long token in one paragraph breaks it for
|
||||
the entire page. */
|
||||
.book-flow :is(p, li, td, h1, h2, h3, h4, h5, h6) {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
`;
|
||||
|
||||
const FLOW_STYLESHEET = `
|
||||
.book-flow {
|
||||
position: relative;
|
||||
@@ -248,6 +338,18 @@ export class BookShadowHost extends HTMLElement {
|
||||
* affect it with.
|
||||
*/
|
||||
private readonly txtEl: HTMLStyleElement;
|
||||
/**
|
||||
* The reader's rules for content *inside* a chapter, present for every format.
|
||||
*
|
||||
* A fourth sheet rather than more rules in `layoutEl`, because the two make
|
||||
* different claims. `layoutEl` is the *viewer* — the flow, the column, the
|
||||
* measure — and none of it is about the book's own bytes. This one is about the
|
||||
* bytes: how an image is clamped, how an unlabelled body of characters is
|
||||
* wrapped. Separating them means the sheet that has to yield to the author can be
|
||||
* placed *below* the author's own stylesheet on its own, without moving the
|
||||
* viewer's rules down with it.
|
||||
*/
|
||||
private readonly chapterEl: HTMLStyleElement;
|
||||
private readonly contentEl: HTMLDivElement;
|
||||
|
||||
constructor() {
|
||||
@@ -255,14 +357,20 @@ export class BookShadowHost extends HTMLElement {
|
||||
this.shadow = this.attachShadow({ mode: 'open' });
|
||||
this.layoutEl = document.createElement('style');
|
||||
this.txtEl = document.createElement('style');
|
||||
this.chapterEl = document.createElement('style');
|
||||
this.styleEl = document.createElement('style');
|
||||
this.contentEl = document.createElement('div');
|
||||
this.contentEl.className = 'book-flow';
|
||||
// The sheets go in cascade order, weakest first: the reader's layout rules, then
|
||||
// the plain-text typesheet, then the book's own styles, which must be able to
|
||||
// override both — the same order the document stylesheet gives them.
|
||||
// the plain-text typesheet, then the reader's rules for content inside a
|
||||
// chapter, then the book's own styles — which must be able to override all
|
||||
// three, since preserving the author's design is the point of the product. The
|
||||
// order the *content* sheet sits in is load-bearing rather than tidy: an author
|
||||
// who writes `img { width: 60% }` has to win over the clamp, and the same
|
||||
// specificity means the later sheet wins.
|
||||
this.layoutEl.textContent = FLOW_STYLESHEET;
|
||||
this.shadow.append(this.layoutEl, this.txtEl, this.styleEl, this.contentEl);
|
||||
this.chapterEl.textContent = CHAPTER_CONTENT_STYLESHEET;
|
||||
this.shadow.append(this.layoutEl, this.txtEl, this.chapterEl, this.styleEl, this.contentEl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,6 +399,26 @@ export class BookShadowHost extends HTMLElement {
|
||||
this.toggleAttribute('data-plain-text', enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the chapter's body as characters that were *not* typeset as a novel.
|
||||
*
|
||||
* The distinction is the one `plainText` does not make. A TXT chapter is
|
||||
* characters, and the reader typesets them into paragraphs — after which the body
|
||||
* is markup, and the browser's own whitespace collapsing is exactly right for it.
|
||||
* A body of characters that arrived under a *document* label (a fixed-layout
|
||||
* archive read as reflowable, a `text/plain` page the manifest called a chapter)
|
||||
* keeps its characters, and its line breaks are then the only structure it has:
|
||||
* collapsed, a log or an ASCII figure renders as one squashed run and the reader
|
||||
* is looking at a paragraph that was never in the file.
|
||||
*
|
||||
* Both "what is it" and "was it typeset" have to be asked, because the answers
|
||||
* combine: characters-not-typeset is the case this guards and
|
||||
* characters-typeset is the ordinary TXT.
|
||||
*/
|
||||
setRawText(raw: boolean): void {
|
||||
this.contentEl.toggleAttribute('data-raw', raw);
|
||||
}
|
||||
|
||||
/** The element pagination and scroll measurement should look at. */
|
||||
get flow(): HTMLDivElement {
|
||||
return this.contentEl;
|
||||
|
||||
@@ -99,6 +99,34 @@ describe('tap zones', () => {
|
||||
expect(h.saw.zones).toEqual(['previous', 'toggle-chrome', 'next']);
|
||||
});
|
||||
|
||||
it('reads the zones from layout geometry, so a scaled page still taps where the finger is', () => {
|
||||
// The failure this closes is a *half* of the tap area, and it is invisible at
|
||||
// 100% zoom: `getBoundingClientRect()` and `event.clientX` are both in visual
|
||||
// viewport units, so under a pinch zoom the border box and the finger disagree
|
||||
// while the zone arithmetic happily accepts the tap. The reader taps the middle
|
||||
// third to hide the toolbar and turns a page instead.
|
||||
//
|
||||
// Stated as a *scaled* rect: the layout box is 300px and on screen it is
|
||||
// reported as 600px wide, which is what a page at 2× looks like to this layer.
|
||||
// A tap at the visual centre of the screen must be the *middle* zone, and the
|
||||
// two taps at the outer quarters must be the outer zones — with the old
|
||||
// arithmetic, which compared a visual offset against a layout width, the first
|
||||
// of the three landed in the middle and the whole row shifted by one.
|
||||
const h = harness();
|
||||
// `offsetWidth` is what the zones divide; the rect is where the element *is*.
|
||||
Object.defineProperty(h.stage, 'offsetWidth', { get: () => 300, configurable: true });
|
||||
h.stage.getBoundingClientRect = () =>
|
||||
({ left: 0, top: 0, width: 600, height: 1200, right: 600, bottom: 1200, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
|
||||
// The tap coordinates are in *visual* units and the zones are in layout units.
|
||||
// A third of the 300px layout box is 100px of *layout* and 200px of screen, so
|
||||
// 150 is inside the first third, 300 (the visible centre) is the middle, and 450
|
||||
// is the last.
|
||||
press(h.stage, [150, 300]);
|
||||
press(h.stage, [300, 300]);
|
||||
press(h.stage, [450, 300]);
|
||||
expect(h.saw.zones).toEqual(['previous', 'toggle-chrome', 'next']);
|
||||
});
|
||||
|
||||
it('does not turn a page when the tap lands on a control inside the stage', () => {
|
||||
// The bug: the gesture layer treats *every* pointer it sees on the stage as a
|
||||
// page turn, so a tap on a button that lives inside the reading surface — a
|
||||
|
||||
@@ -214,6 +214,38 @@ describe('sanitiseInjectedContent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the sheet for content inside a chapter', () => {
|
||||
/**
|
||||
* The rules that keep a book's own markup from breaking the page.
|
||||
*
|
||||
* Asserted on the *host's* adopted sheets rather than on a rendered layout,
|
||||
* because jsdom does no layout: what is checkable here is that the sheet exists,
|
||||
* that it names the properties that matter, and — the one that decides whether the
|
||||
* author's design survives — that it sits *before* the book's own stylesheet in
|
||||
* the shadow root, so a chapter that styles its own images still wins.
|
||||
*/
|
||||
it('clamps images that are wider than the column', () => {
|
||||
const host = createBookHost();
|
||||
const css = [...host.shadow.querySelectorAll('style')].map((el) => el.textContent ?? '').join('\n');
|
||||
// The clamp itself, and the `height: auto` that keeps the aspect ratio: a
|
||||
// `max-width` alone squashes the plate, which is the failure a fix for the
|
||||
// width introduces.
|
||||
expect(css).toMatch(/\.book-flow img[^}]*max-width:\s*min\(100%/);
|
||||
expect(css).toMatch(/\.book-flow img[^}]*height:\s*auto/);
|
||||
});
|
||||
|
||||
it('keeps the sheet below the book, so an author who sizes an image still wins', () => {
|
||||
const host = createBookHost();
|
||||
const sheets = [...host.shadow.querySelectorAll('style')];
|
||||
// The order is the fix, not decoration: same specificity means the later sheet
|
||||
// wins, and the book's own stylesheet is the one that has to.
|
||||
const content = sheets.findIndex((el) => (el.textContent ?? '').includes('reader-image-max'));
|
||||
const book = sheets.findIndex((el) => el === (host as unknown as { styleEl: HTMLStyleElement }).styleEl);
|
||||
expect(content).toBeGreaterThanOrEqual(0);
|
||||
expect(book).toBeGreaterThan(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBody', () => {
|
||||
it('returns only the body of a full XHTML document', () => {
|
||||
const html = '<html><head><title>t</title></head><body><p>内容</p></body></html>';
|
||||
|
||||
+166
-1
@@ -92,7 +92,7 @@ const SCENES = [
|
||||
{
|
||||
name: '14-reader-epub-image',
|
||||
label: 'EPUB · 插图',
|
||||
what: '章节文档里的图片真的画出来了,而不是浏览器的破图占位符',
|
||||
what: '章节文档里的图片真的画出来了,而不是浏览器的破图占位符;比页面宽的那张也在栏内',
|
||||
openBook: true,
|
||||
book: 'review-illustrated',
|
||||
},
|
||||
@@ -151,6 +151,106 @@ async function audit(cdp, origin, scenes, results) {
|
||||
: `visible=${visibleChrome.visible} 按钮 ${visibleChrome.buttons} 个 pointer-events=${visibleChrome.pointerEvents}`,
|
||||
);
|
||||
|
||||
/*
|
||||
* The rail holds the whole set of controls the reader reaches for, and every one
|
||||
* of them is reachable.
|
||||
*
|
||||
* The standing report is "竖排工具栏显示为 主题切换、听书", and its shape decided
|
||||
* these checks. The rail was a column with no height ceiling and no scrolling, so
|
||||
* on a screen short enough it simply ran off the bottom: the reader sees what fits,
|
||||
* concludes that is what the rail does, and reports the two they can see. Two
|
||||
* properties answer that, and both are measured rather than read from the CSS:
|
||||
*
|
||||
* - **the column is inside the page**, so its last control is not drawn off the
|
||||
* bottom of the screen;
|
||||
* - **the controls it cannot fit are reachable** — the column scrolls, and it
|
||||
* looks scrollable, because a control that can only be found by scrolling has to
|
||||
* advertise the scroll or it does not exist as far as the reader is concerned.
|
||||
*/
|
||||
const rail = await cdp.evaluate(`(() => {
|
||||
const el = document.querySelector('.reader-rail');
|
||||
if (!el) return { missing: true };
|
||||
const r = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
const labels = [...el.querySelectorAll('button')].map((b) => b.getAttribute('aria-label') ?? '');
|
||||
return {
|
||||
labels,
|
||||
top: r.top,
|
||||
bottom: r.bottom,
|
||||
viewport: window.innerHeight,
|
||||
inside: r.bottom <= window.innerHeight + 1 && r.top >= -1,
|
||||
scrollable: style.overflowY === 'auto' || style.overflowY === 'scroll',
|
||||
overflows: el.scrollHeight - el.clientHeight > 1,
|
||||
scrollbarWidth: el.offsetWidth - el.clientWidth,
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'快捷列: 整组控件都在页内',
|
||||
!rail.missing && rail.inside,
|
||||
rail.missing ? '没有找到快捷按钮列' : `列 y=${Math.round(rail.top)}..${Math.round(rail.bottom)} / 屏高 ${rail.viewport}`,
|
||||
);
|
||||
check(
|
||||
'快捷列: 装不下的控件有滚动条,说明还有更多',
|
||||
!rail.missing && (!rail.overflows || (rail.scrollable && rail.scrollbarWidth > 0)),
|
||||
rail.missing
|
||||
? '没有找到快捷按钮列'
|
||||
: `溢出 ${rail.overflows ? '是' : '否'} overflow-y=${rail.scrollable} 滚动条 ${rail.scrollbarWidth}px`,
|
||||
);
|
||||
check(
|
||||
'快捷列: 一列里含整组快捷操作,而不是只露出前几个',
|
||||
!rail.missing && rail.labels.length >= 7,
|
||||
rail.missing ? '没有找到快捷按钮列' : `按钮 ${rail.labels.length} 个:${rail.labels.join(' / ')}`,
|
||||
);
|
||||
// The read-aloud control is on the rail, which is the report's own example
|
||||
// ("听书"). It matters because it is the one adjustment a reader makes *without*
|
||||
// leaving the page: reaching it only through the settings sheet is a different
|
||||
// product.
|
||||
check(
|
||||
'快捷列: 听书在列内',
|
||||
!rail.missing && rail.labels.includes('听书'),
|
||||
rail.missing ? '没有找到快捷按钮列' : `列内控件:${rail.labels.join(' / ')}`,
|
||||
);
|
||||
|
||||
/*
|
||||
* The same column on a short screen, which is the screen the report came from.
|
||||
*
|
||||
* A phone in landscape, or a phone whose browser chrome and status bar leave it
|
||||
* 520px of page, is where a column with no height ceiling stops showing its own
|
||||
* contents — and it is the case a review at a single 844px portrait size can never
|
||||
* see. Asserted by *scrolling the column* and asking where the last control ended
|
||||
* up, rather than by reading `overflow-y` back: the CSS is a claim about intent and
|
||||
* this is a claim about the reader being able to reach the control.
|
||||
*/
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 420, deviceScaleFactor: 2, mobile: true });
|
||||
await cdp.sleep(400);
|
||||
const shortRail = await cdp.evaluate(`(() => {
|
||||
const el = document.querySelector('.reader-rail');
|
||||
if (!el) return { missing: true };
|
||||
const r = el.getBoundingClientRect();
|
||||
const buttons = [...el.querySelectorAll('button')];
|
||||
el.scrollTop = el.scrollHeight;
|
||||
const last = buttons[buttons.length - 1]?.getBoundingClientRect();
|
||||
const col = el.getBoundingClientRect();
|
||||
return {
|
||||
bottom: r.bottom,
|
||||
viewport: window.innerHeight,
|
||||
inside: r.bottom <= window.innerHeight + 1,
|
||||
reachable: last ? last.bottom <= col.bottom + 1 && last.top >= col.top - 1 : false,
|
||||
count: buttons.length,
|
||||
scrollbarWidth: el.offsetWidth - el.clientWidth,
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'快捷列(420px 短屏): 列在页内,超出的控件能滚到',
|
||||
!shortRail.missing && shortRail.inside && shortRail.reachable,
|
||||
shortRail.missing
|
||||
? '没有找到快捷按钮列'
|
||||
: `列底 ${Math.round(shortRail.bottom)} / 屏高 ${shortRail.viewport},滚动条 ${shortRail.scrollbarWidth}px,`
|
||||
+ `滚到底后末个控件可见=${shortRail.reachable}`,
|
||||
);
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', { width: VIEWPORT.width, height: VIEWPORT.height, deviceScaleFactor: SCALE, mobile: true });
|
||||
await cdp.sleep(300);
|
||||
|
||||
await cdp.tapMiddle();
|
||||
await cdp.sleep(700);
|
||||
const hidden = await cdp.evaluate(`(() => {
|
||||
@@ -868,6 +968,71 @@ async function audit(cdp, origin, scenes, results) {
|
||||
`正文开头="${chapterText}"`,
|
||||
);
|
||||
|
||||
/*
|
||||
* A plate the publisher sized in its own pixels stays inside the column.
|
||||
*
|
||||
* Three numbers, three different ways of saying the same failure:
|
||||
*
|
||||
* - the image's own box against the reading column's — a picture wider than the
|
||||
* column is a picture the reader cannot see the ends of;
|
||||
* - the aspect *ratio* against the file's. A clamp that sets a width and leaves
|
||||
* the height alone squashes the plate, which is the defect a fix for the first
|
||||
* number can easily introduce, and it is invisible in a screenshot of a
|
||||
* diagonal;
|
||||
* - the screen's own horizontal overflow. The first two are about the image; this
|
||||
* one is about the *page*, which is what the reader actually reported.
|
||||
*
|
||||
* Measured on the *second* image, because the first is the one that fits and
|
||||
* already has its own check above.
|
||||
*/
|
||||
const wide = await cdp.evaluate(`(() => {
|
||||
const root = document.querySelector('book-content')?.shadowRoot;
|
||||
const img = root?.querySelector('.pic.wide img') ?? root?.querySelectorAll('img')[1];
|
||||
if (!img) return { missing: true };
|
||||
const r = img.getBoundingClientRect();
|
||||
const flow = root.querySelector('.book-flow');
|
||||
const fr = flow.getBoundingClientRect();
|
||||
const style = getComputedStyle(flow);
|
||||
// The column the image is in, which is what a max-width of 100% is a percentage
|
||||
// of — read from the flow's own box rather than from the viewport, so the
|
||||
// reader's page margin is accounted for the way the browser accounts for it.
|
||||
const pad = (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0);
|
||||
return {
|
||||
naturalRatio: img.naturalWidth / img.naturalHeight,
|
||||
shownRatio: r.width / r.height,
|
||||
width: r.width,
|
||||
columnWidth: fr.width - pad,
|
||||
layoutWidth: img.offsetWidth,
|
||||
layoutHeight: img.offsetHeight,
|
||||
dpr: window.devicePixelRatio || 1,
|
||||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
hostOverflow: (() => { const h = document.querySelector('book-content'); return h.scrollWidth - h.clientWidth; })(),
|
||||
attributeWidth: img.getAttribute('width'),
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'EPUB: 比页面宽的插图被收进栏内',
|
||||
!wide.missing && wide.width <= wide.columnWidth + 1 && wide.layoutWidth <= wide.columnWidth + 1,
|
||||
wide.missing
|
||||
? '章节里没有第二张图'
|
||||
: `出版方写的是 ${wide.attributeWidth}px,画出来 ${Math.round(wide.width)}px / 栏宽 ${Math.round(wide.columnWidth)}px`
|
||||
+ `(布局 ${wide.layoutWidth}x${wide.layoutHeight},dpr=${wide.dpr})`,
|
||||
);
|
||||
check(
|
||||
'EPUB: 收窄插图没有改变它的比例',
|
||||
!wide.missing && Math.abs(wide.shownRatio - wide.naturalRatio) <= 0.02,
|
||||
wide.missing
|
||||
? '章节里没有第二张图'
|
||||
: `原图 ${wide.naturalRatio.toFixed(3)} / 画出来 ${wide.shownRatio.toFixed(3)}`,
|
||||
);
|
||||
check(
|
||||
'EPUB: 宽插图没有让页面横向溢出',
|
||||
!wide.missing && wide.documentOverflow <= 1 && wide.hostOverflow <= 1,
|
||||
wide.missing
|
||||
? '章节里没有第二张图'
|
||||
: `document 溢出 ${wide.documentOverflow}px / 阅读面溢出 ${wide.hostOverflow}px`,
|
||||
);
|
||||
|
||||
/*
|
||||
* The search field's magnifier, measured rather than eyeballed.
|
||||
*
|
||||
|
||||
@@ -63,6 +63,25 @@ const ILLUSTRATED_CHAPTERS = [
|
||||
const PLATE_WIDTH = 240;
|
||||
const PLATE_HEIGHT = 160;
|
||||
|
||||
/**
|
||||
* A plate far wider than any phone column.
|
||||
*
|
||||
* The report behind this is a screenshot of an illustration running off *both* edges
|
||||
* of the page, and the fixture it was reported against was 240px wide — which fits
|
||||
* inside a 342px column, so the harness certified that illustrations render while the
|
||||
* defect was fully reproducible on a real book. A publisher sizes a plate in the
|
||||
* book's own pixels, and reflowable comics and screenshot-heavy novels are full of
|
||||
* them, so the fixture has to be one of those: the question this scene answers is not
|
||||
* "did the image load" but "does the page still fit the screen once it has".
|
||||
*
|
||||
* The aspect ratio is deliberately extreme (1400×160), so the reported failure and
|
||||
* the *other* failure a naive clamp produces are distinguishable in one picture. A
|
||||
* plate clamped in width alone comes out squashed at 342×160; one clamped with an
|
||||
* automatic height comes out at 342×39, which is what the book's own proportions are.
|
||||
*/
|
||||
const WIDE_PLATE_WIDTH = 1400;
|
||||
const WIDE_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
|
||||
@@ -145,6 +164,7 @@ function zlibDeflate(buf) {
|
||||
}
|
||||
|
||||
const PNG = platePng(PLATE_WIDTH, PLATE_HEIGHT);
|
||||
const WIDE_PNG = platePng(WIDE_PLATE_WIDTH, WIDE_PLATE_HEIGHT);
|
||||
|
||||
/** The two paragraphs of a document chapter, and the image between them. */
|
||||
function illustratedChapter(index) {
|
||||
@@ -155,13 +175,19 @@ function illustratedChapter(index) {
|
||||
'<body><p>后记:这一章没有插图,用来确认上一章的图片不是碰巧出现的。</p></body></html>',
|
||||
].join('\n');
|
||||
}
|
||||
const image = `/api/v1/books/${ILLUSTRATED_ID}/assets?__reader-book-resource__=1&ref=${encodeURIComponent('OEBPS/Images/pic.png')}`;
|
||||
const image = (name) =>
|
||||
`/api/v1/books/${ILLUSTRATED_ID}/assets?__reader-book-resource__=1&ref=${encodeURIComponent(`OEBPS/Images/${name}`)}`;
|
||||
return [
|
||||
'<?xml version="1.0" encoding="utf-8"?>',
|
||||
'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>第一卷 插图</title></head>',
|
||||
'<body>',
|
||||
`<div class="pic"><img src="${image}" alt="插图"/></div>`,
|
||||
// The plate that fits the column, as a book normally presents one…
|
||||
`<div class="pic"><img src="${image('pic.png')}" alt="插图"/></div>`,
|
||||
'<p>台版 转自 天使动漫</p>',
|
||||
// …and one the publisher sized in its own pixels, which is the case the reader
|
||||
// reported. Both are on the page so one screenshot shows "a plate is drawn" and
|
||||
// "a plate wider than the page is still inside it" at the same time.
|
||||
`<div class="pic wide"><img src="${image('wide.png')}" alt="宽插图" width="${WIDE_PLATE_WIDTH}" height="${WIDE_PLATE_HEIGHT}"/></div>`,
|
||||
'<p>插图下面还有正文,这样图片没显示出来的时候,空出来的位置也是看得见的。</p>',
|
||||
'</body></html>',
|
||||
].join('\n');
|
||||
@@ -600,6 +626,10 @@ export function createReviewServer({ port = 5199 } = {}) {
|
||||
reply.writeHead(200, { 'content-type': 'image/png' });
|
||||
return reply.end(PNG);
|
||||
}
|
||||
if (ref.endsWith('wide.png')) {
|
||||
reply.writeHead(200, { 'content-type': 'image/png' });
|
||||
return reply.end(WIDE_PNG);
|
||||
}
|
||||
const index = ILLUSTRATED_CHAPTERS.findIndex((chapter) => ref === chapter.path);
|
||||
reply.writeHead(200, { 'content-type': 'application/xhtml+xml; charset=utf-8' });
|
||||
return reply.end(illustratedChapter(index < 0 ? 0 : index));
|
||||
|
||||
Reference in new issue
Block a user