From 7f74baab685bedc5546426fb99d4a95f4eeda7f3 Mon Sep 17 00:00:00 2001 From: hectorqin <8m0v8c12sZDrY2infddElF+hectorqin@noreply.cnb.cool> Date: Sat, 19 Sep 2026 14:46:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=8F=92=E5=9B=BE=E6=94=B6?= =?UTF-8?q?=E8=BF=9B=E7=89=88=E5=BF=83=EF=BC=8C=E5=BF=AB=E6=8D=B7=E5=88=97?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=B9=B6=E5=8F=AF=E6=BB=9A=E5=8A=A8=EF=BC=8C?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E5=8C=BA=E6=8C=89=E5=B8=83=E5=B1=80=E5=87=A0?= =?UTF-8?q?=E4=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ui-review/README.md | 17 +++- docs/ui-review/report.json | 50 +++++++++- docs/ui.md | 57 ++++++++++- web/src/styles/reader.css | 44 +++++++++ web/src/ui/gestures.ts | 42 ++++++++- web/src/ui/reader-chrome.tsx | 104 +++++++++++++++++--- web/src/ui/shadow.ts | 134 +++++++++++++++++++++++++- web/test/gestures.test.ts | 28 ++++++ web/test/shadow.test.ts | 32 +++++++ web/tools/ui-review/run.mjs | 167 ++++++++++++++++++++++++++++++++- web/tools/ui-review/server.mjs | 34 ++++++- 11 files changed, 674 insertions(+), 35 deletions(-) diff --git a/docs/ui-review/README.md b/docs/ui-review/README.md index be1eec5..57a0c6a 100644 --- a/docs/ui-review/README.md +++ b/docs/ui-review/README.md @@ -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) | 全部通过。 diff --git a/docs/ui-review/report.json b/docs/ui-review/report.json index 5eb7dff..b534e58 100644 --- a/docs/ui-review/report.json +++ b/docs/ui-review/report.json @@ -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": "搜索框: 放大镜与输入框中线对齐", diff --git a/docs/ui.md b/docs/ui.md index 1ea12ed..657074a 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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—— diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index a2536a4..a9d35b7 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -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. diff --git a/web/src/ui/gestures.ts b/web/src/ui/gestures.ts index 0295f2d..be9ba7e 100644 --- a/web/src/ui/gestures.ts +++ b/web/src/ui/gestures.ts @@ -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'); diff --git a/web/src/ui/reader-chrome.tsx b/web/src/ui/reader-chrome.tsx index fb39af5..798d956 100644 --- a/web/src/ui/reader-chrome.tsx +++ b/web/src/ui/reader-chrome.tsx @@ -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. */}
handlers.onSetting({ theme: nextTheme(state.theme) })} /> handlers.onSetting({ fontScale: stepFontScale(state.fontScale, 1) })} /> + handlers.onSetting({ lineHeight: stepLineHeight(state.lineHeight) })} + /> + handlers.onSetting({ pageMargin: stepPageMargin(state.pageMargin) })} + /> +
@@ -485,7 +521,7 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom /> ({ + 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 ( - ); @@ -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 = ['light', 'sepia', 'dark']; const THEME_LABELS: Record = { light: '白', sepia: '米黄', dark: '夜间' }; diff --git a/web/src/ui/shadow.ts b/web/src/ui/shadow.ts index c697630..20ddd92 100644 --- a/web/src/ui/shadow.ts +++ b/web/src/ui/shadow.ts @@ -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; diff --git a/web/test/gestures.test.ts b/web/test/gestures.test.ts index 28ca183..e198f26 100644 --- a/web/test/gestures.test.ts +++ b/web/test/gestures.test.ts @@ -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 diff --git a/web/test/shadow.test.ts b/web/test/shadow.test.ts index 57721cc..27f77ff 100644 --- a/web/test/shadow.test.ts +++ b/web/test/shadow.test.ts @@ -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 = 't

内容

'; diff --git a/web/tools/ui-review/run.mjs b/web/tools/ui-review/run.mjs index 8ecfc39..f33d688 100644 --- a/web/tools/ui-review/run.mjs +++ b/web/tools/ui-review/run.mjs @@ -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. * diff --git a/web/tools/ui-review/server.mjs b/web/tools/ui-review/server.mjs index 56eb5b8..109b304 100644 --- a/web/tools/ui-review/server.mjs +++ b/web/tools/ui-review/server.mjs @@ -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) { '

后记:这一章没有插图,用来确认上一章的图片不是碰巧出现的。

', ].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 [ '', '第一卷 插图', '', - `
插图
`, + // The plate that fits the column, as a book normally presents one… + `
插图
`, '

台版 转自 天使动漫

', + // …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. + `
宽插图
`, '

插图下面还有正文,这样图片没显示出来的时候,空出来的位置也是看得见的。

', '', ].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));