Skip to content

feat(reader): 跨平台两端对齐 —— @layer + 最小化 <br> 扫描 - #737

Merged
codedogQBY merged 9 commits into
codedogQBY:mainfrom
k6G52m4Dz75W:feat/justified-body-text
Sep 10, 2026
Merged

codedogQBY merged 9 commits into
codedogQBY:mainfrom
k6G52m4Dz75W:feat/justified-body-text

Conversation

@k6G52m4Dz75W

@k6G52m4Dz75W k6G52m4Dz75W commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

问题

当前的"正文两端对齐"实现(justified-text.js,来自 #686)会遍历每个文档中的每一个 <p>,
逐个调用 getComputedStyle,给符合条件的段落打标记,再注入
[data-marker] { justify !important } 样式。在大书上,每次章节加载 / 设置变更都触发
O(段落数) 次 getComputedStyle —— 在低端设备上成本明显。而且它只覆盖移动端:
桌面阅读器只能继承 foliate 硬编码的 justify: true,无法响应阅读器级别的
justifyBodyText 设置。

修复

用一套轻量、跨平台的策略替换 DOM 扫描:

  1. CSS 兜底放进 @layer —— body { text-align: justify } 提供两端对齐默认值,
    但它位于 @layer readany-justify 中。因为书籍样式是"未分层"的,任何作者设置的对齐
    都会胜出我们的 layer;:where() 又把特异性降到 0。这样在机制上就不可能覆盖书籍。
  2. 含 <br> 的块级元素 通过 :where(*:has(> br)) 设置 text-align: start,
    避免短诗 / 歌词行被拉伸(用 > 直接子,避免误伤含 br 后代的整段容器)。
  3. 代码 / 表格 / 图注 / 表单 用 text-align: start 排除。
  4. 一段极小的 JS 只读取含 <br> 的块级元素(远少于全部段落)的计算后对齐,
    并把作者已对齐的(center/right/end,无论用 class、id、内联样式、align 属性还是
    对齐的祖先实现)内联固定,让书籍对齐即使被我们的 start 规则匹配也得以保留。
    • 祖先对齐查找:由于 justify 样式表已注入时 :has(> br) 会直接覆盖元素自身的
      computed text-align 为 start,读取时沿祖先链找最近的非 start/inherit 对齐,
      才能正确保留 div.center / .poem-wrap 等继承居中。
    • align 属性直读:align="center" 等 HTML 属性在阅读器沙箱中可能不产生
      computed text-align,直接读属性兜底。

选择器作用域修复(关键 bug):querySelectorAll(选择器列表 + ":has(> br)") 中,
:has(> br) 只会挂到选择器列表的最后一项(figcaption),导致前面的 p, div, blockquote...
都变成裸选择器、匹配文档中每一个对应元素,全部被设成内联 start,彻底盖掉 body justify。
修复为 :is(选择器列表):has(> br),has 条件应用到整组 —— 只碰真正含直接子 <br> 的块
(实测:6 章共 90 个候选元素,修复后只处理 15 个含 br 的,其余 75 个普通段落完全不触碰)。

同一套 CSS 与逻辑现在同时用于移动端 WebView 阅读器(reader.template.html +
justified-text.js)与桌面 foliate 阅读器(FoliateViewer.tsx)。
justifyBodyText 设置保留(默认开启);关闭时不再注入 justify layer。

验证

  • 助手单测 + 源码契约测试已更新并通过(17 个 app-expo 测试,含 :is() 选择器、getAttribute、
    align 属性、start pin 断言)。
  • 桌面端(app)与 core 的 tsc --noEmit 干净。
  • 在 Chromium(headless,getComputedStyle)中端到端验证:
    • 17 个对齐场景:普通段落、div.center / div.right、语义类名 div(div.poem-wrap)、.poem 类、
      内联 text-align、align 属性、<br> 诗句(裸写及在对齐容器内)、嵌套居中、
      pre / figcaption / table、书籍 body { text-align } 覆盖 —— 全部尊重作者对齐。
    • is() 选择器作用域:修复后只处理真正含直接子 <br> 的块(6 章 90 → 15 个),
      普通段落(text-align 未设)保持干净、正确继承 body justify;中文长段落与西文长段落均实测 justify。
    • 竖排排除:竖排文档(writing-mode: vertical-rl)正文不被 justify(实测 start,根标记生效)。
    • 关闭撤销:开关关闭时,之前 pin 的内联被清除,恢复书级联;桌面端 syncJustifyForDoc 幂等
      (重复 apply 不丢失居中)。
    • 混合方向:同一 HTML 文档内多 section 不同书写方向,浏览器正确渲染(证明竖排是文档级,
      foliate 分页引擎按单一 #vertical 处理,跨方向销毁重建)。
    • 三层竖排检测必要性:class(<html class="vrtl"> 不设 CSS)、body writingMode、firstChild
      writingMode 三种情况各自实证,缺任一都会漏判。
  • 真实桌面端(tauri dev)逐场景验证:长段落 justify、居中对齐、align 属性、书 body left 覆盖、
    关闭开关恢复 —— 均符合预期。

已知局限

  • 混合方向文档(同一 XHTML 内 body 横排 + 某个 section 竖排):竖排 section 的普通正文
    会继承 body justify,本方案与 原版 feat(reader): add optional justified EPUB body text #686 都无法排除 —— 原版 isVerticalDoc 只查
    html class / body writing-mode / body 第一个子元素三处,且 apply 是文档级
    全有或全无;本方案至少让竖排 section 内含 br 的诗句被 :has(> br) 强制 start。
    这是 foliate 分页引擎本身不支持混合方向(getDirection 文档级检测、单一 #vertical)
    的边缘场景,正常竖排书是整文档竖排,不受影响。
  • 纯竖排文档(body/firstChild/class 竖排)正确排除 justify。

@k6G52m4Dz75W
k6G52m4Dz75W marked this pull request as draft August 27, 2026 14:41
@k6G52m4Dz75W
k6G52m4Dz75W marked this pull request as ready for review August 28, 2026 17:09
@k6G52m4Dz75W

Copy link
Copy Markdown
Contributor Author

测试epub:

justify-full-test.zip

@codedogQBY

Copy link
Copy Markdown
Owner

:has() 和 @layer 这两个属性在低版本的webview上应该不会生效,看看是不是加一下兜底的方案

@k6G52m4Dz75W

k6G52m4Dz75W commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor Author

感谢评审,兜底方案已加上(64039039 + 5f6b62b),先交代兼容性结论,再说方案。

兼容性结论(caniuse 已核实)

特性 最低支持线 低版本引擎上的实际行为
:has() Chromium/WebView2 105、Safari/iOS 15.4、WebKitGTK 2.36 含它的整条规则被丢弃,且 querySelectorAll(":is(...):has(...)") 会直接抛 SyntaxError(原实现会在这里崩)
@layer Chromium/WebView2 99、Safari/iOS 15.4、WebKitGTK 2.36 引擎不认识的 at-rule 整块丢弃——body justify 静默消失,功能整体失效

受影响的真实场景:旧发行版的 WebKitGTK(如 Ubuntu 20.04 自带 2.28)、iOS < 15.4、厂商冻结 WebView 的安卓机、企业固定版 WebView2。

兜底设计:能力检测 + 三档降级

启动时检测 CSSLayerBlockRule 是否存在(@layer 的可靠判据)和 CSS.supports('selector(:has(> br))')(Chromium 28+ 均可用),按结果分档:

  1. 全支持:行为与原实现完全一致,零变化。
  2. 无 :has():不输出 br 容器 CSS 规则(引擎反正会丢),改由 JS 扫描内联固定——扫描退化为"普通块级选择器 + 遍历 children 找直接子 <br>",不再触碰任何会让引擎抛异常的 API。
  3. 无 @layer:规则去掉 layer 原样输出,但每个选择器包 :where()(特异性 0),书籍任何真实规则仍优先于我们的兜底;连 :where 都没有的最老引擎给裸选择器(已知取舍:书籍若在 body 级声明对齐会被覆盖,代码注释已说明)。:has() 查询再包一层 try/catch,防引擎怪癖。

性能说明(不会退回 #686 的问题):回退扫描虽然会遍历所有块级元素,但只读 children/tagName(纯 DOM 树指针读取,不触发样式计算);昂贵的 getComputedStyle 在任何档位都只发生在匹配到的少数 br 块的祖先链上——与旧实现"逐段落一次样式计算"有数量级差距。且扫描仅在章节加载/设置变更时执行一次。

顺带修复:书籍自带 text-wrap: pretty 时,两端对齐会变得难看(借鉴 readest
#5582,5f6b62bf)

先解释问题。text-wrap: pretty 是一个较新的 CSS 断行优化属性:它会给段落"挑着
断行",让结尾不出现孤零零的短词,本意是美化未两端对齐的文本。知名电子书制作
组织 Standard Ebooks 的标配样式表就把它写在 body 上,导入这类 EPUB 后整本书都会
启用。而两端对齐的原理相反——不挑断行,而是把每一行硬性拉满、多出的空隙平均分给
词与词之间。两者叠加后:pretty 挑出的断行再被强行拉齐,部分行的词间距会被撑得
特别大,页面上出现一条条明显的空白缝,阅读器里调"词间距"也看不出效果。新版
WebView2 / Safari 26+ / 近期 Chromium 都会触发,不算冷门场景。

修复:两端对齐开启时,断行交由阅读器接管——对对齐会覆盖到的容器(html、body、
p、li、blockquote、dd)重置 text-wrap-style: auto !important。注意只重置
"断行风格"这半个属性:书籍若声明了 text-wrap: nowrap(不换行)依然生效,我们
不粗暴清除整条声明。老引擎不认识这个新属性,会当作未知声明直接忽略,零副作用。

验证:vitest 24/24(新增能力检测、三档 CSS 变体、无 :has 扫描、查询抛异常回退等 8 个用例),桌面 tsc 与 lint 通过,reader.html 已重新生成。桌面正常引擎行为零变化。

@codedogQBY

Copy link
Copy Markdown
Owner

最新的低版本 WebView 降级方案方向没问题。不过开启两端对齐时写入的 inline text-align: start 没有完整清理,关闭设置后可能残留;原本已有 inline 对齐的元素也可能被删掉。建议保存并恢复原始 text-align 后再合。

@codedogQBY

Copy link
Copy Markdown
Owner

另外移动端和桌面端目前有不少重复的 capability 检测、CSS 生成和对齐扫描逻辑,后续建议抽成公共实现,避免两端继续漂移。

@k6G52m4Dz75W

Copy link
Copy Markdown
Contributor Author

两条意见都已处理(13084115):

  1. inline text-align 的保存与恢复:pin 时先把元素自身的 inline
    text-align(可能为空)记录到 data-readany-justify-original,再写入
    我们的值;unpin 时逐字恢复该原始值——书籍自带的 inline 对齐不再被覆盖或
    清除。首次 pin 后跳过重复记录,避免把 pinned 值误存为原始值。

  2. 两端去重(本次一并做了):capability 检测、CSS 生成、对齐扫描整体
    抽取到 core 共享包(packages/core/src/reader/justified-text.ts)——
    桌面 viewer 直接 import,移动端 reader.html 由 build-reader 从同一模块
    打包(esbuild、不压缩、逻辑可审计),静态镜像文件 assets/reader/
    justified-text.js 已删除。行为测试随实现迁入 core(14 个用例),
    契约测试改为断言 core 产物存活。净删除 382 行。

验证:core vitest 598/598、双端 tsc/lint、桌面与安卓模拟器实测对齐与
开关切换正常。

… br scan

Replace the DOM-scan justified-text.js (PR codedogQBY#686) with a light, cross-platform
strategy shared by mobile WebView and desktop foliate:

- CSS fallback in @layer readany-justify: body { text-align: justify } is the
  default, but unlayered book styles always win, so author alignment can never
  be overridden. :where() keeps specificity at 0.
- :where(*:has(> br)) { text-align: start } so direct-br poetry/lyrics lines
  are not stretched; using :has(> br) (direct child) avoids cascading start
  onto unrelated siblings of a br-bearing outer container.
- Code/tables/captions/forms excluded with text-align: start.
- A tiny JS pass reads the computed alignment of only br-bearing block-level
  elements and pins author-aligned ones (center/right/end, however expressed)
  inline, so the book's alignment survives our start rule. Pins are tagged and
  un-pinned on disable (clean undo) or on vertical/fixed layouts.
- Vertical/fixed documents are tagged data-readany-vertical (via getDirection
  on desktop, isVerticalDoc on mobile) so the justify CSS scopes to horizontal
  text.
- justifyBodyText setting retained (default on); when off the layer is not
  injected.
…marker

class=vrtl/vltr is an authoring hint for reflow-to-vertical, but it does not
produce vertical writing by itself — the document is vertical only when a CSS
writing-mode is actually applied (book's own stylesheet or ReadAny's injected
:root.vrtl fallback). Detecting the class alone misclassifies a horizontal
document with a bare class as vertical.

Detect vertical writing purely from computed writing-mode:
- body's writing-mode, then
- the first non-inert child of body (some EPUBs set it there), mirroring
  foliate's getDirection.

This is applied consistently on both platforms:
- mobile reader.template.html isVerticalDoc: drop class check
- mobile justified-text.js isVerticalDoc: drop class check
- desktop document-loader.ts getDirection: add missing first-child check

Verified (Chromium headless): body-vertical, class+CSS, and first-child-vertical
all detect true; bare class without CSS now correctly detects false.
…t pin)

apply() previously unpinned first, then re-pinned. When re-run after the
justify stylesheet was already injected (e.g. applySettings ran, then a
section load triggered applyDocStyles), reading the alignment saw 'start'
(from :has(> br)) instead of the book's center, so the unpin dropped the
pinned center and the re-pin did nothing — centered poetry went left.

Make apply() idempotent: only unpin when justify is disabled or the layout
is unsupported; when enabled, preserve() re-pins (reading the already-pinned
inline center keeps it stable).
…ight)

Mirror the mobile fix: only unpin when justify is disabled or the layout is
unsupported. Re-running syncJustifyForDoc on later renders (after the justify
stylesheet is already injected) must not clear a pinned center — re-reading
the alignment then would see 'start' from :has(> br) and drop the alignment,
so centered poetry went left.
…gnment lookup

- querySelectorAll of BR_SELECTOR + ':has(> br)' only attached :has(> br)
  to the last selector (figcaption), so every p/div/... matched and got
  inline text-align:start, overriding the body justify fallback. Wrap the
  list in :is() so only genuinely br-containing blocks are touched.
- Keep the ancestor-alignment lookup in inheritAlign: by the time apply()
  runs, the justify stylesheet is already injected and the :has(> br) start
  rule pollutes the element's own computed alignment, so the nearest
  ancestor's center/right must be read instead.
- Honor align= attributes directly (sandbox may not expose UA styling).
- Drop the !important on body justify (not needed once the scan is scoped).
- Remove visible debug overlay and console diagnostics.
- Update FakeDoc/FakeContainer in tests for the :is() selector and
  getAttribute; default-align br blocks are now pinned to start.
…as()

Review asked for a fallback: both features are missing on old webviews
(WebKitGTK < 2.36, iOS < 15.4, vendor-frozen Android WebViews), and the
degradation is dangerous by default — engines that don't know @layer
discard the whole layer block (justify silently disappears), and :has()
drops the whole rule plus makes querySelectorAll throw SyntaxError.

Add capability detection (CSSLayerBlockRule presence; CSS.supports
selector queries) and split behavior accordingly:

- @layer missing: serve the rules unlayered, every selector wrapped in
  :where() (specificity 0) when available, so book rules with real
  specificity still win. Engines without :where get bare selectors as a
  last resort (documented tie-loss risk).
- :has() missing: drop the br-container CSS rule and let the JS scan pin
  those blocks inline — the scan now queries the plain block list and
  filters by iterating children, an API set that works everywhere.
  querySelectorAll(:is(...):has(...)) is additionally wrapped in
  try/catch for engine quirks.

Desktop FoliateViewer mirrors the mobile helper (capability memoized per
app run; all foliate docs share one engine). Rebuilt reader.html.

Verified: vitest 24/24 (new tests cover the three degradation tiers and
the throwing-query fallback), desktop tsc clean, build:reader OK.
Borrowed from readest (#5582): books like Standard Ebooks set
text-wrap: pretty on body, and engines that apply pretty to justified
text (Safari 26+, recent Chromium) overshoot inter-word spacing — the
gaps balloon and word-spacing stops working. When justify is on the
reader owns line breaking, so reset only the text-wrap-style longhand
(an authored nowrap mode survives) on the containers justify reaches:
html, body, p, li, blockquote, dd. !important so the reset also wins
over unlayered author styles from inside our @layer.

Mirrored in the mobile helper's three capability variants (the reset is
a plain property unknown to old engines, so it degrades to a harmless
no-op there).
…ning

Review follow-up: pinning overwrote whatever inline text-align the book
itself had set, and unpinning removed the property outright — so an
author's own inline alignment could be lost after toggling justify.

Record the element's previous inline text-align in
data-readany-justify-original on first pin (guarded by the pin
attribute so repeated apply cannot mistake the pinned value for the
original), and on unpin restore it verbatim — or remove our property
when the element had none.
… desktop/mobile)

Review follow-up: capability detection, CSS generation and the
alignment scan were duplicated between the desktop viewer and the
mobile reader, and had already started to drift (the save/restore fix
had to be applied twice).

Move the whole engine to packages/core/src/reader/justified-text.ts:
capability detection, three-tier CSS generation, the guarded br scan,
and pin/unpin with the original inline text-align save/restore. The
desktop viewer imports it directly; build-reader.js now bundles the
same core module (unminified, so the logic stays auditable) into
reader.html instead of injecting the static assets/reader/
justified-text.js file — that file is gone. The behavior tests moved
to core and run against the shared module directly.

No behavior change on fully capable engines; the three degradation
tiers are covered by the moved test suite (core vitest 598 green,
contract tests green).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants