Readest 分页渲染下的 CSS 防御式改写:transformStylesheet 如何中和 background-attachment: fixed 与负水平外边距
【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest
Readest 的 EPUB 阅读器把每一章放进一个"多栏分页 iframe"中渲染,书籍作者书写的 CSS 一旦依赖"页面即视口"的假设,就会在这一环境中画错、画花甚至画到相邻页面上。本文以 Issue #5711(PR #5729,已合入)为线索,深入解析 transformStylesheet 中针对两类"毒样式"——background-attachment: fixed文字花屏与"负外边距 + 背景"跨页渗色——的改写策略、实现细节与边界条件。读完你既能复现并验证这两类问题,也能理解 Readest 在分页场景下做 CSS 归一化时"少即是多"的设计取舍。
问题背景:分页 iframe 并非书稿作者想象中的视口
Readest 的正文渲染基于 foliate 的分页器(paginator):每个章节的文档被放入一个被 transform 移动的巨大多栏条带(multi-column strip)中,CSS 多栏布局负责把内容切成一页一页。这与浏览器普通页面有本质区别:
- 视口是"整条多栏带"而非一页:任何锚定到视口的渲染行为(如
background-attachment: fixed、vw/vh单位、orientation媒体查询)都会按整条带的尺寸求值,与作者预期的"当前页"完全脱节; - 列与列之间没有裁剪:内容一旦溢出所在列的盒模型,就会直接画到相邻页面上;
- 内容随 transform 移动:翻页时整条带被平移,任何以视口为参照的绘制都会被"拖走"。
第 #5711 号问题报告的,正是两种由多看(Duokan)出品的书稿里常见、且恰好踩中上述特性的 CSS 写法。而承载修复的地方只有一个:阅读器在注入样式前统一调用的transformStylesheet。
transformStylesheet:一处中枢,多类改写
transformStylesheet 的函数签名如下:
export const transformStylesheet = ( css: string, vw: number, vh: number, vertical: boolean, isFixedLayout = false, ) => { ... };四个入参分别对应:待改写的样式文本、阅读器视口宽高(用于把vw/vh、超宽像素值等换算成可用的分页单位)、是否为竖排书写模式、是否为固定版式(FXL)。它的调用链清晰可见:
- 阅读器装配点:FoliateViewer.tsx 中的
getDocTransformHandler在收到detail.type === 'text/css'时调用transformStylesheet(data, width, height, viewSettings.vertical, bookData?.isFixedLayout);对 XHTML/HTML 内容则构造TransformContext走 transformer 管线; - style 变换器:styleTransformer 用正则抽取每个
<style ...>...</style>块,把transformStylesheet的结果回填;它还保留原标签的type、media等属性,避免media="print"这类限定被丢弃后打印样式污染屏幕渲染(readest/readest#6233); - 变换编排:transformContent 按名称依次执行 availableTransformers 中注册的 11 个变换器,
style位于其中。
一个重要的全局开关是isFixedLayout:固定版式页面是按作者自己的视口排版的,缩放字号、改写vw/vh、重绘颜色都会破坏原版,因此函数开头就直接return css原样放行(对应 fxl-authored-colors-5649,FXL 绕过全部变换)。所有下列 #5711 修复也都因此天然不作用于固定版式。
修复一:把 background-attachment: fixed 改写为 scroll
故障机理
在分页 iframe 中,background-attachment: fixed会把背景图像锚定到被 transform 的整个多栏条带视口上:
- 背景图相对视口固定,而承载它的元素随翻页条带移动,于是图像远离了它本该装饰的元素;
- Blink 在绘制时会把覆盖在 fixed 背景上的文字"抹花"(garbled band),形成一段花屏带。
从规范角度讲,CSS 变换(transform)子树内部的 fixed 附着本就被规定按scroll行为求值,所以把fixed改写成scroll不只是绕过 bug,更是回到规范要求的语义。这也是 transformStylesheet 采取的策略:统一重写fixed→scroll,且同时覆盖长写属性background-attachment与简写background,多图层列表(multi-layer)也逐层处理。
实现细节:先掩码,再改写
直接对fixed做全局替换是有风险的:url(fixed.png)里的 "fixed" 只是文件名,var(--fixed, fixed)里的fixed只是自定义属性的值。因此实现分三步:
- url() 全表掩码:先用
READEST_URL_N_PLACEHOLDER占位符替换所有url(...)片段。这一步必须在声明切分之前做,因为未加引号的 data URI 里可能含分号(会提前截断声明匹配),带引号的 url 里可能含右括号甚至 "fixed" 字样; - 函数 token 逐值掩码:对匹配到的声明值,再把
var()、渐变等name(...)函数掩码为READEST_FN_N_PLACEHOLDER,然后只对剩余的裸\bfixed\b做替换; - 还原占位符:依次还原函数 token 与 url token,保证
url(fixed.png)、var(--fixed)原样存活。
css = css.replace(/url\(\s*(?:"[^"]*"|'[^']*'|[^)]*)\s*\)/gi, (url) => { urlTokens.push(url); return `READEST_URL_${urlTokens.length - 1}_PLACEHOLDER`; }); css = css.replace( /((?:^|[{;\s])background(?:-attachment)?\s*:)([^;{}]*)/gi, (match, prop, value) => { if (!/\bfixed\b/i.test(value)) return match; // 掩码函数 token 后替换 fixed → scroll,再还原 ... }, ); css = css.replace(/READEST_URL_(\d+)_PLACEHOLDER/g, (_, i) => urlTokens[+i]!);改写是纯文本级、幂等友好的:!important被保留(background-attachment: fixed !important→scroll !important),而position: fixed因为正则要求background前缀而完全不受影响。
测试覆盖
style.test.ts 的background-attachment fixed (issue 5711)测试组完整锁定了这些行为:
- 长写与简写、多图层列表(
fixed, scroll, fixed→scroll, scroll, scroll)都被改写; !important保留;url(images/fixed.png)与var(--fixed, fixed)不被触碰;- 含分号的 data URI(
url(data:image/png;base64,AAAA))不截断匹配; - 带右括号与 "fixed" 的带引号 url(
url("weird) fixed.png"))不被破坏; - 内联样式(无
{}的声明串)同样生效; position: fixed原样保留。
修复二:中和"负水平外边距 + 背景"的跨页渗色
故障机理
多看书稿常用一种"通栏出血"(full-bleed)技巧:用与多看固定 2em 页边距配套的负外边距把色带撑满整页,例如:
h1.title { background-color: #0069B7; color: white; margin: -2em -2em 1.5em -2em; /* 左右 -2em,正好吃掉 2em 页边距 */ padding-top: 3em; }在多看自己的渲染器里,页边距是固定的 2em,负-2em外边距恰好把背景"溢出"到页面边缘,形成通栏色带。但 Readest 的多栏分页中列与列之间没有裁剪,这个-2em的外溢量会直接画到相邻页面上,出现"上一页的色带残留到下一页"的跨页渗色。
修复策略:以报告者的临时方案为政策
实现先尝试过一个更"聪明"的方案——用max()钳制并通过--page-margin-*变量保留通栏效果,但被维护者以"过度复杂、宁可少一点兼容性也要更简单"为由否决,要求不得复活该方案(这一点在源码注释与记忆文档中均有明确记录)。最终合入的策略极其朴素:对命中条件的规则追加margin-left: 0 !important;与margin-right: 0 !important;,让色带停在列边缘——不再通栏(NO full-bleed),但这恰好就是问题报告者自己在自定义 CSS 里使用的规避手段,属于"把用户验证过的 workaround 固化为产品策略"。
命中条件(Gate)
改写不是无脑的,实现对每条规则做了四项检查,全部满足才追加归零声明:
| 门控条件 | 意图 | 反例 |
|---|---|---|
| 规则确实绘制了背景(any-painting background gate) | 悬垂缩进(hanging indent)等纯排版负外边距不参与 | margin-left: -1em且无背景 → 不动 |
| 水平方向的外边距解析后确实为负 | 只归零负的那一侧 | margin: 1em -2em 3em 4em→ 仅margin-right归零 |
非竖排书写模式(!vertical) | 竖排书的分栏几何不同 | vertical: true→ 跳过 |
简写不含calc()/var() | 函数式值无法可靠按空白拆分边 | margin: calc(0px - 2em) 0→ 跳过 |
背景判定上,none、transparent以及 alpha 为 0 的rgba()/hsla()都算"不绘制";声明顺序上按"后声明覆盖先声明"逐边解析最终值(margin: -2em; margin-left: 1em;时左侧最终为1em,只归零右侧)。由于归零声明以!important追加在块末尾、级联靠后,即使作者写了!important的负外边距也能被压过(测试wins over an authored !important margin专门验证了这一点);实现注释也说明:忽略块内!important优先级的最坏结果只是"留下作者原本的负外边距",绝不会破坏有效布局。
测试覆盖
style.test.ts 的negative horizontal margins with background (issue 5711)测试组逐条锁定了:四值简写、两值简写、长写属性、按侧归零、后声明覆盖、background: none之后再声明background-color仍能检测到绘制、alpha 零色不算绘制、无背景/透明背景不动、仅垂直方向为负不动、margin: -1em auto的自动居中保留、calc()/var()简写跳过、竖排跳过。
值得区分的是:transformStylesheet中另有一套独立的duokan-bleed属性处理(style.ts),它针对显式书写duokan-bleed: left right的书稿,用margin-left: calc(-1 * var(--page-margin-left))等变量保留通栏,并补position/overflow/display/width声明。这与 #5711 的"负外边距检测 + 归零"是两条并行的路径:前者是作者主动声明出血意图,后者是隐式出血技巧的防御式中和。
排障实录:三个容易踩的坑
记忆文档还记录了复现与排查过程中三个非显而易见的陷阱,对任何调试"谁画坏了这一页"的人都有直接价值:
- Issue 附件里的"部分 EPUB"会说谎:复现书 2 的
part0010.xhtml引用了../Styles/style0001.css,但 zip 里只打包了style0010.css。缺失引用的结果不是报错,而是整个样式表静默加载成 0 条规则,bug 因此"无法复现"——必须把 CSS 复制到被引用的名字并重新打包,才能暴露真正的问题。排查分页排版问题时,务必先核对 zip 内实际打包的文件名与 XHTML 中的引用是否一一对应。 <body id="b2">上的装饰性角标:foliate 的paginator.jsgetBackground()在加载时读取 body 的计算后背景,随后设置doc.body.style.background = 'none',再把它重绘到与页面同尺寸的背景分段 div 上(相关机制可参考 paginator-swipe-bg-flash 中关于#background层与computeBackgroundSegments的记录)。因此一个no-repeat right bottom的 body 装饰图(如 Sherlock 烟斗图)会被镜像到每个分页容器的右下角,盖住 Readest 的页脚/页码。这是既存的外观问题,不属于 #5711 的修复范围;将来若要修复,应落在 paginator 的背景镜像逻辑里,而不是transformStylesheet。- 追查"这像素是谁画的":阅读器里有多个绘制层。排查时应枚举所有 iframe 以及顶层文档的 shadow root中
computedStyle.backgroundImage !== 'none'的元素——绘制者往往是一个 foliate 的 shadow-DOM div,它在elementFromPoint下不可见(属于内容下方的画布级绘制),漏掉 shadow root 就会误判元凶。
联动与边界:同一中枢的相邻问题
transformStylesheet是全仓库 CSS 归一化的唯一中枢,多个版式问题都在这里收敛:
- table-cell-overflow-wrap-anywhere-5681:表格
overflow-wrap: anywhere会把每列最小宽度压到单字符,导致窄列碎成一摞字母;transformStylesheet配套注入td, th { overflow-wrap: break-word; }(见 getColorStyles),与 #5711 共用同一改写枢纽; - fxl-authored-colors-5649:固定版式绕过所有变换,因此 #5711 的两项修复对 FXL 天然无影响。
截至记忆文档的索引快照(2026-08-24):#5711 已通过 PR #5729 合入(含评审修正:function-token 掩码、切分前的 url() 掩码、按侧解析外边距、any-painting 背景门控),而"body 角标盖住页脚"的 paginator 背景镜像问题仍处于 OPEN 状态。
结语:防御式 CSS 改写的取舍
回看 #5711 的整个修复过程,最有价值的并非两条正则,而是两次"克制":面对fixed时没有否定规范、而是回到规范要求的scroll语义;面对通栏出血时放弃了复杂的max()钳制方案,采纳了报告者验证过的"归零"策略——在分页渲染这个充满作者假设的场景里,简单、可预期、不破坏有效布局的改写,往往比追求完整兼容的实现更值得合入。这正是 transformStylesheet 及其 测试套件 留给后续维护者的方法论遗产。
【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考