
Readest 阅读器 EPUB 命名空间属性修复实战srcdoc HTML 解析下的epub:type失效、applyNamespacedAttributes与媒体查询稳定化【免费下载链接】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 仓库中问题 #6038 的完整排障与修复记录srcdoc-html-parsing-namespaced-attrs-6038.md为核心骨架结合packages/foliate-js与apps/readest-app/src下的真实源码与测试展开。你将掌握为什么基于iframe.srcdoc渲染的 EPUB 章节会丢失 XML 命名空间、div[epub|typechapter]这类 CSS 命名空间选择器为何静默失效、Readest 如何通过applyNamespacedAttributes在 DOM 层面“还原”命名空间以及隐藏在背后的分页布局震荡resize 时 iframe 在 975px 与 487px 之间死循环为何必须用媒体查询改写来根治。一、问题现场IDPF 示例书“The Swans”的三处渲染缺失IDPF 官方示例childrens-media-query.epub即 “The Swans”在 Readest 阅读器中打开后出现了三个症状天鹅插图swans illustration不显示花朵条带flowers strip不显示奶油色页面底色cream page colour不渲染。这三个视觉效果全部由书内 CSS 通过同一个选择器驱动namespace epub http://www.idpf.org/2007/ops; div[epub|typechapter] { /* 插图 / 条带 / 页面底色都从这里来 */ }也就是说只要div[epub|typechapter]匹配成功三处效果应该同时出现反之则集体消失。事实正是后者——这个选择器在运行时的 iframe 文档里一个元素都匹配不到。二、根因定位iframe.srcdoc强制按 HTML 解析2.1 章节内容通过 srcdoc 注入问题 #6038 的根因经 Chromium 实测确认非推断Readest 使用的packages/foliate-js分页器在加载章节时只要section.loadContent()返回了内容就把整段 XHTML 字符串塞进 iframe 的srcdoc属性而不是传统的iframe.src blobUrl// packages/foliate-js/paginator.jsView.load 内 if (data) { this.#iframe.srcdoc data } else { this.#iframe.src src }同样的分支也出现在 packages/foliate-js/fixed-layout.js 的两处加载逻辑第 582 行与第 1073 行附近即固定版式pre-paginated章节走的是同一路径。这是一处Readest fork 对上游 foliate-js 的有意改动对应提交0561d06主题为 “More accessible iframes to improve compatibility with browser extensions”浏览器扩展无法向blob:iframe 注入脚本而srcdoc生成的 iframe 对扩展可见因此 fork 用srcdoc换取了扩展兼容性。2.2 HTML 解析器没有命名空间代价随之而来srcdoc的内容永远按text/html解析。在真实阅读器中实测iframe.contentDocument.contentType text/html章节div上的epub:type属性namespaceURI null。HTML 解析器在“foreign content外来内容”之外不存在命名空间机制所以epub:type只被当作一个字面属性名、落在 null 命名空间下。而 CSS 的namespace[epub|type]选择器要求属性必须位于对应命名空间中才能匹配。结果就是每一个 CSS 命名空间选择器都静默地匹配不到任何元素——浏览器不报错样式只是不生效。2.3 影响范围不止书本样式还有阅读器自身样式这个缺陷的影响比想象中更广且多数早已存在却未被注意书内样式大面积失效div[epub|typechapter]、[epub|typefootnote]等全部是死代码Readest 自己在getPageLayoutStyles/getFixedlayoutStyles中注入的aside[epub|type~endnote|footnote|note|rearnote]规则同样失效因此脚注目前实际依赖的是footnoteTransformer这一运行时兜底把aside epub:typefootnote重写为classepubtype-footnote见 apps/readest-app/src/services/transformers/footnote.tstransform: async (ctx) { let result ctx.content; result result.replace( /aside\sepub:type\s*\s*[](https://link.gitcode.com/i/497160d38be99cf5325538d2ed98a243)/gi, aside classepubtype-footnote epub:type$1$2, ); return result; },注意该正则只覆盖单值、属性在前的精确写法并且它证实了一件事此前 #4438 的“让namespace位于样式表最前”修复并不是脚注规则真正生效的原因——真正让规则起作用的是这个 class 重写。相关断言可参见 src/tests/utils/style-get-styles.test.tsnamespace必须领先于所有样式与font-face规则否则被静默忽略aside[epub|type~...]选择器随之失效。三、修复方案一applyNamespacedAttributes在 DOM 层面还原命名空间2026-09-03修复以 readest#6040squash 提交36d0fa2dd合并。3.1 设计思路packages/foliate-js侧不改动srcdoc路径改回去会再次破坏浏览器扩展注入能力而是在文档加载完成后、任何样式计算发生前由 Readest 前端对解析结果做一次“命名空间修复”。核心函数为 src/utils/style.ts 中的applyNamespacedAttributes(doc)export const applyNamespacedAttributes (document: Document) { // xmlns: 声明在 HTML 解析后会作为普通属性存活但已失去作用域语义 // 必须按 XML 的方式从元素自身的祖先链上解析前缀绑定。 const lookupNamespace (element: Element, prefix: string) { for (let node: Element | null element; node; node node.parentElement) { const uri node.getAttribute(xmlns:${prefix}); if (uri) return uri; } return null; }; for (const element of document.querySelectorAll(*)) { for (const { name, value } of Array.from(element.attributes)) { const [, prefix, localName] PREFIXED_ATTR_REGEX.exec(name) ?? []; if (!prefix) continue; // Foliate 可能交出章节片段而非完整 XHTMLhtml 上的声明可能丢失 // epub 在 EPUB 中命名空间固定足以恢复书内样式所需的选择器。 // xml 前缀由 XML 规范隐式绑定、从不显式声明因此 xml:lang 也要同样处理。 const uri prefix xml ? XML_NAMESPACE : (lookupNamespace(element, prefix) ?? (prefix epub ? EPUB_OPS_NAMESPACE : null)); if (uri !element.hasAttributeNS(uri, localName!)) { element.setAttributeNS(uri, name, value); } } } };配套常量const PREFIXED_ATTR_REGEX /^([A-Za-z_][\w.-]*):([A-Za-z_][\w.-]*)$/; const EPUB_OPS_NAMESPACE http://www.idpf.org/2007/ops; const XML_NAMESPACE http://www.w3.org/XML/1998/namespace;3.2 关键实现细节逐个元素、逐个属性遍历用setAttributeNS(uri, name, value)为每个带前缀属性prefix:local添加一个“命名空间孪生属性”。孪生属性与原始属性同名qualified name 相同因此CSS 命名空间选择器依赖namespaceURI从此可以匹配依赖getAttribute(epub:type)的既有调用方如 TTS、脚注弹窗完全不受影响——原属性原样保留。前缀解析走祖先链而非扁平映射xmlns:epub声明在 HTML 解析后只是普通属性不再具备作用域嵌套的重绑定rebinding必须在子树结束后失效扁平 map 会把内层绑定错误地带到后续兄弟节点。源码注释与测试都明确了这个取舍。兜底规则xml前缀由 XML 规范本身绑定、从不显式声明因此xml:lang直接绑定XML_NAMESPACEepub前缀在 EPUB 中只有一个固定命名空间OPS当章节片段丢失了html上的声明时直接使用EPUB_OPS_NAMESPACE兜底足以恢复书内样式选择器包括 noteref 标记。幂等性仅当!element.hasAttributeNS(uri, localName)时才写入孪生属性重复调用不会产生重复节点。3.3 调用时机早于一切样式消费applyNamespacedAttributes由FoliateViewer.tsx中的docLoadHandler在detail.doc就绪后最先调用// apps/readest-app/src/app/reader/components/FoliateViewer.tsx const docLoadHandler (event: Event) { docLoaded.current true; if (bookDoc.rendition?.layout pre-paginated) { setLoading(false); // Fixed layout doesnt emit stabilized event } const detail (event as CustomEvent).detail; if (detail.doc) { // Repair the parsed DOM before anything reads it: the renderer and the // fix-ups below both resolve styles off this document. applyNamespacedAttributes(detail.doc); // ……后续 getDirection / 写作方向 / 样式注入等 } };docLoadHandler挂接自 foliate 的afterLoad见useFoliateEvents中的onLoad: docLoadHandler其执行时序先于getBackground(doc)、也先于render()。因此修复发生在任何背景读取与重排之前不会引起二次布局或可见闪烁。3.4 测试覆盖测试位于 src/tests/utils/style-dom.test.ts 的describe(applyNamespacedAttributes)用DOMParser模拟srcdoc的 HTML 解析语义覆盖了这些场景测试点断言声明过的前缀被重新绑定getAttributeNS(OPS, type)从null变为chapter#6038 主场景限定名查找不被破坏修复后getAttribute(epub:type)仍返回footnote未声明前缀保持原样ops:type不会被绑定到 OPS 命名空间按 URI 而非前缀匹配书用xmlns:ops声明同一 URI 时ops:type正确绑定到 OPS片段丢失声明时兜底a epub:typenoteref仍恢复 OPS 命名空间兜底与无关声明共存xmlns:svg存在时epub:type仍走 EPUB 兜底隐式xml前缀xml:lang绑定到http://www.w3.org/XML/1998/namespacexmlns声明自身不被改动修复不触碰xmlns:epub属性作用域边界内层子树的重绑定不会泄漏到后续兄弟节点外层的绑定在重绑定子树结束后恢复深层的note绑定内层 URI其后的chapter回到外层 OPS无前缀文档是 no-op纯 class 文档修复前后innerHTML不变四、第二个缺陷布局永不收敛的 resize 震荡命名空间修复让div[epub|typechapter]规则真正生效后立刻暴露出一个更隐蔽的问题窗口尺寸变化时分页布局进入永久“乒乓”震荡。4.1 现象与测量调整窗口大小时iframe 每约 65ms 在 975px 与 487px 之间来回翻转永不停止在滚动scrolled模式下渲染器直接冻结CDPRuntime.evaluate超时。4.2 机理条带宽度与内容互为因果View.expand()会把 iframe 拉伸到整条多栏条带pageCount * columnSize的宽度而不是单页宽度。因此章节内部的media (orientation: ...)描述的是条带的宽高比而非单页487×632 读出 portraitcolumn-count: auto、内容高、2 页975×632 读出 landscapecolumn-count: 2、内容矮、1 页。于是闭环形成内容决定页数 → 页数决定条带宽度 → 条带宽度决定媒体查询结果 → 媒体查询结果又改变column-count/内容高度 → 回到第一步。条带由内容推导、内容又由条带推导系统没有不动点循环因此无法自发终止。4.3 排除法不是命名空间修复引入的记录中特别强调该震荡并非命名空间修复引起。证明方式——把 OPS 属性孪生移除、把同一规则改写成普通.probe-plain类结果在 3 秒内复现 43 次翻转完全相同的循环。因此这是分页架构固有的问题只是此前书内该样式从未生效未被触发而已。4.4 失败的尝试勿重试曾尝试在View.expand()里加“震荡阻尼器”锁定 A→B→A 周期中的最大值仅在render()的layout对象真正变化时清除锁。结果即便加了锁渲染器仍在容器宽度 600px 处冻结——因为到达render()的路径比守卫能看到的更多环路还有其他入口。4.5 有效的修复在transformStylesheet源头改写媒体查询最终方案不是在运行时打补丁而是在样式变换阶段消除依赖——与既有的 vw/vh 重写同处一地即 apps/readest-app/src/utils/style.ts 的transformStylesheet// media (orientation: ...) 在章节内是按 iframe 求值的而分页器把 iframe // 拉伸到整条多栏条带而非单页单页章节报 portrait双页章节报 landscape。 // 条带宽度本身又由内容推导因此改变内容高度的规则如示例中的 // column-count: 2会翻转查询、翻转页数、再翻转查询布局永不收敛#6038。 // 改为按阅读器自身视口解析该特性与下方 vw/vh 重写同理循环无法形成。 const isLandscape vw vh; const always (min-width: 0px); const never (min-width: 999999px); // 先暂存引号内字符串避免把声明值里的 at-rule 误当作真实 prelude。 const quoted: string[] []; css css.replace(/[^\n]*|[^\n]*/g, (value) { quoted.push(value); return READEST_STR_${quoted.length - 1}_PLACEHOLDER; }); css css.replace(/media[^{]*/gi, (prelude) prelude .replace(/\(\s*orientation\s*:\s*(landscape|portrait)\s*\)/gi, (_, mode: string) (mode.toLowerCase() landscape) isLandscape ? always : never, ) .replace( /\(\s*(min|max)-(width|height)\s*:\s*([^)]?)\s*\)/gi, (feature, bound: string, axis: string, length: string) { // 只有已是 px 的长度才能在不猜测书根字号的情况下求值 // 其他单位保留作者写下的特性。 const px /^(\d*\.?\d)(?:px)?$/.exec(length); if (!px) return feature; const viewport axis.toLowerCase() width ? vw : vh; const bounds parseFloat(px[1]!); return (bound.toLowerCase() min ? viewport bounds : viewport bounds) ? always : never; }, ), ); css css.replace(/READEST_STR_(\d)_PLACEHOLDER/g, (_, i) quoted[i]!);关键设计方向特性(orientation: landscape|portrait)在匹配阅读器自身视口vw vh时改写为恒真(min-width: 0px)不匹配时改写为恒假(min-width: 999999px)。内容不再依赖条带宽度循环无法形成尺寸特性同样危险双页条带会让宽度翻倍示例书自带的media (max-width: 480px)块其中h1 { margin: 50% auto 0 0 }会改变内容高度同样能在窄窗口冻结渲染器。因此min/max-width与min/max-height一律按阅读器视口求值但只处理 px 单位其他单位保留作者特性因为无法在不猜根字号的前提下换算改写范围收窄两处重写都只作用于媒体查询前奏media与开块{之间声明值或属性选择器里的同名文本不会被误伤引号内容先“停靠”park再还原确保声明值中带引号的 at-rule 永远不会被当作真实 prelude哨兵值自反(min-width: 0px)与(min-width: 999999px)求值结果仍是自身因此多趟处理顺序无关order-free。验证结果在容器宽度 820 / 760 / 700 / 640 / 600 / 560 / 520 / 480 / 440 下全部稳定此前 600 与 760 会冻结渲染器并经受住反复的 OS 级窗口缩放。五、复现与验证配方文档给出了完整的本地验证流程可用于回归起一个带 CORS 的静态服务用python3 http.server托管childrens-media-query.epub样本拖入开发版阅读器在pnpm dev-web中通过fetch - DataTransfer - DragEvent(drop)把文件投到.library-page上完成导入穿透 shadow root 读取 iframe按foliate-view - foliate-paginator的层级打开开放 shadow root检查计算样式读取getComputedStyle(chapterDiv).backgroundImage确认天鹅插图背景是否已渲染。经验备注在验证环境中Chrome 会把127.0.0.1:8899路由到某个返回ok的代理即便如此拖放导入依然成功不影响验证路径。六、经验总结与工程启示srcdoc是 HTML 解析的“陷阱通道”凡以srcdoc注入 XHTML 的地方命名空间属性必然退化为字面属性名。凡是依赖[prefix|attr]CSS 选择器的 EPUB 排版章节背景、脚注、旁注、noteref 标记等都会静默失效且不报任何错误排查极难。修复应放在 DOM 修复层而非回退架构不要因为命名空间问题就把srcdoc改回iframe.src blobUrl——那会重新破坏浏览器扩展注入能力而这正是 fork 当初引入srcdoc的目的对应 foliate-js 提交0561d06。applyNamespacedAttributes以“保留原属性 补孪生属性”的方式同时满足了 CSS 命名空间选择器与getAttribute()老调用方。媒体查询在分页 iframe 里的语义是“条带”而非“页面”View.expand()把 iframe 撑满整条多栏条带使 orientation / 宽度查询与内容互为输入输出形成无不动点的振荡。正确做法是在transformStylesheet阶段按阅读器真实视口解析这类查询仅 px在源头掐断自指循环——这与既有 vw/vh 重写是同一设计哲学。测试锚定解析语义style-dom.test.ts用DOMParser模拟 srcdoc 的 HTML 解析逐一锁定命名空间绑定、作用域边界、兜底规则与 no-op 行为footnote.ts的正则则展示了“class 兜底”这一务实的过渡手段。这些测试文件src/tests/utils/style-dom.test.ts、src/tests/utils/style-get-styles.test.ts、src/tests/services/transformers/transformers.test.ts可作为后续 EPUB 渲染类问题的回归参考。延伸阅读本案例与 footnote-aside-namespace-order-4438.mdnamespace在样式表中的位置、epub3-samples-idpf-480.mdIDPF 样本巡检此前只比较了文本布局而从未检查背景渲染以及 annotator-overlay-z-layers.md注释层叠上下文互为印证共同勾勒出 Readest 在 EPUB 渲染兼容性上的完整修复脉络。【免费下载链接】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),仅供参考