ReadViewSDK/.planning/phases/01-current-engine-boundaries/01-reflowable-audit.md

13 KiB
Raw Blame History

Phase 1 Audit: Current Reflowable Engine, Boundaries, and Stable Contracts

Phase: 01-current-engine-boundaries
Date: 2026-05-21
Purpose: 固化当前 .textReflowable 旧引擎的真实调用链、模式分流边界、以及本次重构不能碰的外层契约。

1. Executive Conclusion

当前仓库并不是“所有 EPUB 都走 WKWebView”。真实结构是三分流:

  1. webFixedLayoutFixed Layout EPUB继续走 WKWebView
  2. webInteractive:带脚本/多媒体/iframe/表单等交互内容的 EPUB继续走 WKWebView
  3. textReflowable:普通 reflowable EPUBDTCoreText -> NSAttributedString -> CoreText 分页 -> 原生文本页视图

因此,本次“旧引擎直接演进”的主战场已经存在,且就在 EPUBTextRendering。Phase 2/3 不需要新起一套阅读器,也不需要改 RDReaderView;要做的是升级当前 native reflowable 的 typesetter、属性体系和分页能力。

2. Entry Path: URL -> Reader Controller

入口在 Sources/RDReaderView/RDURLReaderController.swift

2.1 .epub 入口

RDURLReaderController.embedReaderController() 对扩展名做分支:

  • 扩展名是 epub
  • 直接构造 RDEPUBReaderController(epubURL:configuration:)
  • 把 reader controller 作为子控制器嵌入当前界面

这里没有直接决定 native 还是 web它只负责把 .epub 交给 RDEPUBReaderController

2.2 .txt 入口

TXT 则走 RDPlainTextBookBuilder 构造 RDEPUBTextBook,再以 external TextBook 模式交给 RDEPUBReaderController。这条链路与 EPUB 的原生文本展示在 UI 层会合,但不参与 EPUB reading profile 判定。

3. Reading Profile Split

模式分流在 Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift

3.1 判定规则

RDEPUBParser.readingProfile() 的真实逻辑是:

  • metadata.layout == .fixed -> .webFixedLayout
  • 否则,如果 hasInteractiveContent() == true -> .webInteractive
  • 否则 -> .textReflowable

3.2 hasInteractiveContent() 的判定来源

hasInteractiveContent() 会检查两类信号:

  • manifest 中是否出现 JS 相关 media type 或 scripted property
  • spine HTML 中是否出现交互模式特征

具体 regex 关注:

  • <script>
  • <iframe>
  • <video>
  • <audio>
  • <canvas>
  • <svg>
  • <form>
  • onload / onclick / touchstart 等内联事件
  • hype_generated_script
  • swiper
  • webview

这意味着 fixed layout 与 interactive EPUB 的 WebView 边界不是概念约束,而是已经在 parser 层具备明确判定逻辑。

4. .textReflowable Real Call Chain

当前 native reflowable 的真实调用链如下:

RDURLReaderController
  -> RDEPUBReaderController(epubURL:)
    -> RDEPUBParser
    -> RDEPUBPublication
    -> RDEPUBParser.readingProfile()
      -> .textReflowable
        -> RDEPUBReaderController.paginatePublication()
          -> resolvedTextRenderer()
            -> RDEPUBDTCoreTextRenderer
          -> RDEPUBTextBookBuilder.build()
            -> renderChapter(html:baseURL:style:)
            -> RDEPUBRenderedChapterContent
            -> ss_pageRanges(size:)
            -> RDEPUBTextBook / RDEPUBTextChapter / RDEPUBTextPage
          -> RDEPUBTextContentView
          -> RDReaderView

4.1 Branch point in RDEPUBReaderController

Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swiftpaginatePublication(restoreLocation:) 是当前核心分支点:

  • 如果 publication.readingProfile == .textReflowable

    • 构造 rendererresolvedTextRenderer()
    • 当前默认返回 RDEPUBDTCoreTextRenderer()
    • 构造 builderRDEPUBTextBookBuilder(renderer:)
    • 计算 pageSizeRDEPUBTextRenderStyle
    • 在后台线程执行 builder.build(...)
    • 完成后 applyTextBook(...),最终由 RDReaderView 刷新内容
  • 如果不是 .textReflowable

    • fixed layout直接创建 fixed snapshot
    • reflowable-web / interactive使用 RDEPUBPaginator

结论native reflowable 与 WebView 路径已经在 reader controller 内部彻底分开。

5. Render Stage: RDEPUBDTCoreTextRenderer

Sources/RDReaderView/EPUBTextRendering/RDEPUBDTCoreTextRenderer.swift 是当前原生章节渲染入口。

5.1 输入

renderChapter(html:baseURL:style:) 接收:

  • html: String
  • baseURL: URL?
  • style: RDEPUBTextRenderStyle

5.2 主要处理步骤

  1. RDEPUBTextRendererSupport.injectFragmentMarkers(into:)

    • 先往 HTML 注入 fragment marker
    • 为后续目录/定位/fragment offset 映射保留锚点
  2. 把 HTML 转为 UTF-8 Data

  3. 调用 DTHTMLAttributedStringBuilder

    • 通过 makeAttributedString(from:baseURL:style:)
    • options 由 dtOptions(baseURL:style:) 提供
  4. 如果 DTCoreText 构建失败

    • 回退到 fallbackRenderedContent(for:style:)
  5. 对生成的 attributed string 做后处理

    • extractFragmentOffsets(from:)
    • normalizeReadingAttributes(in:style:)
  6. 返回 RDEPUBRenderedChapterContent

    • attributedString
    • fragmentOffsets

5.3 当前 dtOptions 的能力边界

当前 options 只有基础排版参数:

  • DTDefaultFontFamily
  • DTDefaultFontName
  • DTDefaultFontSize
  • DTDefaultLineHeightMultiplier
  • DTDefaultTextColor
  • NSBaseURLDocumentOption
  • DTUseiOS6Attributes

这说明当前旧引擎的 renderer 仍然是“简单 DTCoreText builder + 少量 options”模型还没有形成 WXRead 风格的多层 stylesheet / 自定义属性 / 页面级后处理体系。

6. Pagination Stage: RDEPUBTextBookBuilder

Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift 当前负责把章节渲染结果组织为 book/chapter/page 结构。

6.1 Chapter iteration

build(parser:publication:pageSize:style:) 会:

  • 遍历 publication.spine.enumerated()
  • 只处理 linear == true
  • 只处理 html / xhtml 类型
  • 从 parser 读取 rawHTML
  • 计算章节标题
  • normalizeHTML(rawHTML)
  • 调用 renderer 生成 RDEPUBRenderedChapterContent

6.2 Base URL behavior

builder 把每个 spine item 的章节目录作为 baseURL

  • parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent()

这意味着当前图片/CSS/相对资源的解析锚点已经是“章节所在目录”,这是 Phase 2 引入更完整 CSS 层和资源解析时必须保留的基础约束。

6.3 Data model produced

builder 生成以下原生分页数据结构:

  • RDEPUBTextBook

    • chapters: [RDEPUBTextChapter]
    • pages: [RDEPUBTextPage]
  • RDEPUBTextChapter

    • chapterIndex
    • spineIndex
    • href
    • title
    • attributedContent
    • fragmentOffsets
    • pages
  • RDEPUBTextPage

    • absolutePageIndex
    • chapterIndex
    • spineIndex
    • href
    • chapterTitle
    • pageIndexInChapter
    • totalPagesInChapter
    • content
    • contentRange
    • pageStartOffset
    • pageEndOffset

这些模型就是当前原生引擎与 UI/状态层的契约面。

7. Current Core Pagination Primitive

当前分页核心在 Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPaginationSupport.swift

  • 扩展方法:NSAttributedString.ss_pageRanges(size:)
  • 底层实现:
    • CTFramesetterCreateWithAttributedString
    • CTFramesetterCreateFrame
    • CTFrameGetVisibleStringRange

处理循环逻辑是:

  1. 从当前 location 创建 frame
  2. visibleRange
  3. 把这个 range 记录为一页
  4. location += visibleRange.length
  5. 重复直到结束

7.1 Current limitation

这是一种典型的“可见字符串范围切页”模型,优点是简单、稳定、易于接回现有 RDEPUBTextBook 结构;缺点是:

  • 没有块级避免断页策略
  • 没有页面级语义结构
  • 没有自定义 layout frame / layouter
  • 很难表达复杂图片、表格、背景、分页约束

因此,ss_pageRanges(size:) 明确是后续需要被增强或替换的核心点之一。

8. Presentation Stage: RDEPUBTextContentView

Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift 是当前 native text page 的展示层。

8.1 Responsibilities

它负责:

  • 接收单页 RDEPUBTextPage
  • page.content 赋给 UITextView
  • 叠加高亮和搜索高亮
  • 展示页码
  • 输出 selection 和 annotation menu action

8.2 Why this matters

这说明当前 .textReflowable 路径已经和以下阅读器能力发生绑定:

  • selection
  • highlight
  • annotate
  • search highlight
  • page number display

因此,如果后续升级分页器或页面语义,必须继续维护 RDEPUBTextPageRDEPUBTextContentView 的兼容面,而不是绕开它另起视图层。

9. Location / Highlight / Search Compatibility Chain

当前原生文本路径不只是“能显示文本”,还承担已有 reader 能力链路:

9.1 Location mapping

RDEPUBTextBook 提供:

  • pageNumber(for:resolver:bookIdentifier:)
  • location(forPageNumber:bookIdentifier:)

它依赖:

  • fragmentOffsets
  • pageStartOffset
  • pageEndOffset
  • chapter-level attributedContent.length

9.2 Highlight and selection transport

RDEPUBTextContentView 会把选区转成:

  • RDEPUBSelection
  • RDEPUBTextOffsetRangeInfo

并通过 RDEPUBReaderController 的 text selection normalization / persistence 链路继续工作。

9.3 Search compatibility

RDEPUBTextContentView.applySearchHighlights(...) 也是基于页内 offset 重叠来计算高亮区域。

结论:RDEPUBTextBookRDEPUBTextPage 不是单纯的 UI DTO而是 reader state、annotation、search、location mapping 的核心兼容层。

10. What Still Uses WKWebView

下列链路必须继续视为 WebView 范围:

10.1 Fixed Layout

Sources/RDReaderView/EPUBCore/RDEPUBWebView+FixedLayout.swift

  • loadFixedSpread(...)
  • handleFixedLayoutLoad(...)

职责:

  • 把 fixed spread 渲染成 HTML
  • 通过 WKWebView.loadHTMLString(...) 加载
  • 处理 fixed spread 资源与 ready fallback

10.2 Interactive / web pagination support

Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift

职责:

  • 使用离屏、非持久化 WKWebView
  • 按 spine item 加载 HTML
  • 测量文档页数
  • 为 WebView 路径生成分页信息

尽管它名字叫 paginator但它不是当前 native .textReflowable 的分页核心;它是 WebView 渲染/测量体系的一部分。

10.3 Web reflowable presentation

Sources/RDReaderView/EPUBCore/RDEPUBWebView+Reflowable.swift

职责:

  • 构造 RDEPUBReflowableRenderRequest
  • 对当前 HTML 文档应用 presentation script
  • 处理 page index / target location / highlights 的 WebView 表示

这条链路应该保留给 .webInteractive 以及任何必须留在 WebView 的内容,不应误认为是当前 native reflowable 主路径。

11. Stable Container Contract: RDReaderView

Sources/RDReaderView/RDReaderView.swift 是当前阅读容器的稳定外壳。

11.1 What it owns

它负责:

  • pageCurl
  • horizontalScroll
  • verticalScroll
  • RTL / LTR 翻页方向
  • landscape dual-page
  • cover page pairing
  • orientation change handling
  • page container reuse和 data source protocol

11.2 Why Phase 1 must keep it unchanged

roadmap 已经把 RDReaderView 排除在本次改造范围外,而源码也支持这一结论:

  • RDEPUBReaderController 只把页面数据和内容视图交给 RDReaderView
  • native text 和 web content 都通过相同的 container contract 接入
  • 改它会把内核升级变成“内核 + 容器 + 手势 + 双页策略”的多变量回归

结论:RDReaderView 不修改 是正确的硬约束,不只是项目管理偏好,而是当前架构稳定性的必要条件。

12. Boundary and Exclusion Rules for Next Phases

后续 phase 应默认遵守以下边界:

  1. RDEPUBParser.readingProfile() 三分流保持不变
  2. webFixedLayout 继续走 WKWebView
  3. webInteractive 继续走 WKWebView
  4. .textReflowable 继续以 EPUBTextRendering 为主战场
  5. RDReaderView 不修改
  6. RDEPUBTextBook / RDEPUBTextChapter / RDEPUBTextPage 是兼容层,后续升级不能轻易破坏
  7. fragmentOffsets、offset-based location mapping、高亮/搜索 range transport 必须继续可用

13. Direct Refactor Entry Points

基于当前代码,后续“旧引擎直接演进”的直接触点应当是:

  • RDEPUBDTCoreTextRenderer

    • 从简单 dtOptions 升级为更完整的 stylesheet / HTML preprocessing / attribute pipeline
  • RDEPUBTextBookBuilder

    • 继续保留 chapter -> rendered content -> page model 的骨架
    • 后续承接更丰富页面语义、附件/块元素信息
  • RDEPUBTextPaginationSupport

    • 从单纯 CTFrameGetVisibleStringRange 切页,升级到更强分页策略
  • RDEPUBTextPage / RDEPUBTextChapter

    • 承接页面级元数据,而不是只保存纯 range
  • RDEPUBReaderController

    • 保持 branch point 与 reader integration不把它变成新的排版实现层

14. Final Audit Result

Phase 1 的事实基线可以归纳为一句话:

当前旧引擎已经拥有一条完整的 native .textReflowable 路径,真正需要演进的是 EPUBTextRendering 内部的 renderer / page model / pagination semanticsFixed Layout、交互式 EPUB 以及 RDReaderView 容器都应保持边界不变。