fix(web): 插图收进版心,快捷列补齐并可滚动,点击区按布局几何

This commit is contained in:
hectorqin committed 2026-09-19 14:46:15 +08:00
1 parent 4715704d84
commit 7f74baab68
11 files changed
+674 -35

No files matched your search

+13 -4
View File
@@ -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) |
全部通过。
+45 -5
View File
@@ -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
View File
@@ -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——
+44
View File
@@ -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
View File
@@ -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');
+93 -11
View File
@@ -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
View File
@@ -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;
+28
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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.
*
+32 -2
View File
@@ -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));