Compare commits

...

32 Commits

Author SHA1 Message Date
shen
8ccb7157b2 feat: improve epub reader controls and annotations 2026-07-12 12:23:10 +08:00
shen
063a493b18 fix epub reader playback and trial navigation 2026-07-11 08:16:56 +08:00
shenlei
d7fcda345d refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView
- Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/
- Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec
- Update Podfile, demo project, and CocoaPods config for new pod name
- Delete old RDReaderView pod support files from ReadViewDemo/Pods
- Add new RDEpubReaderView pod support files
- Update documentation (API ref, architecture, UML, conventions, etc.)
- Add FixedLayoutRotationTests
- Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
2026-07-10 19:44:53 +09:00
shenlei
d5a7755702 feat: 支持加密 EPUB、试读墙、工具栏定制与朝向锁定
- 新增 RDEPUBResourceDataProvider 解密钩子协议 + 访问登记表,收敛章节 HTML/
  图片/内联 CSS/脚注/封面/图片查看器/正文取图等读取点,scheme handler 对加密书
  禁用流式分支;新增 RDEPUBDecryptingImageAttachment 解密 DTCoreText 图片附件;
  RDEPUBReaderDependencies.live(resourceDataProvider:) 便捷注入。明文书零影响。
- 试读墙:configuration.trialPolicy + delegate epubReaderTrialWallView/
  DidReachTrialWall,UI 由宿主提供(RDEPUBReaderController+Trial)。
- 工具栏定制:RDEPUBReaderTop/BottomToolViewProtocol 协议 + dependencies 工厂注入,
  内置栏已 conform,configureTopToolView 改协议类型。
- 朝向锁定:RDEPUBMetadata.orientation(OPF rendition:orientation 主,
  iBooks display-options 兜底)+ RDEPUBReaderController+Orientation 重写支持朝向、
  打开后主动转向。

注:RDEPUBReaderController+LocationResolution / PaginationCoordinator 为本次改动前
即存在的工作区修改,一并纳入。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:25:17 +09:00
shenlei
83dfa40299 完善搜索定位与内存监测回归验证
本次提交围绕搜索链路的稳定性、定位恢复体验以及长章节内存优化的验证能力进行了补强。

主要改动:

1. 调整阅读器搜索栏、搜索协调器与定位恢复逻辑,改善搜索结果跳转、状态同步与相关 UI 行为。

2. 补充内存探针接入点与上下文记录,便于跟踪阅读过程中的内存占用变化。

3. 更新 RDURLReaderController、ReaderContext 与工具视图相关实现,使调试与观测链路更完整。

4. 新增 MemoryFootprintTests,并同步更新 DemoReaderState 与 SearchTests,用 UI 测试覆盖搜索与内存相关回归场景。
2026-07-09 18:46:04 +09:00
shenlei
c4be426299 长章节内存优化 P1:章节级共享显示内容与 layouter,高亮转 overlay
- 新增 RDEPUBChapterDisplayContentCache(LRU 2,主线程限定,控制器
  持有):整章显示串构建时一次性注入全章主题色与暗黑图替换,配套
  共享 DTCoreTextLayouter;签名 = 章节内容对象标识 + 主题双色 +
  暗黑图配置,设置/主题变化自动失效重建
- RDEPUBTextContentView.configure 改为引用共享 entry,删除每次翻页
  的整章可变拷贝与按页属性写入
- 高亮/下划线改走 overlay decoration(高亮背景层、下划线前景层),
  移除 applyHighlightsToContent 与 render view 的属性绘制路径,
  高亮增删不再触发整章重建
- 内存警告时清空 display cache;shouldAvoidReaderPageCaching 保持
  保守(P1-4 待真机数据)

验证:编译通过;高亮/批注/选区/翻页/设置 13 项 UI 回归全过。
SearchTests 10 项失败经二分确认为先存回归(5a41066 与 e4e629a 均
复现,最后已知通过为 2026-06-08),与本次改动无关,待单独排查。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:09:40 +09:00
shenlei
e4e629a06c 长章节内存优化 P0:每页子串改按需构造并新增内存打点
- RDEPUBTextPage.content 由存储属性改为基于 chapterContent+contentRange
  的计算属性,三处构造点不再生成每页子串,章节缓存窗口内每章少一份
  整章文本常驻副本
- 新增 RDEPUBMemoryProbe(--demo-memory-probe 开启),在章节插入、
  每 20 次翻页、设置失效、转屏完成时输出 phys_footprint
- shouldAvoidReaderPageCaching 补注释,锁定放开前置条件为 P1-1
- 新增实施清单文档 LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md 并勾选 P0

验证:Demo 编译通过;UI 回归 9/9 通过(PageNavigation/
ReaderOpenClose/LargeBookOnDemand)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:34:45 +09:00
shenlei
5a41066b66 修复 EPUB 文本分页显示错位并补充分页调试能力
本次提交围绕 DTCoreText 文本页的分页一致性、交互索引和高亮命中进行了集中修复。

主要改动:

1. 文本页显示改为基于整章 attributed string 的上下文布局,只对当前页 range 进行渲染,避免页面子串重新换行导致的页末断行偏差。

2. 页面布局快照与交互控制器统一改为使用 chapter-absolute 索引,修正点击、选区、菜单锚点与高亮矩形在整章上下文下的定位。

3. 修复跨章节高亮串页问题,并调整文本页高亮命中逻辑:保留 CoreText 层绘制,点击时按高亮真实 rect 精确命中,避免重复绘制和整行误判。

4. 收紧 reader 级页面缓存策略,避免预加载同时持有多份整章显示副本带来的内存放大。

5. 新增分页边界校验器、垂直对齐器和 settings-flip 自动化调试入口,用于复现与诊断页范围/显示度量不一致问题。

6. 放宽 inline attachment 的 avoid-break 处理,并补充相关分页问题调查文档与索引。
2026-07-08 14:27:36 +09:00
shenlei
d0efe3f2cc 安全区域改为从 key window 动态获取
- 新增 RDEPUBSafeArea:参考 GKNavigationBarSwift 的 keyWindow 三级查找与临时 window 兜底
- 移除 reflowableContentInsets 中 top/bottom 40 的固定默认值,改按设备真实安全区生成
- 各取安全区处的 ?? .zero 兜底改为回退到 key window 值
- DEBUG 构建下为文本可读区域绘制红色边框,方便查看排版范围

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:04:22 +09:00
shenlei
ca408c20ab 收紧脚注图的 alt 兜底判定
原判定 alt 非空即视为脚注图,导致带 alt 的正常插图(如凡人修仙传的
logo)被缩到一个字号大小。改为 alt 长度达到脚注正文量级(≥8 字符)
且原图为小图标(≤50pt)双条件同时成立才走脚注缩放,与 HTML 归一化
层只认 qqreader-footnote class 的口径对齐。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:16:53 +09:00
shenlei
5ae7823ef8 全量换表前用旧页表回环验证当前位置
初始 partial 窗口被全书页表接管时,live 页码(窗口坐标)与新表解析
页码(全书坐标)不可比,±1 启发式误判后 preserveLivePage 会把模型
位置钉在全书第 1 页,导致位置错乱并级联阻塞后续全表提交。改为换表
前先用旧表解析 currentLocation:回环等于 live 页说明位置忠实反映
屏幕内容,换表后无条件信任新表解析结果;不等时才退回 ±1 门槛,保留
对 stale 持久化位置的防护。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:56:22 +09:00
shenlei
bd6e915fbd 邻接窗口淘汰不再清除前瞻预取章节
scheduleAdjacentChapterPrefetches 的窗口外淘汰会把
maybePrefetchUpcomingChapters 刚预取的后续章节清掉,下一次
prepare 又触发重建,形成"淘汰-重建"循环。淘汰时保留当前章之后
lookahead 范围内的章节,lookahead 数量提为共享常量。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:56:22 +09:00
shenlei
7132e4952b 章节加载器补充三级缓存命中日志
loadChapterWithSnapshot 打印内存章节 HIT、并发加载合流、构建来源
(memoryPageCount/diskSummary/fullRender)与构建耗时,便于判断章节
跳转时预加载与缓存的命中情况。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:34:02 +09:00
shenlei
f796823db8 预加载控制器补充页面视图缓存命中日志
pageViewForDisplay / prime / takePreloadedView 三条取视图路径打印
HIT/MISS 及视图地址,invalidate 打印被清空的缓存页码,便于排查
翻页时页面视图是否命中缓存。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:22:09 +09:00
shenlei
47fe2dc450 页表全量接管改为按子集覆盖判定
fullReplace 从"候选章节数 >= 当前章节数"改为"候选页表必须覆盖当前窗口
的全部章节",避免跳章后 partial map 数量达标但缺少远端已映射章节时,
全量替换丢弃这些条目导致总页数抖动。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:14:48 +09:00
shen
9e91207011 优化 EPUB 翻页与后台分页刷新 2026-07-06 11:08:28 +09:00
shenlei
71f4feb12f Ignore local build caches 2026-07-03 16:13:50 +09:00
shenlei
99bdc98895 Fix reader interaction and web resource handling 2026-07-03 16:12:11 +09:00
shenlei
22e7e44220 feat: chapter runtime refactoring and related updates
- Refactor chapter runtime: replace window coordinator/snapshot with warmup orchestrator
- Update EPUB core: parser, reading session, JS bridge, navigator layout
- Update reader controller: data source, location resolution, persistence
- Update chapter runtime: data cache, loader, runtime store, disk cache, warmup orchestrator
- Remove deprecated navigation state machine and pagination state
- Update text rendering: book cache, HTML normalizer
- Update UI: text content view, dark image adjuster, text selection controller
- Update settings and reader configuration
- Add CODE_REVIEW.md and AUDIT_FINAL.md documentation
- Update pod dependencies (remove SSAlertSwift, SnapKit)
- Update podspec and pod configuration files

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 18:50:07 +09:00
shenlei
b8aa10c535 Add diagnostic logs for total pages not updating investigation
Add RDEPUBBackgroundTrace.log at key decision points in the
reconciliation pipeline to trace why pageMap updates get rejected:

1. refreshBookPageMapInPlace — logs when a full pageMap update is enqueued
2. evaluateTakeover — logs when keepCurrentWindow is returned due to:
   - jumpSession coverage ratio < 0.8
   - adjacent coverage missing (hasPrev/hasNext)
   - renderSignature mismatch
3. evaluateFullPageMapTakeover — logs when keepCurrentWindow is returned due to:
   - currentSpineIndex not in candidate chapters
   - adjacent coverage missing
4. commitPendingPageMapUpdate — logs when keepCurrentWindow decision is
   applied and pending update is removed from queue

Also fix unused variable warning for currentPageNumber in extendPartial case.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 18:57:16 +08:00
shenlei
15b15d0e11 Remove all RDEPUBBackgroundTrace.log and bare print diagnostic calls
Keep only the evaluateFullPageMapTakeover: fullReplace diagnostic log
in RDEPUBPageMapReconciliationCoordinator.swift for future debugging.

Simplified RDEPUBBackgroundTrace to just the log method (removed measure
and debug gating). Removed all [EPUB][...], [ReadViewDemo], and
[EPUB][Pagination] print statements across the project.

Also includes earlier bug fixes:
- Fix stale currentPageNumber in extendPartial commit
- Fix pageCurl rebindVisiblePage during transition
- Fix keepCurrentWindow not removing pending update from queue
- Fix right-aligned text (巫鸿 bug) via avoidPageBreakInside,
  tail merger, and continuation paragraph normalization
- Add text-indent reset for aligned blocks in CSS compatibility layer

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 18:25:22 +08:00
shenlei
8c5059e72f chore: RDEPUBBackgroundTrace 日志默认关闭
Release 构建下完全静默(isEnabled 固定 false),
Debug 构建下通过 _enabled 开关控制,默认开启。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 17:36:28 +08:00
shenlei
2fa58b1026 diag: 总页数未更新问题 — 增加Reconciliation诊断日志 + 修复keepCurrentWindow后pending不移除
1. RDEPUBPageMapReconciliationCoordinator: 在evaluateTakeover、
   evaluateSegmentTakeover、evaluateFullPageMapTakeover的每个返回点
   增加RDEPUBBackgroundTrace日志,记录关键决策参数:
   - currentSpineIndex、candidateChapters/pages、currentChapters/pages
   - hasPrev/hasNext、lastBuildableSpineIndex
   - jumpSession protected coverage ratio
   用于确认fullMap被拒绝的具体原因

2. RDEPUBPresentationRuntime: .keepCurrentWindow分支增加
   removePendingPageMapUpdate(at: index),防止被拒绝的
   reconcileFullMap update无限期留在pending队列中不被消费。
   之前return false不移除,导致:
   - 后续commitPendingPageMapUpdateIfNeeded重试时条件不变
     仍然keepCurrentWindow,形成无效循环
   - 如果用户不再翻页,总页数永远不更新

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 17:33:16 +08:00
shenlei
c555329948 fix: pageCurl翻页后偶尔刷新回章节第一页
根因:.extendPartial pageMap更新提交时使用了异步扩展开始时捕获的
currentPageNumber/currentLocation,在pageCurl模式下用户可能已经翻了几页,
导致过时值通过transitionToPage把用户拉回旧位置。

修复三点:
1. PresentationRuntime: .extendPartial fallback不再使用捕获的
   currentPageNumber,改用readerView.currentPage实时值
2. ChapterWarmupOrchestrator: 异步加载完成回调中使用实时位置
   (readerView.currentPage + locationCoordinator.currentVisibleLocation())
   而非异步开始时捕获的旧值
3. PresentationRuntime: rebindVisiblePage在pageCurl模式下,
   如果正在翻页动画中(isPageCurlTransitioning),延迟到动画结束后
   再用实时currentPage执行transitionToPage,避免在pageNum回调栈
   内同步触发transitionToPage导致重入

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 16:52:38 +08:00
shenlei
d7f28c3f10 fix: 修复右对齐短文本显示不完整及多项功能改进
右对齐文本显示修复(如"巫鸿"只显示"鸿"的问题):
- RDEPUBChapterTailNormalizer: 短尾页合并时检查 avoidPageBreakInside 语义,
  避免将带有此标记的短文本(如 right-info 署名)错误合并回上一页
- RDEPUBTextContentView: 续段归一化时跳过右对齐/居中对齐的段落,
  防止错误修改 firstLineHeadIndent 导致首字不可见
- RDEPUBCSSCompatibilityLayer: 为含 text-align:right/center 的 CSS 块
  自动注入 text-indent:0 !important,确保右对齐/居中文本无首行缩进
- RDEPUBTextRendererSupport: 排版属性归一化时将右对齐/居中段落的
  firstLineHeadIndent 重置为 0

图片查看器及脚注点击功能:
- 新增 RDEPUBImageViewController 和 RDEPUBImageViewerCoordinator,
  支持从 WebView 和 TextPage 两种模式查看图片
- epub-bridge.js: 检测图片和脚注图片的点击事件,脚注图片显示弹窗
- RDEPUBJavaScriptBridge: 新增 imageDidTap/footnoteDidTap 桥接消息
- RDEPUBWebView: 新增图片和脚注点击的 delegate 方法
- RDEPUBAttachmentNormalizer: 改进脚注检测,优先使用 alt 文本判断
- RDEPUBPaginationModels: 新增 .footnote 附件类型
- RDEPUBPageLayoutSnapshot: 运行时动态解析附件类型,脚注优先级高于图片

位置解析改进:
- RDEPUBReaderController+LocationResolution: 利用 rangeInfo 提升页码定位精度

其他:
- RDEPUBTextBookCache: schema 版本升级至 13
- RDEPUBReaderTheme: 主题更新
- Pod 项目文件更新

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 15:39:58 +08:00
shenlei
d15f20b097 feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理
- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip)
- 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示
- 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配
- 新增 RDEPUBSelectionLoupeView 选区放大镜
- 新增 MetadataParseWorker/CancellationController 元数据解析取消机制
- 重构 PresentationRuntime/PaginationCoordinator 精简职责
- 优化 ChapterLoader/WarmupOrchestrator 异步章节加载
- CFI 模块微调与 NoteModels 更新
- 清理冗余文档,更新架构/UML/业务逻辑文档

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 17:47:24 +08:00
shen
7de661eb54 feat: 架构整改 — Context拆分、Runtime拆分、异步章节加载、UI测试覆盖
Phase 1: Context 拆分
- 新增 RDEPUBReaderState/RDEPUBReaderEnvironment/RDEPUBReaderServices
- RDEPUBReaderContext 改为过渡门面,代理到 State/Environment/Services

Phase 2: Runtime 拆分
- 新增 RDEPUBPresentationRuntime 处理分页状态管理
- 新增 RDEPUBChapterWarmupOrchestrator 处理章节预热与加载编排
- RDEPUBReaderRuntime 从 1277 行收缩,公共 API 转发到新 facade

Phase 0.5: 性能优化
- prepareOnDemandChapter 支持异步模式(allowSynchronousLoad: false)
- extendPartialBookPageMapIfNeeded 改为 DispatchGroup 并发加载
- RDEPUBChapterOffsetMap.cfiMap 加 NSLock 保护数据竞争
- CFI Map 构建延迟到后台队列(scheduleDeferredCFIMapBuildIfNeeded)
- RDEPUBTextPageRenderView 引入静态位图缓存
- RDEPUBTextContentView 新增 loadingSpinner 占位页

Phase 3: 状态机
- 新增 RDEPUBNavigationStateMachine(含 DEBUG 合法转换校验)
- 新增 RDEPUBPaginationState 记录分页来源

Review 修复
- makeSummary 重复方法合并
- ensureNavigationTargetAvailable 同步路径加注释标记

UI 测试
- 新增 AsyncChapterLoadingTests(20 个测试,覆盖全部架构整改场景)
- 跨章节翻页、延迟 CFI、状态机、内存警告、预加载、位置恢复等
2026-06-23 08:17:08 +08:00
shenlei
c65c190b71 feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
2026-06-22 20:26:34 +08:00
shenlei
f50495ad91 fix: CFI module — 11 issues from code review
Phase 1 (correctness):
- #1: rawValue changed from stored to computed property, eliminating stale cache risk
- #2: tokenSamples/textMarker unified to UTF-16 offsets, fixing emoji/CJK positioning
- #3: makeOffsetCFI now accepts optional contentPath parameter

Phase 2 (robustness):
- #4: Resolver uses fixed index access instead of last(where:) for manifest/spine steps
- #5: HTML comments and CDATA stripped before regex matching
- #6: Text assertion parser handles backslash escapes (\[ \] \)
- #7: Token matching uses prefix/suffix with length ratio constraints
- #8: makeOffsetRangeCFI validates startOffset <= endOffset

Phase 3 (code quality):
- #9: Shared internal nilIfEmpty extension in RDEPUBCFIUtilities.swift
- #10: RDEPUBCFIMap has markerByPath index dictionary for O(1) lookup
- #11: parseRange unified to use try (not try?) for parent parsing

Review fixes:
- Documented init(rawValue:) parameter is ignored (computed property)
- Fixed escape unescaping to handle \\ → \ correctly
- Lowered token minLength threshold from 4 to 2

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 13:25:17 +08:00
shenlei
6ed3fcb071 feat: 设置面板与章节运行时联动优化
- 设置页打开时仅重新计算当前章节预览,关闭后触发完整补全
- 新增 preview 加载优先级,支持设置页内当前章预览
- 添加设置页防抖机制,合并连续字号/行距变更
- 支持下滑手势关闭设置页,通过 presentation delegate 通知运行时
- 重新分页时跳过导航和尺寸校验,避免状态冲突
- currentTextPageSize 线程安全优化,后台线程使用缓存尺寸
- 更新 CocoaPods 依赖

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 09:47:50 +08:00
shenlei
c64460988a feat: 实现大书远距目录跳转与后台补全优化方案
Phase 1: 稳定性优先
- 新增 RDEPUBJumpSession 保护机制,防止远距跳转后翻页串章
- 升级页图接管条件,增加 JumpSession 保护区检查
- 窗口扩展改为基于当前权威窗口方向

Phase 2: 补全优先级重排
- 新增 RDEPUBBackgroundPriorityPolicy 策略配置
- 实现 hot/warm/cold zone 优先级排序
- 添加失败重试机制(指数退避,最多3次)

Phase 3: 分段覆盖与最终收敛
- 新增 RDEPUBBackgroundCoverageStore 分段存储
- 新增 RDEPUBPageMapReconciliationCoordinator 页图接管仲裁
- 实现 LRU 淘汰和内存警告处理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 16:28:11 +08:00
shenlei
e976ceebd5 chore: add .artifacts/ to .gitignore
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 08:38:09 +08:00
371 changed files with 27809 additions and 19872 deletions

23
.gitignore vendored
View File

@ -1,3 +1,26 @@
# Xcode user data
xcuserdata/
# Build artifacts
.artifacts/
.deriveddata/
.swift-module-cache/
# This repository checks in the Demo Pods project. Keep generated files for the
# local RDEpubReaderView pod visible even when a global gitignore excludes Pods.
!ReadViewDemo/Pods/
!ReadViewDemo/Pods/Local Podspecs/
!ReadViewDemo/Pods/Local Podspecs/RDEpubReaderView.podspec.json
!ReadViewDemo/Pods/Target Support Files/
!ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/
!ReadViewDemo/Pods/Target Support Files/RDEpubReaderView/**
# macOS system files
.DS_Store
# Local backup directories
*手动解包备份*
# AI tool output
_ssoft-output/

151
CODE_REVIEW.md Normal file
View File

@ -0,0 +1,151 @@
# ReadViewSDK 代码审查报告
> 审查日期2026-06-26
> 审查范围Sources/RDEpubReaderView 全部源码
> 审查方法:逐文件阅读 + 交叉验证 + 线程模型分析
---
## ✅ 确认成立的问题
### P1-1 WKURLSchemeHandler 同步文件 I/O 阻塞调用线程
**文件**: `EPUBCore/RDEPUBResourceURLSchemeHandler.swift:51-174`
**现象**: `webView(_:start:)` 回调中,所有文件读取均同步执行:
- `respondWithInMemoryData`第119行使用 `Data(contentsOf:)` 全量读入 ≤512KB 的文件
- `respondWithStreaming`第143行`while true` 循环中同步读 64KB 块并逐块回调 `urlSchemeTask.didReceive(data)`
Apple 未保证 WKURLSchemeHandler 回调线程为主线程,实际在 WKWebView 内部队列上执行。同步阻塞该线程会:
- 若在主线程:直接造成 UI 卡顿/ANR
- 若在 WKWebView 内部队列:阻塞资源加载管线,影响渲染时序
**建议**: 将文件读取移至后台队列,通过 `DispatchQueue.main.async` 或回调队列回传 `didReceive`/`didFinish`。流式场景可改为分批异步读取。
---
### P1-2 EPUB 解压缓存目录无清理策略
**文件**: `EPUBCore/RDEPUBParser+Archive.swift:79-90`
**现象**: `temporaryExtractionDirectory(for:)``(slug)-(fileSize)-(modificationTimestamp)` 生成缓存目录,存放在 `Caches/ssreaderview-epub/`。全局搜索确认不存在任何清理逻辑——无 LRU 淘汰、无总量上限、无 `removeItem` 调用指向该目录。
一本 50MB 的 EPUB 解压后约 100-200MB用户打开多本书后缓存目录无限增长。
**建议**: 添加缓存清理策略:
1. 提供 `clearCache()` 公开方法供宿主 App 在低存储时调用
2. 每次打开新书时按访问时间淘汰超出上限的旧缓存
3. 或采用 `FileManager.default.urls(for: .cachesDirectory:)` 依赖系统自动清理
---
### P1-3 搜索在主线程同步执行
**文件**: `EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift:15-36`
**现象**: `search(keyword:)` 直接调用 `resolvedSearchMatches(for:)`,该方法遍历整本书的所有 spine 条目执行 HTML→文本转换或章节同步加载 + NSRange 搜索,全程在主线程完成。
对于大型 EPUB数百章
- UI 完全冻结直到搜索完成
- 用户无法取消或看到进度
- 可能触发 iOS 看门狗终止0x8badf00d
`resolvedOnDemandSearchMatches(for:)`第125行更严重它同步加载每个章节`loadChapterSynchronouslyForMigration`),整本书搜索意味着逐章同步加载。
**建议**: 将搜索逻辑移至后台队列,对大型书籍实施分批搜索 + 渐进结果回调,并在 UI 层添加搜索进度指示和取消能力。
---
### P2-1 ZIP 无效条目导致整本 EPUB 打开失败
**文件**: `EPUBCore/RDEPUBParser+Archive.swift:41-44`
**现象**: 解压循环中,若 `validatedExtractionDestination` 返回 `nil`(条目路径验证失败),直接 `throw` 终止整本书的解析。实际 EPUB 中常包含无害条目macOS `__MACOSX/` 目录、`.DS_Store`、Thumbs.db 等),这些不应阻止打开。
```swift
for entry in archive {
guard let destinationURL = validatedExtractionDestination(for: entry.path, ...) else {
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path) // 应改为 continue
}
```
**建议**: 将验证失败的条目改为 `continue` + 日志警告,并增加对已知无害条目路径的跳过逻辑。
---
### P2-2 javaScriptStringLiteral 实现依赖隐式假设
**文件**: `EPUBCore/RDEPUBJavaScriptBridge.swift:169-174`
**现象**: `javaScriptStringLiteral` 将值包装进单元素数组做 JSON 编码,再暴力去掉 `[``]`
```swift
private static func javaScriptStringLiteral(_ value: String?) -> String {
guard let value else { return "null" }
return jsonString(from: [value], fallback: "[null]")
.replacingOccurrences(of: "[", with: "")
.replacingOccurrences(of: "]", with: "")
}
```
这不是 XSS/注入问题(`JSONSerialization` 已正确转义引号/反斜杠等危险字符),但该方法依赖"值不含 `[``]`"的隐式假设。当前调用场景传入的是 CSS 颜色值(如 `#FFFFFF`、`rgba(...)`),不含方括号,所以实际安全。但未来如果有人将包含方括号的值传入,输出会被静默破坏。
**建议**: 改为更直观的安全编码方式,如直接对单值做 `JSONSerialization` 后去掉两端引号,或添加文档注释明确标注此方法的约束前提。
---
### P3-1 后台线程同步 hop 主线程获取布局信息
**文件**: `EPUBUI/ReaderController/RDEPUBReaderContext.swift:160-186`
**现象**: `currentTextPageSize()` 在非主线程且缓存不可用时,通过 `DispatchQueue.main.sync` 回到主线程获取布局信息。调用方包括 `RDEPUBMetadataParseWorker`后台初始化第72行`RDEPUBChapterLoader``chapterLoadQueue.async`第298行
```swift
func currentTextPageSize() -> CGSize {
if Thread.isMainThread {
// 主线程路径 — 安全
...
} else if let lastTextPaginationPageSize, ... {
// 缓存路径 — 无需 hop
return lastTextPaginationPageSize
} else {
let mainThreadSize = DispatchQueue.main.sync { [weak self] in
self?.currentTextPageSize() ?? .zero
}
```
`Thread.isMainThread` 保护使得从主线程调用不会死锁。实际风险是**时序耦合**:后台任务阻塞等待主线程布局信息,如果主线程正在忙于 UI 操作(如翻页动画),后台线程会被卡住直到主线程空闲。这是一个线程模型/可维护性问题,而非可直接触发的死锁。
**建议**: 将布局参数作为初始化参数传入后台任务,而非在后台线程通过 `DispatchQueue.main.sync` 获取。这样后台线程完全自主,不依赖主线程时序。
---
## ✅ 已撤回的原始误判
以下条目经逐条代码验证后确认不成立或定级过高:
| 原始编号 | 原始结论 | 修正结论 |
|---------|---------|---------|
| ~~P0 引用循环~~ | Runtime/Coordinator 强引用 context 形成循环 | **不成立**。全量搜索确认所有 Coordinator/Runtime 均使用 `unowned let context``weak var context` |
| ~~P0 XSS 注入~~ | `javaScriptStringLiteral``]` 替换导致注入 | **不成立**。`JSONSerialization` 编码已处理引号/反斜杠等危险字符。实际是健壮性问题,非安全问题(已调整为 P2-2 |
| ~~P1 Publication 暴露 parser~~ | 外部可直接修改 parser 内部状态 | **不成立**。`RDEPUBParser` 关键属性均为 `public internal(set)`,外部模块无法修改 |
| ~~P2 NSRange 越界~~ | UTF-16 和 Swift String 混用导致偏移 | **不成立**。搜索代码全程在 `NSString` + `NSRange` 域内操作,未混合 Swift String 索引 |
| ~~P2 后台 deinit 崩溃~~ | teardownWebView 可能在后台线程执行 | **证据不足**。`RDEPUBWebView` 是 UIView 子类,正常在主线程释放,无证据表明会后台释放 |
| ~~P0 ZIP 路径遍历绕过~~ | `%2e%2e` 可绕过路径校验 | **论证过头**。当前实现有组件级 `..` 检查 + `standardizedFileURL` 前缀校验,双层防护有效 |
---
## 📊 问题汇总
| 严重度 | 编号 | 问题 | 类型 |
|--------|------|------|------|
| 🟠 P1 | P1-1 | WKURLSchemeHandler 同步文件 I/O 阻塞调用线程 | 性能 |
| 🟠 P1 | P1-2 | EPUB 解压缓存目录无清理策略 | 存储 |
| 🟠 P1 | P1-3 | 搜索在主线程同步执行,大书可致 ANR | 性能 |
| 🟡 P2 | P2-1 | ZIP 无效条目导致整本打开失败 | 鲁棒性 |
| 🟡 P2 | P2-2 | javaScriptStringLiteral 实现依赖隐式假设 | 可维护性 |
| 🔵 P3 | P3-1 | 后台线程同步 hop 主线程获取布局信息 | 线程模型 |
**整体评价**: SDK 架构设计良好模块分层清晰引用管理unowned/weak使用正确分页取消机制有 `paginationToken` + `cancellationController` 兜底。主要值得修复的是三个性能类 P1 问题(主线程阻塞搜索、资源加载阻塞、缓存无清理),两个 P2 鲁棒性/可维护性问题,以及一个 P3 线程模型优化建议。

483
Doc/API_REFERENCE.md Normal file
View File

@ -0,0 +1,483 @@
# 公开 API 参考手册
> 最后更新2026-06-18
本文档列出 ReadViewSDK 所有公开 API 的完整签名、参数说明和使用方法。
---
## 1. RDEPUBReaderController — 入口控制器
**文件**`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift` 及扩展
### 1.1 初始化
```swift
// EPUB 文件初始化
init(
epubURL: URL, // EPUB 文件路径
configuration: RDEPUBReaderConfiguration = .default, // 阅读配置
persistence: RDEPUBReaderPersistence? = nil, // 持久化实现
dependencies: RDEPUBReaderDependencies? = nil // 依赖注入
)
// 预构建 TextBook 初始化(如 .txt 文件)
init(
textBook: RDEPUBTextBook, // 预构建的文本书籍
bookIdentifier: String, // 书籍唯一标识
title: String, // 书籍标题
textFileURL: URL? = nil, // 原始文件路径
configuration: RDEPUBReaderConfiguration = .default,
persistence: RDEPUBReaderPersistence? = nil,
dependencies: RDEPUBReaderDependencies? = nil
)
```
### 1.2 公开属性
| 属性 | 类型 | 读写 | 说明 |
|------|------|------|------|
| `delegate` | `RDEPUBReaderDelegate?` | 读写 | 委托代理 |
| `configuration` | `RDEPUBReaderConfiguration` | 读写 | 阅读配置,设置后触发重新分页 |
| `currentLocation` | `RDEPUBLocation?` | 只读 | 当前阅读位置 |
| `currentPageNumber` | `Int?` | 只读 | 当前页码1-based |
| `currentSelection` | `RDEPUBSelection?` | 只读 | 当前文本选区 |
| `highlights` | `[RDEPUBHighlight]` | 只读 | 所有高亮标注 |
| `bookmarks` | `[RDEPUBBookmark]` | 只读 | 所有书签 |
| `annotations` | `[RDEPUBAnnotation]` | 只读 | 合并排序后的统一标注列表 |
| `tableOfContents` | `[EPUBTableOfContentsItem]` | 只读 | 目录树(嵌套结构) |
| `flattenedTableOfContents` | `[RDEPUBReaderTableOfContentsItem]` | 只读 | 扁平化目录列表 |
| `currentTableOfContentsItem` | `RDEPUBReaderTableOfContentsItem?` | 只读 | 当前所在目录项 |
### 1.3 导航方法
```swift
/// 重新加载书籍
func reloadBook()
/// 跳转到指定位置
func go(to location: RDEPUBLocation)
/// 跳转到指定页码1-based
/// - Returns: 是否跳转成功
@discardableResult
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool
/// 跳转到目录项EPUBTableOfContentsItem
@discardableResult
func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool
/// 跳转到目录项RDEPUBReaderTableOfContentsItem
@discardableResult
func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool
/// 跳转到目录 href支持 fragment如 "chapter1.xhtml#section2"
@discardableResult
func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool
/// 跳转到指定高亮位置
@discardableResult
func go(toHighlightID id: String, animated: Bool = true) -> Bool
/// 跳转到指定书签位置
@discardableResult
func go(toBookmarkID id: String, animated: Bool = true) -> Bool
```
### 1.4 高亮方法
```swift
/// 添加高亮(使用当前选区或指定选区)
/// - Parameters:
/// - selection: 选区信息nil 时使用 currentSelection
/// - color: 高亮颜色(十六进制),默认 "#F8E16C"
/// - note: 可选备注
/// - Returns: 创建的高亮对象,失败返回 nil
@discardableResult
func addHighlight(
from selection: RDEPUBSelection? = nil,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight?
/// 添加标注(支持高亮和下划线样式)
@discardableResult
func addAnnotation(
from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight?
/// 更新或插入高亮
@discardableResult
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight?
/// 删除高亮
@discardableResult
func removeHighlight(id: String) -> RDEPUBHighlight?
/// 更新高亮备注
@discardableResult
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight?
/// 删除所有高亮
func removeAllHighlights()
/// 根据 ID 查找高亮
func highlight(withID id: String) -> RDEPUBHighlight?
```
### 1.5 书签方法
```swift
/// 添加书签(使用当前位置)
@discardableResult
func addBookmark(note: String? = nil) -> RDEPUBBookmark?
/// 切换书签状态(已存在则删除,不存在则添加)
@discardableResult
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark?
/// 删除书签
@discardableResult
func removeBookmark(id: String) -> RDEPUBBookmark?
/// 根据 ID 查找书签
func bookmark(withID id: String) -> RDEPUBBookmark?
```
### 1.6 搜索方法
```swift
/// 开始搜索
func search(keyword: String)
/// 跳转到下一个匹配
/// - Returns: 是否有下一个匹配
@discardableResult
func searchNext() -> Bool
/// 跳转到上一个匹配
@discardableResult
func searchPrevious() -> Bool
/// 清除搜索
func clearSearch()
```
### 1.7 其他方法
```swift
/// 清除当前文本选区
func clearSelection()
/// 获取当前页面的语义摘要(调试用)
func nativeTextSemanticSummary() -> String?
```
---
## 2. RDEPUBReaderDelegate — 委托协议
**文件**`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderDelegate.swift`
所有方法均有默认空实现,可按需实现。
```swift
public protocol RDEPUBReaderDelegate: AnyObject {
/// 书籍打开完成
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
/// 阅读位置变化
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
/// 到达书籍末尾
func epubReaderDidReachEnd(_ reader: UIViewController)
/// 文本选区变化nil 表示取消选中)
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
/// 高亮列表变化
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
/// 书签列表变化
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
/// 搜索结果更新
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
/// 当前搜索匹配项变化
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
/// 当前目录项变化
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
/// 外部链接被点击
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
/// 是否允许打开外部链接(返回 false 拦截)
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
/// 错误回调
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
/// 配置顶部工具栏(自定义按钮等)
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
}
```
---
## 3. RDEPUBReaderPersistence — 持久化协议
**文件**`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
```swift
public protocol RDEPUBReaderPersistence: AnyObject {
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
func loadReaderSettings() -> RDEPUBReaderSettings?
func saveReaderSettings(_ settings: RDEPUBReaderSettings)
}
```
默认实现 `RDEPUBUserDefaultsPersistence` 使用 UserDefaults 存储:
| 数据 | Key 格式 |
|------|----------|
| 阅读位置 | `ssreader.epub.location.{bookID}` |
| 书签 | `ssreader.epub.bookmarks.{bookID}` |
| 高亮 | `ssreader.epub.highlights.{bookID}` |
| 全局设置 | `ssreader.epub.settings` |
---
## 4. RDEPUBReaderConfiguration — 配置模型
**文件**`Sources/RDEpubReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift`
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `fontSize` | `CGFloat` | `15` | 字体大小pt |
| `lineHeightMultiple` | `CGFloat` | `1.6` | 行距倍数 |
| `fontChoice` | `RDEPUBReaderFontChoice` | `.system` | 字体选择system/serif/rounded/monospaced |
| `numberOfColumns` | `Int` | `1` | 每页列数1 或 2 |
| `columnGap` | `CGFloat` | `20` | 列间距 |
| `displayType` | `RDEpubReaderView.DisplayType` | `.pageCurl` | 翻页模式 |
| `landscapeDualPageEnabled` | `Bool` | `true` | 横屏双页 |
| `showsTableOfContents` | `Bool` | `true` | 显示目录 |
| `allowsHighlights` | `Bool` | `true` | 允许高亮 |
| `showsSettingsPanel` | `Bool` | `true` | 显示设置面板 |
| `reflowableContentInsets` | `UIEdgeInsets` | `(40,16,40,16)` | 重排内容内边距 |
| `fixedContentInset` | `UIEdgeInsets` | `.zero` | 固定布局内边距 |
| `theme` | `RDEPUBReaderTheme` | `.light` | 阅读主题 |
| `darkImageAdjustmentEnabled` | `Bool` | `true` | 暗色模式图片调整 |
| `darkImageBlendRatio` | `CGFloat` | `0.15` | 暗色图片混合比例0-0.35 |
| `fixedLayoutFit` | `RDEPUBFixedLayoutFit` | `.page` | 固定布局适配方式 |
| `fixedLayoutSpreadMode` | `RDEPUBFixedLayoutSpreadMode` | `.automatic` | 固定布局跨页模式 |
| `textRenderingEngine` | `RDEPUBTextRenderingEngine` | `.dtCoreText` | 文本渲染引擎 |
| `onDemandChapterWindowSize` | `Int` | `3` | 按需加载窗口大小奇数3-15 |
| `metadataParsingConcurrency` | `Int` | CPU 核心数 | 后台解析并发数 |
| `jumpSessionPolicy` | `RDEPUBJumpSessionPolicy` | `.default` | 远距跳转策略 |
| `allowedExternalURLSchemes` | `Set<String>` | `["https"]` | 允许的外部 URL 协议 |
| `requiresExternalLinkConfirmation` | `Bool` | `true` | 外部链接需确认 |
| `allowsInspectableWebViews` | `Bool` | `false` | WebView 可调试 |
| `enablesVerboseWebViewLogging` | `Bool` | `false` | WebView 详细日志 |
**计算属性**
- `chapterWindowRadius: Int` — 内存缓存窗口半径(`onDemandChapterWindowSize / 2`
---
## 5. 翻页容器协议
**文件**`Sources/RDEpubReaderView/ReaderView/RDEpubReaderViewProtocols.swift`
### 5.1 RDEpubReaderPageProvider推荐
```swift
@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
/// 总页数
func numberOfPages(in readerView: RDEpubReaderView) -> Int
/// 返回指定页的视图
/// - Parameters:
/// - index: 页码索引0-based
/// - reusableView: 可复用的旧视图(可能为 nil
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
/// 页面标识符(用于缓存去重)
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
/// 顶部工具栏视图
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
/// 底部工具栏视图
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
}
```
### 5.2 RDEpubReaderDelegate
```swift
@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
/// 页面变化回调
func pageNum(readerView: RDEpubReaderView, pageNum: Int)
/// 屏幕方向即将变化
@objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
}
```
### 5.3 RDEpubReaderPageNavigating
```swift
public protocol RDEpubReaderPageNavigating: AnyObject {
/// 当前页码
var currentPage: Int { get }
/// 重新加载所有页面
func reloadPages()
/// 跳转到指定页
func transition(to page: Int, animated: Bool)
}
```
### 5.4 RDEpubReaderDataSource遗留
```swift
@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
@objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
@objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
}
```
通过 `RDEpubReaderLegacyDataSourceAdapter` 自动适配到 `RDEpubReaderPageProvider`
---
## 6. 数据模型速查
### 6.1 RDEPUBLocation
```swift
public struct RDEPUBLocation: Codable, Equatable {
public var bookIdentifier: String? // 书籍标识
public var href: String // 章节文件路径
public var progression: Double // 章节内进度0.0-1.0
public var lastProgression: Double? // 上次进度(用于恢复方向)
public var fragment: String? // 片段 ID#section1
public var rangeAnchor: RDEPUBTextRangeAnchor? // 文本范围锚点
}
```
### 6.2 RDEPUBBookmark
```swift
public struct RDEPUBBookmark: Codable, Equatable, Identifiable {
public let id: String // 唯一标识
public let bookIdentifier: String?
public let location: RDEPUBLocation
public let chapterTitle: String?
public let note: String?
public let createdAt: Date
}
```
### 6.3 RDEPUBHighlight
```swift
public struct RDEPUBHighlight: Codable, Equatable, Identifiable {
public let id: String
public let bookIdentifier: String?
public let location: RDEPUBLocation
public let text: String // 高亮文本内容
public let style: RDEPUBHighlightStyle // .highlight / .underline
public let color: String // 十六进制颜色
public let note: String?
public let rangeInfo: String? // CoreText 选区范围信息
public let createdAt: Date
public var uiColor: UIColor { ... } // 计算属性
}
```
### 6.4 RDEPUBAnnotation
```swift
public struct RDEPUBAnnotation: Codable, Equatable, Identifiable {
public let id: String
public let bookIdentifier: String?
public let kind: RDEPUBAnnotationKind // .bookmark / .highlight / .underline
public let location: RDEPUBLocation
public let text: String?
public let rangeInfo: String?
public let chapterTitle: String?
public let createdAt: Date
public var bookmark: RDEPUBBookmark? { ... } // kind == .bookmark 时有值
public var highlight: RDEPUBHighlight? { ... } // kind == .highlight/.underline 时有值
}
```
### 6.5 RDEPUBSelection
```swift
public struct RDEPUBSelection: Equatable {
public let bookIdentifier: String?
public let location: RDEPUBLocation
public let text: String // 选中文本
public let rangeInfo: String? // CoreText 选区范围
public let createdAt: Date
}
```
### 6.6 EPUBPage / EPUBChapterInfo
```swift
public struct EPUBPage: Equatable {
public let spineIndex: Int
public let chapterIndex: Int
public let pageIndexInChapter: Int
public let totalPagesInChapter: Int
public let chapterTitle: String
public let fixedSpread: EPUBFixedSpread?
}
public struct EPUBChapterInfo: Equatable {
public let spineIndex: Int
public let title: String
public let pageCount: Int
}
```
### 6.7 RDEPUBSearchResult / RDEPUBSearchMatch
```swift
public struct RDEPUBSearchResult: Equatable {
public let keyword: String
public let matches: [RDEPUBSearchMatch]
}
public struct RDEPUBSearchMatch: Equatable {
public let spineIndex: Int
public let progression: Double
public let previewText: String
public let rangeAnchor: RDEPUBTextRangeAnchor?
}
```

View File

@ -1,115 +0,0 @@
# 架构分析WXRead 参考 vs ReadViewSDK 当前
**分析日期:** 2026-05-23
**分析基础:** `Doc/WXRead/decompiled-doc.md`、`Doc/WXRead/resources-doc.md`、`Doc/WXRead/读书EPUB阅读器实现架构.md`
**状态:** Decisions captured
---
<domain>
## 分析范围
对比读书 (WeRead v10.0.3) 逆向架构与当前 ReadViewSDK 项目的结构性差距聚焦四大方向分页引擎集成、CSS 处理、渲染架构、字体系统。
读书 EPUB 渲染的核心架构特征:
- Path A (EPUB): 纯 CoreText 渲染 — `WRPageView.drawRect:``CTFrameDraw`,不用 UITextView/UILabel
- 4 级语义断页:`WRCoreTextLayouter` + `WRCoreTextLayoutFrame` (语义边界 > 附件边界 > 块边界 > 帧限制)
- 5 层 CSS 级联:`default.css < replace.css < dark.css < EPUB 内嵌 < 用户设置`
- 字符级位置精度:`WREpubPositionConverter` (fileIndex, row, column) ↔ 全局字符偏移
- 标注直接在 CTFrame 层叠加绘制,搜索高亮同理
</domain>
<decisions>
## 已决事项
### Area 1: 分页引擎集成P0
| 决策 | 说明 |
|------|------|
| 将 `RDEPUBTextLayouter` 集成到 `RDEPUBTextBookBuilder` | 内部调用 `rd_paginatedFrames(size:)` 替代旧的 `ss_pageRanges(size:)`,外部 API 不变 |
| 分页元数据暴露到 `RDEPUBTextPage` | 新增 `metadata: RDEPUBTextPageMetadata` 字段(含 breakReason/blockKinds/semanticHints对外暴露分页质量数据 |
| `RDEPUBTextChapterPaginationDiagnostic` 增强 | 透传 RDEPUBTextLayoutFrame 的诊断信息 |
| 分页缓存 | 按 `bookID + fontSize + lineHeightMultiple + contentInsets` 生成缓存 key缓存完整 `RDEPUBTextBook` 到磁盘 |
### Area 2: CSS `<link>` 外部样式表处理P0
| 决策 | 说明 |
|------|------|
| 预处理内联 CSS | 渲染前扫描 HTML 中 `<link>` 标签,从 EPUB 解压目录读取 CSS 内容,注入 `<style>` 替换 `<link>` |
| 实现 5 层 CSS 级联 | `RDEPUBTextStyleSheetBuilder` 实现 `default < replace < dark < epub-embedded < user` 五层合并,使用已定义的 `RDEPUBTextStyleSheetPackage`/`RDEPUBTextStyleSheetLayer` |
### Area 3: CoreText 直接绘制迁移P1 — 大架构变更)
| 决策 | 说明 |
|------|------|
| 直接替换 UITextView | 新实现完全替代 `RDEPUBTextContentView`,不保留 UITextView 渐进迁移路径 |
| CoreText 原生选区 | `CTLineGetStringIndexForPosition` 坐标 hit test + 自定义选区绘制,不依赖 UITextView 选区 |
| 标注渲染CGContext 装饰层 | drawRect 中 CTFrameDraw 绘制文本后,遍历当前页 RDEPUBHighlightCGContext 绘制背景矩形highlight/ 下划线underline |
| 搜索高亮CGContext 叠加绘制 | 不修改底层 attributedString在 drawRect 中根据匹配范围直接绘制高亮背景 |
### Area 4: 字体系统P1 — 延迟到后续版本)
| 决策 | 说明 |
|------|------|
| 方案:内嵌固定字体集 | SDK bundle 内嵌常用中文字体Settings 面板新增字体选择。不做 CDN 动态下载 |
| 本版本范围:暂不做 | 字体切换功能延迟到后续迭代 |
</decisions>
<canonical_refs>
## 关键参考文档
**WXRead 逆向参考(必须阅读):**
- `Doc/WXRead/decompiled-doc.md` — 44 个逆向文件的职责说明
- `Doc/WXRead/resources-doc.md` — CSS/JS 资源文件清单与职责
- `Doc/WXRead/读书EPUB阅读器实现架构.md` — 双渲染引擎架构总览
- `Doc/WXRead/decompiled/WRCoreTextLayoutFrame.m` — 跨页避让、装饰元素、搜索高亮实现
- `Doc/WXRead/decompiled/WRCoreTextLayouter.m` — 4 级语义断页配置
- `Doc/WXRead/decompiled/WRPageView.m` — CoreText 直接绘制参考
- `Doc/WXRead/decompiled/WREpubTypesetter.m` — CSS 级联合并参考
- `Doc/WXRead/resources/css/replace.css` — 5 层 CSS 参考
**当前项目代码(已有的基础设施):**
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift` — 已实现 4 级语义断页,待集成
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayoutFrame.swift` — 帧模型,含 breakReason/semanticHints
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift` — 已定义 `RDEPUBTextStyleSheetPackage`/`RDEPUBTextStyleSheetLayer`,未完全使用
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` — 当前可能仍在用旧的 `ss_pageRanges`
- `Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift` — UITextView 实现,待替换为 CoreText
</canonical_refs>
<roadmap_mapping>
## 与现有 Roadmap 的映射
| 决策 | 对应 Phase | 说明 |
|------|-----------|------|
| RDEPUBTextLayouter 集成 | Phase 8 (08-02) | 分页质量改善的核心 |
| 分页元数据暴露 | Phase 8 (08-03) | 分页诊断输出的一部分 |
| 分页缓存 | Phase 8 (08-01) | 缓存键与失效策略 |
| CSS `<link>` 内联 | Phase 7 范畴外 | 当前 Roadmap 未覆盖,需新增 phase 或合并到 Phase 8 |
| 5 层 CSS 级联 | Phase 7 + Phase 8 | Phase 7 已完成属性闭环,级联是后续增强 |
| **CoreText 直接绘制** | **v1.2 或 v2.0** | **超出 v1.1 Roadmap需要独立 milestone** |
| CoreText 原生选区 | 随 CoreText 迁移 | 同上 |
| 标注 CGContext 绘制 | 随 CoreText 迁移 | 同上 |
| 字体系统 | 未来版本 | 延迟 |
**关键发现:** CoreText 直接绘制迁移是最大的架构变更,超出 v1.1 的增量改进范围。建议作为 v1.2 独立 milestone 规划。
</roadmap_mapping>
<deferred>
## 延迟事项
- **字体系统**:内嵌固定字体集方案已决,延迟到后续版本
- **位置精度提升**字符级锚点P2与 CoreText 迁移关联
- **章节数据模型内聚**WRChapterData 模式P2架构清晰度改进
- **TTS / DRM / Pencil / 多栏排版**P3独立功能模块
- **翻页控制器健壮性**UIPageViewController crash patchP2当前未遇到相关崩溃
</deferred>
---
*分析范围WXRead 逆向文档 → ReadViewSDK 架构差距*
*产出4 个 Area、14 项决策、1 项延迟*

View File

@ -1,6 +1,6 @@
# ReadViewSDK 系统架构文档
> 最后更新2026-06-09
> 最后更新2026-06-22
---
@ -22,7 +22,7 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK支持文本重排Reflowable
│ ReadViewDemo (Demo App) │
│ ViewController · LaunchAutomationPlan · UITests │
└──────────────────────────────┬──────────────────────────────┘
│ imports RDReaderView
│ imports RDEpubReaderView
┌──────────────────────────────▼──────────────────────────────┐
│ EPUBUI 层 │
│ RDEPUBReaderController · Coordinators · Settings · TextPage │
@ -30,8 +30,8 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK支持文本重排Reflowable
└──────────────────────────────┬──────────────────────────────┘
┌──────────────────────────────▼──────────────────────────────┐
│ RDReaderView 层 │
│ RDReaderView · FlowLayout · PreloadController │
│ RDEpubReaderView 层 │
│ RDEpubReaderView · FlowLayout · PreloadController │
│ SpreadResolver · TapRegionHandler · PagingController │
└──────────────────────────────┬──────────────────────────────┘
@ -54,7 +54,7 @@ ReadViewSDK 是一个 iOS EPUB 阅读器 SDK支持文本重排Reflowable
|---|---|---|
| **EPUBCore** | EPUB 文件解析、资源管理、WebView 渲染、JS 桥接 | `RDEPUBParser`, `RDEPUBPublication`, `RDEPUBWebView` |
| **EPUBTextRendering** | HTML→NSAttributedString 转换、排版、分页、全书构建 | `RDEPUBTextBookBuilder`, `RDEPUBCoreTextPageFrameFactory` |
| **RDReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDReaderView`, `RDReaderFlowLayout`, `RDReaderPreloadController` |
| **RDEpubReaderView** | 通用翻页容器、手势识别、页面预加载、双页布局 | `RDEpubReaderView`, `RDEpubReaderFlowLayout`, `RDEpubReaderPreloadController` |
| **EPUBUI** | 阅读器控制器、协调器模式、设置面板、按需加载、磁盘缓存 | `RDEPUBReaderController`, `RDEPUBReaderPaginationCoordinator` |
---
@ -105,7 +105,7 @@ paginateTextPublication()
│ ├─ makePartialPageMap() // 构建局部 BookPageMap
│ └─ applyBookPageMap() // 应用到 UI用户可立即阅读
└─ paginateMetadataOnly() // 后台元数据解析
└─ RDEPUBMetadataParseWorker.start() // 后台元数据解析
├─ 预计算 contentHash串行 // 读取所有章节 HTML + SHA-256
├─ readAll(keys:) 恢复已有缓存
@ -201,43 +201,81 @@ struct RDEPUBChapterCacheKey: Hashable {
---
## 5. 章节按需加载架构
## 5. ReaderController 组合架构
```
RDEPUBReaderController
├─ RDEPUBReaderContext // 共享状态中心
│ ├─ parser, publication, readingSession
│ ├─ configuration, persistence
│ └─ 便捷方法 (renderStyle, layoutConfig, cacheKey)
├─ RDEPUBReaderContext过渡门面
│ ├─ RDEPUBReaderState
│ │ ├─ parser / publication / readingSession
│ │ ├─ textBook / bookPageMap / pendingFullPageMap
│ │ ├─ activeBookmarks / activeHighlights / searchState
│ │ └─ currentSelection / paginationToken / snapshot
│ │
│ ├─ RDEPUBReaderEnvironment
│ │ ├─ viewport / safeArea / traitCollection 抽象
│ │ ├─ brightness / fallbackViewportSize
│ │ └─ renderStyle / layoutConfig 推导
│ │
│ └─ RDEPUBReaderServices
│ ├─ parser / paginator / builder factory
│ ├─ renderer factory
│ └─ chapter summary disk cache factory
├─ RDEPUBReaderRuntime // 运行时协调器集合Facade 模式)
│ ├─ chapterLoader // 章节加载器
│ ├─ chapterRuntimeStore // 内存缓存
│ ├─ summaryDiskCache // 磁盘摘要缓存
│ ├─ pageResolver // 页码解析器
│ ├─ loadCoordinator // 加载协调器
│ ├─ paginationCoordinator // 分页协调器
│ ├─ locationCoordinator // 位置协调器
│ ├─ searchCoordinator // 搜索协调器
│ ├─ chromeCoordinator // 工具栏协调器
│ ├─ annotationCoordinator // 标注协调器
│ └─ viewportMonitor // 视口变化监控
├─ RDEPUBReaderPaginationCoordinator // 分页协调器
│ ├─ paginatePublication() // 入口
│ ├─ paginateMetadataOnly() // 后台元数据解析
│ └─ restoreBookPageMapIfPossible() // 缓存恢复
├─ RDEPUBChapterLoader // 章节加载器
│ ├─ loadChapter() // 异步加载Tier1→Tier2→全量构建
│ └─ loadChapterSynchronouslyForMigration() // 同步加载(快速打开用)
└─ RDEPUBBookPageMap // 轻量页码映射
├─ ~100KB/1000章不持有 NSAttributedString
└─ 支持增量刷新 (Builder pattern)
└─ RDEPUBReaderRuntime兼容门面
├─ load / pagination / location / search / chrome / annotation coordinator
├─ RDEPUBPresentationRuntime
├─ RDEPUBChapterWarmupOrchestrator
└─ 旧 API 转发
```
当前 `RDEPUBReaderContext` 仍存在,但主要作用已经收缩为过渡门面:
- 对外维持兼容访问面
- 对内把纯状态、环境推导、工厂依赖拆开
- 避免新逻辑继续把 `Context` 当作全能对象扩散
---
## 6. 章节按需加载与分页窗口架构
```
RDEPUBReaderController
├─ RDEPUBReaderPaginationCoordinator // 分页入口与后台元数据解析
│ ├─ paginatePublication()
│ ├─ RDEPUBMetadataParseWorker.start()
│ └─ restoreBookPageMapIfPossible()
├─ RDEPUBPresentationRuntime // 分页状态与窗口替换
│ ├─ applyBookPageMap()
│ ├─ refreshBookPageMapInPlace()
│ └─ applyPendingFullPageMapIfNeeded()
├─ RDEPUBChapterWarmupOrchestrator // 按需章节预热与边界预取
│ ├─ prepareOnDemandChapter()
│ ├─ extendPartialBookPageMapIfNeeded()
│ ├─ prefetchForwardChaptersAfterInitialOpen()
│ └─ ensureNavigationTargetAvailable()
├─ RDEPUBChapterLoader // 章节构建与缓存门面
│ ├─ loadChapter() // 异步加载(主路径)
│ └─ loadChapterSynchronouslyForMigration()
└─ RDEPUBBookPageMap / RDEPUBPaginationState
├─ 当前激活窗口
├─ 待接管完整 page map
└─ 来源与切换状态
```
这轮整改后,跨章节主路径的关键变化是:
- 普通翻页时不再要求 UI 主线程同步等待章节构建
- 章节边界会前移预热相邻章节与 lookahead 章节
- `bookPageMap` 的 partial extension 和 full replacement 统一经过 `RDEPUBPresentationRuntime`
- `RDEPUBReaderRuntime` 不再内联维护整套预热/扩窗实现
---
## 6. WebView 渲染架构Web 路径)
@ -272,10 +310,10 @@ RDEPUBWebView (UIView)
---
## 7. 翻页容器架构RDReaderView
## 7. 翻页容器架构RDEpubReaderView
```
RDReaderView (UIView)
RDEpubReaderView (UIView)
├─ 三种翻页模式:
│ ├─ .pageCurl → UIPageViewController (翻页动画)
@ -283,14 +321,14 @@ RDReaderView (UIView)
│ └─ .verticalScroll → UICollectionView (垂直滚动)
├─ 组合对象:
│ ├─ RDReaderPagingController // 翻页状态管理、请求队列
│ ├─ RDReaderPreloadController // 页面预加载、缓存管理
│ ├─ RDReaderSpreadResolver // 双页展开计算
│ └─ RDReaderTapRegionHandler // 点击区域分类(左/中/右)
│ ├─ RDEpubReaderPagingController // 翻页状态管理、请求队列
│ ├─ RDEpubReaderPreloadController // 页面预加载、缓存管理
│ ├─ RDEpubReaderSpreadResolver // 双页展开计算
│ └─ RDEpubReaderTapRegionHandler // 点击区域分类(左/中/右)
├─ 数据源协议:
│ ├─ RDReaderPageProvider (新) // 格式无关,优先级高
│ └─ RDReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配
│ ├─ RDEpubReaderPageProvider (新) // 格式无关,优先级高
│ └─ RDEpubReaderDataSource (旧) // 遗留兼容,通过 Adapter 适配
└─ 手势流:
点击 → TapRegionHandler → 分类(左/中/右)
@ -313,7 +351,7 @@ struct RDEPUBReaderConfiguration {
var numberOfColumns: Int // 1 或 2
var columnGap: CGFloat // 默认 20
var theme: ReaderTheme // 6 种主题
var displayType: RDReaderView.DisplayType // pageCurl/horizontal/vertical
var displayType: RDEpubReaderView.DisplayType // pageCurl/horizontal/vertical
var onDemandChapterWindowSize: Int // 默认 3奇数最小 3最大 15
var metadataParsingConcurrency: Int // 默认 CPU 核心数
var chapterWindowRadius: Int // 内存缓存窗口半径
@ -336,7 +374,7 @@ struct RDEPUBReaderConfiguration {
## 9. 目录结构
```
Sources/RDReaderView/
Sources/RDEpubReaderView/
├── EPUBCore/ # EPUB 解析与 WebView 渲染
│ ├── Models/ # 数据模型
│ │ ├── RDEPUBAnnotationModels.swift
@ -390,19 +428,19 @@ Sources/RDReaderView/
│ ├── RDEPUBDTCoreTextRenderer.swift # DTCoreText 渲染器实现
│ ├── RDEPUBChapterData.swift # 章节查询门面
│ ├── RDEPUBTextIndexTable.swift # 全书索引表
│ └── RDPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器
│ └── RDEpubPlainTextBookBuilder.swift # 纯文本 (.txt) 构建器
├── ReaderView/ # 通用翻页容器
│ ├── Paging/ # 翻页子系统
│ │ ├── RDReaderPagingController.swift
│ │ ├── RDReaderPreloadController.swift
│ │ ├── RDReaderSpreadResolver.swift
│ │ └── RDReaderTapRegionHandler.swift
│ ├── RDReaderView.swift # 主容器视图
│ ├── RDReaderView+*.swift # 扩展CollectionView/PageCurl/ToolView/ContentAccess
│ ├── RDReaderFlowLayout.swift # 自定义 CollectionView 布局
│ ├── RDReaderContentCell.swift # 滚动模式 Cell
│ └── RDReaderPageChildViewController.swift // 翻页模式子 VC
│ │ ├── RDEpubReaderPagingController.swift
│ │ ├── RDEpubReaderPreloadController.swift
│ │ ├── RDEpubReaderSpreadResolver.swift
│ │ └── RDEpubReaderTapRegionHandler.swift
│ ├── RDEpubReaderView.swift # 主容器视图
│ ├── RDEpubReaderView+*.swift # 扩展CollectionView/PageCurl/ToolView/ContentAccess
│ ├── RDEpubReaderFlowLayout.swift # 自定义 CollectionView 布局
│ ├── RDEpubReaderContentCell.swift # 滚动模式 Cell
│ └── RDEpubReaderPageChildViewController.swift // 翻页模式子 VC
└── EPUBUI/ # 阅读器 UI
├── ReaderController/ # 控制器与协调器
@ -456,8 +494,7 @@ Sources/RDReaderView/
| **Builder** | `RDEPUBBookPageMap.Builder` 增量构建页码映射 |
| **Strategy** | `RDEPUBTextRenderer` 协议,可替换渲染器实现 |
| **Pipeline** | `RDEPUBTextTypesetterPipeline` 排版管线8 个逻辑阶段,封装为 5-6 个顶层调用) |
| **State Machine** | `RDEPUBNavigatorState` 管理阅读器状态转换 |
| **Adapter** | `RDReaderLegacyDataSourceAdapter` 适配旧数据源协议 |
| **Adapter** | `RDEpubReaderLegacyDataSourceAdapter` 适配旧数据源协议 |
| **三级缓存** | 内存 → 磁盘摘要 → 全书分页,逐级降级 |
| **Token 取消** | `paginationToken` 确保过期异步任务不干扰新任务 |
| **Frozen Parameters** | 后台任务冻结 `renderSignature`,避免运行中参数漂移 |

411
Doc/AUDIT_FINAL.md Normal file
View File

@ -0,0 +1,411 @@
# ReadViewSDK 代码审查报告
> 审查范围:`Sources/RDEpubReaderView/` 全部 184 个 Swift 文件(约 32,300 行)
> 审查维度并发安全、内存管理、API 正确性、Swift/iOS 平台特定、架构与依赖
> 审查日期2026-06-26
---
## 严重程度定义
| 标记 | 含义 |
|------|------|
| 🔴 高 | 有明确代码路径可导致崩溃、数据丢失或严重功能缺陷 |
| 🟡 中 | 方向正确但风险程度取决于运行时条件;或当前安全但架构上脆弱 |
| 🟢 低 | 代码异味、可维护性问题或未来风险 |
---
## 🔴 高优先级问题
### H-01 · 内存警告未连接章节缓存清理
`RDEPUBChapterRuntimeStore.handleMemoryWarning()` 已实现(清空除当前章节外的缓存 + 清除图片缓存),但从未被调用。
- `RDEPUBReaderRuntime.handleMemoryWarning()` 只清理了 `backgroundCoverageStore`,未调用 `chapterRuntimeStore.handleMemoryWarning()`
- `RDEPUBReaderController` 未重写 `didReceiveMemoryWarning`
**影响:** 系统内存压力下,`RDEPUBChapterDataCache`(无上限字典)和 `imageCache`(仅 countLimit=50无 totalCostLimit不会主动释放内存。
**位置:**
- `RDEPUBChapterRuntimeStore.swift:103``handleMemoryWarning()` 方法存在
- `RDEPUBReaderRuntime.swift:606-620` — 只清理 backgroundCoverageStore
---
### H-02 · 主线程信号量等待
`RDEPUBChapterLoader.loadChapterSynchronouslyForMigration()` 使用 `DispatchSemaphore.wait()` 阻塞调用线程。当从 `RDEPUBPresentationRuntime.rebindVisibleLocation()``allowSynchronousLoad: true` 调用时,会在主线程等待。
**影响:** 如果章节数据未缓存,主线程被阻塞直到章节构建完成,可能导致 ANR 或死锁(如果构建过程需要主线程资源)。
**位置:**
- `RDEPUBChapterLoader.swift:206-268``semaphore.wait()`
- `RDEPUBPresentationRuntime.swift:282-286``allowSynchronousLoad: true`
---
### H-03 · asyncAfter 闭包中的强制解包
`RDEPUBReaderController+DataSource.swift:425-436` 在 0.3 秒延迟闭包中强制解包 `lastTextPaginationPageSize!`。方法入口的 `guard let lastTextPaginationPageSize` 不保护延迟闭包——0.3 秒后该值可能已变为 `nil`
**位置:**
```swift
// line 432-433
let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.width) > 0.5
|| abs(currentSize.height - self.lastTextPaginationPageSize!.height) > 0.5
```
---
### H-04 · `unowned context` 在后台操作中的崩溃路径
以下两个类在后台操作中访问 `unowned let context: RDEPUBReaderContext`,若 controller 在操作期间释放则会崩溃:
**RDEPUBMetadataParseWorker**
- Line 117: `let context = self.context``[weak self]` guard 之前将 unowned 引用捕获为局部变量
- Lines 197-324: `BlockOperation` 闭包直接使用该局部变量unowned`[weak self]` 保护
- Lines 296-301, 372-376: `DispatchQueue.main.async` 闭包捕获 unowned 局部变量
**RDEPUBReaderLoadCoordinator**
- Line 28: 闭包只捕获 `[weak controller]`,未捕获 `[weak self]`
- Line 30: `self.context.makeParser()` 在后台队列访问 unowned 引用
- Lines 41-42, 53-54: `DispatchQueue.main.async` 闭包直接访问 `self.context`
**影响:** 若 `RDEPUBReaderController` 在后台操作期间释放(如用户快速退出阅读器),`unowned` 引用悬挂导致 EXC_BAD_ACCESS。
**位置:**
- `RDEPUBMetadataParseWorker.swift:117, 197-324, 296-301, 372-376`
- `RDEPUBReaderLoadCoordinator.swift:28-57`
---
### H-05 · 章节数据缓存无容量限制
`RDEPUBChapterDataCache` 是无上限的 `[Int: RDEPUBRuntimeChapter]` 字典,仅通过显式 `remove(spineIndex:)``removeAll()` 逐出。每章持有完整的 `typesetAttributedString`50-200KB+50 章可达 5-10MB+。
**位置:** `RDEPUBChapterDataCache.swift:5``private var storage: [Int: RDEPUBRuntimeChapter] = [:]`
---
### H-06 · 暗色图片静态 NSCache 无 totalCostLimit
`RDEPUBDarkImageAdjuster.imageCache` 是 static `NSCache<NSString, UIImage>()`,无 `totalCostLimit`,跨越整个 App 生命周期累积调整后的图片。
**位置:** `RDEPUBDarkImageAdjuster.swift:9`
---
### H-07 · 持久化操作静默丢弃错误
`RDEPUBReaderPersistence` 中所有编解码操作均使用 `try?`,失败时:
- **保存**静默返回数据丢失无任何反馈lines 84, 98, 112, 131
- **加载**:返回 `nil` 或空数组,损坏/版本不匹配的数据无法恢复lines 80, 94, 108, 127
**影响:** 用户书签和阅读位置可能在无声中丢失。
**位置:** `RDEPUBReaderPersistence.swift:76-131`
---
### H-08 · WKURLSchemeHandler 线程违规与取消回调缺失
两个确定 bug
**(a) 不同队列回调:** `webView(_:start:)` 在某个串行队列(由 WebKit 调度)上被调用,但 `respondWithInMemoryData``respondWithStreaming``didReceive`/`didFinish`/`didFailWithError` 显式切到了 `ioQueue`line 121, 151。这至少违反了"回调应与 `start` 使用同一串行队列"的要求。
**(b) 取消时不调用完成回调:** 任务被 `stop` 后,`isTaskActive` 返回 false 时:
- streaming 路径line 178`guard self.isTaskActive(taskID) else { return }` — 仅 return不调用 `didFinish``didFailWithError`
- in-memory 路径line 128, 141同理
这违反了"每个 started task 必须收到完成回调"的协议要求。
**位置:** `RDEPUBResourceURLSchemeHandler.swift:121-187`
---
### H-09 · 搜索在后台线程同步加载所有章节
`RDEPUBReaderSearchCoordinator``searchQueue` 上遍历所有 buildable spine 索引,逐个同步加载章节。每章持有完整 `typesetAttributedString`,大型书籍搜索时内存峰值显著。
**位置:** `RDEPUBReaderSearchCoordinator.swift:47, 173-236`
---
### H-10 · 依赖版本未锁定
`RDEpubReaderView.podspec` 中 DTCoreText、SnapKit、SSAlertSwift 无版本约束:
```ruby
s.dependency 'ZIPFoundation', '~> 0.9' # ✅ 已锁定
s.dependency 'DTCoreText' # ❌ 无约束
s.dependency 'SnapKit' # ❌ 无约束
s.dependency 'SSAlertSwift' # ❌ 无约束
```
**位置:** `RDEpubReaderView.podspec:17-20`
---
### H-11 · SnapKit 和 SSAlertSwift 疑似死依赖
全仓库搜索未找到任何 `snp.``SSAlert` 的使用点。这两个依赖可能增加了不必要的二进制体积和潜在冲突。
---
## 🟡 中优先级问题
### M-01 · `RDEPUBReaderState` 无同步保护(隐性风险)
所有属性(`parser`, `publication`, `activeBookmarks`, `pendingPageMapUpdates` 等)均为 `var`,无锁、无 `@MainActor`、无 `dispatchPrecondition`。当前所有访问确实在主线程(经追踪 16 个 `pendingPageMapUpdates` 访问点确认),但缺少形式化保障——未来开发者可能无意从后台线程访问而不自知。
**位置:** `RDEPUBReaderState.swift:1-71`
---
### M-02 · `currentSpineIndex` / `windowSpineIndices` 无锁但当前安全
这两个字段无锁保护但追踪全部读写路径后确认当前均在主线程。风险是隐性的store 对象被传入 `chapterLoadQueue` 上下文,未来开发者可能在该队列中访问这些属性而不自知。同文件的其他字段(`navigationLock`, `prefetchLock`, `buildingLock`, `cfiMapLock`)有锁保护,形成不一致的模式。
**位置:** `RDEPUBChapterRuntimeStore.swift:15-17`
---
### M-03 · `warmAnchors` 跨线程访问无同步
`RDEPUBBackgroundPriorityManager.warmAnchors` 在主线程写入(`addWarmAnchor`),在 `MetadataParseWorker` 的后台线程读取(`makeMetadataPriorityOrder`),无锁保护。
**位置:** `RDEPUBBackgroundPriorityPolicy.swift:65`
---
### M-04 · `assert(Thread.isMainThread)` 在 Release 中被移除
`RDEPUBReaderContext``makeLayoutSnapshot()`、`currentTextPageSize()` 等方法使用 `assert(Thread.isMainThread)` 检查。`assert` 在 `-O` 构建中被移除,生产环境中后台线程调用将静默访问 UIKit 属性,造成数据竞争且无诊断。
**应使用:** `dispatchPrecondition(condition: .onQueue(.main))``@MainActor` 标注。
**位置:** `RDEPUBReaderContext.swift:172, 188, 210, 215`
---
### M-05 · 磁盘缓存无自动淘汰策略
`RDEPUBChapterSummaryDiskCache``RDEPUBTextBookCache` 只有全量清除操作(`removeAll()`、`invalidateAll()`),无 LRU/大小/年龄驱逐策略。每次设置变更字体、行距、主题产生新缓存键旧文件无限累积。写操作使用原子替换tmp + replace交错不会损坏数据但可能读到旧值或看到不一致的目录状态。
**位置:**
- `RDEPUBChapterSummaryDiskCache.swift:89``removeAll()` 全清
- `RDEPUBTextBookCache.swift:191``invalidateAll()` 全清
---
### M-06 · CFI 范围分隔符可能与内容冲突
`RDEPUBCFICompatibility``".."``"-"` 分割 CFI 范围字符串,但这些字符可能出现在 CFI 文本断言内部(如 `[foo..bar]`)。代码先匹配 `".."` 再匹配 `"-"`,无歧义消解逻辑。
**位置:** `RDEPUBCFICompatibility.swift:18-21`
---
### M-07 · CFI 步索引对奇数输入容错不严谨
`RDEPUBCFIResolver``steps[1].index / 2 - 1` 对奇数索引做整除截断,可能映射到错误章节。`max(..., 0)` 仅防负索引,不防错误映射。属于 EPUB CFI 规范的输入验证问题。
**位置:** `RDEPUBCFIResolver.swift:38`
---
### M-08 · WKWebView 保留环需主动管理
`RDEPUBWebView``webView``configuration.userContentController``RDEPUBWebView`(作为 `WKScriptMessageHandler`)形成保留环。
主要解环路径是 `reset()``configureWebViewIfNeeded` 中主动调用 `teardownWebView()`。**注意:** `deinit` 虽也调用 `teardownWebView()`,但不能作为环会被打破的保证——保留环的存在意味着 `deinit` 可能永远不会触发。风险在于:若某条代码路径跳过了 `teardownWebView()`,环将持续且对象永远不会释放。
**位置:**
- `RDEPUBWebView.swift:129``deinit` 调用 `teardownWebView()`(不可靠兜底)
- `RDEPUBWebView.swift:138-160``reset()` 调用 `teardownWebView()`(主要解环路径)
---
### M-09 · `configuration` didSet 在 `isViewLoaded` 之前持久化
`RDEPUBReaderController.configuration``didSet``guard isViewLoaded` 之前执行了 `readerContext.configuration = configuration`、`applyWebViewDebugPolicy()` 和 `persistReaderSettingsIfNeeded()`。若在 view 加载前设置 configuration这些副作用会在 UI 未就绪时触发。
**位置:** `RDEPUBReaderController.swift:8-20`
---
### M-10 · empty catch 吞掉章节加载错误
`RDEPUBReaderPaginationCoordinator``loadInitialInteractiveRuntimeChapters``catch {}` 为空,章节加载失败被静默忽略,无日志、无恢复。
**位置:** `RDEPUBReaderPaginationCoordinator.swift:283`
---
### M-11 · `RDEPUBReaderFontChoice.displayName` 始终返回中文
枚举的 `displayName` 计算属性硬编码中文字符串("系统"、"宋体"、"圆体"、"等宽"),无本地化基础设施。
**位置:** `RDEPUBReaderConfiguration.swift:18-28`
---
### M-12 · 大量硬编码中文字符串,本地化覆盖不完整
覆盖文件:`RDEPUBReaderSettingsViewController`、`RDEPUBReaderSearchBarView`、`RDEPUBReaderHighlightsViewController`、`RDEPUBReaderBookmarksViewController`、`RDEPUBReaderTopToolView`、`RDEPUBReaderBottomToolView`、`RDEPUBTextContentView`、`RDEPUBWebView+Configuration`、`RDEPUBChapterLoader`(错误信息)等。
项目中存在约 88 个硬编码中文字符串(如"拷贝"、"高亮"、"批注"、"目录"、"搜索"等),但同时也有使用 `NSLocalizedString` 的地方(如 `RDEPUBImageViewController.swift:126`)。本地化基础设施存在但覆盖不完整——新添加的 UI 文本大多直接硬编码中文而未走本地化流程。
---
### M-13 · DTCoreText 类型泄漏到 EPUBUI 层
5 个 EPUBUI 文件直接使用 `DTCoreTextLayoutFrame`、`DTCoreTextLayoutLine`、`DTCoreTextGlyphRun` 等 DTCoreText 类型(虽有 `#if canImport(DTCoreText)` 保护),意味着替换渲染引擎需修改 UI 层文件。
**位置:**
- `RDEPUBPageInteractionController.swift`
- `RDEPUBPageLayoutSnapshot.swift`
- `RDEPUBTextPageRenderView.swift`
- `RDEPUBTextContentView.swift`
- `RDEPUBDarkImageAdjuster.swift`
---
### M-14 · `imageCache` 有 countLimit 但无 totalCostLimit
`RDEPUBChapterRuntimeStore.imageCache` 设有 `countLimit = 50`,但无 `totalCostLimit`。50 张大尺寸书籍插图可达 150-250MB。
**位置:** `RDEPUBChapterRuntimeStore.swift:41`
---
### M-15 · `UIMenuController` 已废弃
`RDEPUBWebView+Configuration.swift:39``RDEPUBTextContentView.swift:747-758` 使用 `UIMenuController`,该 API 从 iOS 16 起废弃。应迁移至 `UIEditMenuInteraction`iOS 16+),并为 iOS 15 保留 fallback。当前代码在 iOS 17+ 上仍可工作但随时可能失效。
---
### M-16 · Dynamic Type 未支持
UI 中所有字体使用硬编码大小(`UIFont.systemFont(ofSize: 13/14/16/18/19)`),未使用 `UIFontMetrics``preferredFont(forTextStyle:)`。启用 Dynamic Type 的用户看不到文字大小变化。
---
### M-17 · URL Scheme Handler 取消时 `clearTask` 可能被跳过
`respondWithInMemoryData` line 128 的 `guard self.isTaskActive(taskID) else { return }` 在取消时直接返回,跳过了 line 146 的 `self.clearTask(taskID)`。当前因 `stop` 已调用 `clearTask` 而无害,但结构上脆弱。
**位置:** `RDEPUBResourceURLSchemeHandler.swift:128`
---
### M-18 · 硬编码颜色值未集成到主题系统
`RDEPUBReaderSearchBarView` 含 18 个硬编码颜色字面量,默认高亮颜色 `"#F8E16C"` 硬编码在 `RDEPUBReaderController+PublicAPI.swift:65,75`。这些应属于 `RDEPUBReaderTheme`
---
### M-19 · 硬编码时间常量
多个 `DispatchQueue.main.asyncAfter(deadline:)``Thread.sleep` 使用硬编码延迟值0.08s、0.1s、0.15s、0.2s、0.25s、0.3s、1.0s、3.5s),无命名常量或可配置选项。
---
## 🟢 低优先级问题
### L-01 · `RDEPUBChapterLoader``[self]` 强捕获
Line 103 的 `store.chapterLoadQueue.async { [self] in` 强捕获 self阻止 loader 在队列任务完成前释放。应改为 `[weak self]`
**位置:** `RDEPUBChapterLoader.swift:103`
---
### L-02 · MetadataParseWorker Thread.sleep 忙等待
`waitForReadingInteractionToSettle()` 使用 `Thread.sleep(forTimeInterval: 0.08)` 轮询,无上限等待。应考虑 `DispatchSource` 或条件变量。
**位置:** `RDEPUBMetadataParseWorker.swift:385-390`
---
### L-03 · `RDEPUBTextBookCache.queue.sync` 潜在死锁
`load()``save()` 均使用 `queue.sync`。若从 queue 内部调用则会死锁。当前未发现此调用路径,但模式脆弱。
**位置:** `RDEPUBTextBookCache.swift:136-189`
---
### L-04 · `pendingLoads` 字典无超时清理
`RDEPUBChapterLoader.pendingLoads` 的完成闭包数组在章节加载失败且 `resolvePendingLoad` 未被调用时可能累积。当前所有路径均调用 `resolvePendingLoad`,但无防御性超时。
**位置:** `RDEPUBChapterLoader.swift:7-10`
---
### L-05 · `RDEPUBChapterWindowCoordinator` 是死代码
该类定义了但未被任何代码实例化或引用。其中对 `currentSpineIndex`/`windowSpineIndices` 的7个访问点均为死代码。
---
### L-06 · 平台版本不一致
- `podspec`: `s.platform = :ios, "15.0"`
- `Podfile`(根和 demo: `platform :ios, '15.6'`
---
### L-07 · `RDEPUBMetadataCancellationController` 微小竞争
`attach()` 方法在 `lock.unlock()``queue.cancelAllOperations()` 之间存在窄窗口,新操作可能被添加到队列但不会被取消。实际影响极小。
**位置:** `RDEPUBMetadataParseCancellationController.swift:attach()`
---
### L-08 · 页码标签硬编码偏移
`RDEPUBWebContentView``RDEPUBTextContentView` 的页码标签使用硬编码偏移24pt, 20pt在刘海屏设备上可能与安全区域重叠。
---
## 已排除项
以下原报告条目经核实后排除或合并:
| 原编号 | 原描述 | 排除原因 |
|--------|--------|----------|
| #6 | compactMap 结果越界 | 入口有 `childViewControllers.count == expectedCount` 检查compactMap 后不会少于预期数量 |
| #7 | `result!` 强解包崩溃 | 信号量等待的真实风险是无限等待/卡住,不是 result 为 nilsignal 前必定赋值 |
| #21 | 搜索协调器 fallback 空 store | `?? RDEPUBChapterRuntimeStore()` 前的可选链若为 nil 则整个调用返回 nilfallback 不会生效 |
| #23 | init 时加载所有 HTML 到内存 | 逐章取 HTML 算哈希后只保存 digestHTML 字符串不长期保留 |
| #35 | `.nonPersistent()` 阻止 cookie/localStorage | 不持久化是有意设计,不是 bug原始报告将"不持久化"误述为"不能用" |
| #48 | `didMoveToSuperview` 每次重复 makeUI | 有 `didBuildUI` 防重入保护 |
| 原L-04 | `activeTasks` 字典 `clearTask` 设 nil 不删键 | Swift 中 `dict[key] = nil` 等同于 `removeValue(forKey:)`,不保留空洞 |
| 原M-18 | MetadataParseWorker OperationQueue 未在 deinit 取消 | 外层 `[weak self]` guard 确保 self 存活才执行 queue 操作;`cancellationController` 提供显式取消路径 |
| 原L-08 | `ss_superViewController` 前缀暗示外部来源 | 命名风格猜测,非可验证缺陷 |
---
## 建议优先级排序
| 优先级 | 编号 | 修复复杂度 | 说明 |
|--------|------|-----------|------|
| P0 | H-01 | 低 | 在 `handleMemoryWarning` 中加一行 `chapterRuntimeStore.handleMemoryWarning()`,并在 controller 中转发 `didReceiveMemoryWarning` |
| P0 | H-04 | 中 | MetadataParseWorker 和 LoadCoordinator 中 `unowned context` 改为 `weak`,闭包加 `guard` |
| P0 | H-03 | 低 | `lastTextPaginationPageSize!` 改为 `guard let` 安全解包 |
| P1 | H-08 | 中 | URLSchemeHandler 回调改到与 start 相同队列,取消路径加 `didFailWithError` |
| P1 | H-02 | 中 | 主线程同步加载改为异步回调或确保不从主线程调用 |
| P1 | H-07 | 低 | 关键持久化操作加日志,至少记录失败原因 |
| P1 | H-10 | 低 | 给三个依赖加版本约束;移除 SnapKit 和 SSAlertSwift |
| P2 | H-05 | 中 | 为 ChapterDataCache 加 LRU 逐出策略或最大条目数限制 |
| P2 | H-06 | 低 | 为 DarkImageAdjuster.imageCache 设置 `totalCostLimit` |
| P2 | H-09 | 中 | 搜索改为流式/分批加载,避免一次性持有所有章节 |
| P2 | M-01/02 | 中 | 为 State 和 Store 关键属性加 `@MainActor``dispatchPrecondition` |
| P2 | M-04 | 低 | `assert` 改为 `dispatchPrecondition``precondition` |
| P2 | M-08 | 低 | 保留环加注释说明必须通过 `reset()` 打破 |
| P2 | M-15 | 中 | 迁移至 `UIEditMenuInteraction` |

View File

@ -1,606 +0,0 @@
# Android EPUB 阅读器实施方案
## Context
当前 ReadViewSDK 是一个功能完整的 iOS EPUB 阅读器框架Swift + UIKit支持 EPUB 2/3 解析、三种渲染模式(重排/固定布局/网页交互)、全文搜索、高亮/书签/批注、分页管理等。目标是基于现有 iOS 版本的架构和 JS 桥接协议,实现一套 Android 版本的 EPUB 阅读器。
**核心策略**JS 桥接层epub-bridge.js、WeReadApi.js 等直接复用Swift 逻辑层用 Kotlin 重写UIKit UI 层用 Android 原生 View 重写。
---
## 第一阶段:项目基础设施(预计 1 周)
### 1.1 创建 Android 项目
```
ReadViewSDK-Android/
├── app/ # Demo 应用
├── reader-core/ # Kotlin 核心库(解析、模型、搜索)
├── reader-jsbridge/ # WebView JS 桥接层
├── reader-ui/ # Android UI 组件(工具栏、搜索面板、设置等)
├── reader-view/ # 翻页容器ViewPager2 封装)
└── build-logic/ # Gradle convention plugins
```
- 语言Kotlin
- 最低 API24Android 7.0
- 目标 API34
- 构建工具Gradle + Kotlin DSL
- 依赖注入:手动(与 iOS 的 `RDEPUBReaderDependencies` 模式一致)
### 1.2 Gradle 模块划分
| 模块 | 对应 iOS 层 | 职责 |
|------|------------|------|
| `reader-core` | EPUBCore/ + EPUBTextRendering/ | 解析、模型、搜索引擎、CSS 生成、分页算法 |
| `reader-jsbridge` | RDEPUBJavaScriptBridge + JS Resources | JS 文件管理、消息收发、脚本生成 |
| `reader-ui` | EPUBUI/ | 工具栏、搜索面板、设置面板、目录列表、高亮管理 |
| `reader-view` | ReaderView/ | 翻页容器ViewPager2 + PagerSnapHelper |
### 1.3 公共依赖
```kotlin
// reader-core
implementation("net.lingala.zip4j:zip4j:2.11.5") // 替代 ZIPFoundation
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
implementation("com.google.code.gson:gson:2.11.0") // 替代 Codable
implementation("androidx.webkit:webkit:1.10.0")
// reader-ui
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.viewpager2:viewpager2:1.0.0")
// reader-view
implementation("androidx.viewpager2:viewpager2:1.0.0")
implementation("androidx.recyclerview:recyclerview:1.3.2")
```
---
## 第二阶段Core 层 — EPUB 解析与模型(预计 2 周)
### 2.1 数据模型迁移
将 iOS 的 Codable struct 迁移为 Kotlin data class
| iOS 文件 | Android 文件 | 关键类型 |
|----------|-------------|---------|
| `RDEPUBModels.swift` | `EpubModels.kt` | `EpubLayout`, `EpubMetadata`, `ManifestItem`, `SpineItem`, `TableOfContentsItem` |
| `RDEPUBReadingLocationModels.swift` | `ReadingLocationModels.kt` | `ReadingLocation`, `Viewport`, `ReadingContext` |
| `RDEPUBAnnotationModels.swift` | `AnnotationModels.kt` | `Selection`, `Highlight`, `Bookmark`, `Annotation` |
| `RDEPUBPaginationModels.swift` | `PaginationModels.kt` | `EpubPage`, `ChapterInfo`, `FixedSpread` |
| `RDEPUBSearchModels.swift` | `SearchModels.kt` | `SearchMatch`, `SearchResult`, `SearchState`, `SearchPresentation` |
| `RDEPUBPreferences.swift` | `ReaderPreferences.kt` | `ReadingPreferences`(字体、行高、颜色、分栏) |
| `RDEPUBRenderRequest.swift` | `RenderRequest.kt` | `ReflowableRenderRequest`, `FixedRenderRequest` |
| `RDEPUBReaderConfiguration.swift` | `ReaderConfiguration.kt` | 完整阅读器配置 |
| `RDEPUBReaderTheme.swift` | `ReaderTheme.kt` | 6 种预设主题 |
### 2.2 EPUB 解析器迁移
| iOS 文件 | Android 文件 | 说明 |
|----------|-------------|------|
| `RDEPUBParser.swift` | `EpubParser.kt` | 主解析入口 |
| `RDEPUBParser+Archive.swift` | `EpubParser+Archive.kt` | zip4j 解压(替代 ZIPFoundation |
| `RDEPUBParser+Package.swift` | `EpubParser+Package.kt` | OPF XML SAX 解析 |
| `RDEPUBParser+TOC.swift` | `EpubParser+TOC.kt` | NCX/Nav Document 解析 |
| `RDEPUBParser+Resources.swift` | `EpubParser+Resources.kt` | HTML/资源文件读取 |
| `RDEPUBParser+ReadingProfile.swift` | `EpubParser+ReadingProfile.kt` | 阅读模式判断 |
| `RDEPUBPublication.swift` | `EpubPublication.kt` | Facade 门面 |
| `RDEPUBResourceResolver.swift` | `ResourceResolver.kt` | URL 规范化 |
**XML 解析方案**iOS 使用 `XMLParser`SAXAndroid 使用 `org.xml.sax.XMLReader`(同为 SAX解析逻辑可逐行对照翻译。
### 2.3 搜索引擎迁移
| iOS 文件 | Android 文件 |
|----------|-------------|
| `RDEPUBSearchEngine.swift` | `EpubSearchEngine.kt` |
| `RDEPUBTextSearchEngine.swift` | `TextSearchEngine.kt` |
搜索引擎核心逻辑(遍历 spine → 读取 HTML → 解析为纯文本 → NSString.range 搜索 → 生成 match可直接翻译为 Kotlin `String.indexOf` 循环。
### 2.4 CSS 生成与样式
| iOS 文件 | Android 文件 |
|----------|-------------|
| `RDEPUBStyleSheetBuilder.swift` | `StyleSheetBuilder.kt` |
| `RDEPUBFixedLayoutTemplate.swift` | `FixedLayoutTemplate.kt` |
CSS 生成逻辑完全平台无关,直接翻译即可。
### 2.5 分页算法
| iOS 文件 | Android 文件 | 说明 |
|----------|-------------|------|
| `RDEPUBPaginator.swift` | `EpubPaginator.kt` | 离屏 WebView 分页计算 |
| `RDEPUBReadingSession.swift` | `ReadingSession.kt` | 状态机 + 页面管理 |
**关键决策**Android 版统一使用 WebView 渲染,不实现 `EPUBTextRendering`DTCoreText 的 Android 等价物过于复杂且收益低)。这意味着:
- **不迁移** `EPUBTextRendering/` 整个目录18 个文件)
- **不迁移** `EPUBUI/TextPage/` 目录7 个文件)
- Android 版的所有内容(包括重排模式)都通过 WebView + CSS column 分页实现
- 这与 iOS 的 `webInteractive` 路径一致,已验证可行
---
## 第三阶段JS 桥接层(预计 1 周)
### 3.1 JS 文件直接复用
将以下文件直接复制到 Android `assets/` 目录:
```
reader-jsbridge/src/main/assets/js/
├── epub-bridge.js # 直接复用
├── WeReadApi.js # 直接复用
├── cssInjector.js # 直接复用
├── rangy-core.js # 直接复用
└── rangy-serializer.js # 直接复用
```
### 3.2 WKWebView → Android WebView 桥接映射
| iOS 机制 | Android 等价物 |
|----------|--------------|
| `WKScriptMessageHandler.userContentController(_:didReceive:)` | `@JavascriptInterface` 方法 |
| `window.webkit.messageHandlers.{name}.postMessage()` | 修改 JS 端调用 `AndroidBridge.{method}()` |
| `WKWebView.evaluateJavaScript()` | `WebView.evaluateJavascript()` |
| `WKURLSchemeHandler` | `WebViewClient.shouldInterceptRequest()` |
| `WKUserScript`(页面加载前注入) | `WebView.addJavascriptInterface()` + `WebViewClient.onPageStarted()` |
### 3.3 Android Bridge 实现
```kotlin
// EpubBridge.kt — 对应 iOS RDEPUBJavaScriptBridge
class EpubBridge(private val callback: BridgeCallback) {
@JavascriptInterface
fun onProgressionChanged(json: String) { callback.onProgressionChanged(json) }
@JavascriptInterface
fun onSelectionChanged(json: String) { callback.onSelectionChanged(json) }
@JavascriptInterface
fun onInternalLink(href: String) { callback.onInternalLink(href) }
@JavascriptInterface
fun onExternalLink(url: String) { callback.onExternalLink(url) }
@JavascriptInterface
fun onJSError(message: String) { callback.onJSError(message) }
@JavascriptInterface
fun onFixedLayoutReady() { callback.onFixedLayoutReady() }
}
```
**JS 端修改**:需要将 `window.webkit.messageHandlers.xxx.postMessage(data)` 替换为 `window.AndroidBridge.xxx(JSON.stringify(data))`。可以通过在注入时做字符串替换,或维护一个 Android 版本的 bridge JS 文件。
### 3.4 WebView 资源拦截
对应 iOS 的 `ss-reader://book/` 自定义协议:
```kotlin
// EpubSchemeHandler.kt
class EpubSchemeHandler(private val publication: EpubPublication) : WebViewClient() {
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
val url = request.url.toString()
if (url.startsWith("ss-reader://book/")) {
val path = url.removePrefix("ss-reader://book/")
val data = publication.readResource(path)
val mimeType = getMimeType(path)
return WebResourceResponse(mimeType, "UTF-8", data.byteInputStream())
}
return null
}
}
```
### 3.5 JavaScriptBridge 脚本生成
对应 iOS 的 `RDEPUBJavaScriptBridge` 中各种 `*Script()` 方法,生成 `evaluateJavascript()` 调用的 JS 代码字符串。这些方法的核心是拼接 JSON 参数 + 调用 `WeReadApi.*` 方法,逻辑完全相同。
---
## 第四阶段:翻页容器(预计 1.5 周)
### 4.1 PagerView 实现
对应 iOS `RDReaderView`UICollectionView + UIPageViewController
```kotlin
// PagerView.kt — 对应 iOS RDReaderView
class PagerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val viewPager: ViewPager2
private val adapter: PageAdapter
enum class DisplayMode { HORIZONTAL, VERTICAL }
var displayMode = DisplayMode.HORIZONTAL
set(value) { /* 切换 orientation */ }
fun reloadData() { adapter.notifyDataSetChanged() }
fun scrollToPage(index: Int, animated: Boolean) { /* ... */ }
}
```
**PageAdapter**:使用 `RecyclerView.Adapter`,每个 item 是一个 `FrameLayout`,内部放 `WebView`
### 4.2 点击区域检测
对应 iOS `RDReaderTapRegionHandler`
```kotlin
// TapRegionHandler.kt
class TapRegionHandler {
enum class Region { LEFT, CENTER, RIGHT }
fun resolve(x: Float, viewWidth: Float): Region {
return when {
x < viewWidth / 3 -> Region.LEFT
x > viewWidth * 2 / 3 -> Region.RIGHT
else -> Region.CENTER
}
}
}
```
### 4.3 预加载控制
对应 iOS `RDReaderPreloadController`
使用 `ViewPager2.offscreenPageLimit` 控制预加载页面数量(默认 1-2 页)。
---
## 第五阶段UI 组件(预计 2 周)
### 5.1 阅读器主控制器
对应 iOS `RDEPUBReaderController`
```kotlin
// EpubReaderFragment.kt — Fragment 优于 Activity方便宿主嵌入
class EpubReaderFragment : Fragment() {
// 对应 iOS 的各 Coordinator
private lateinit var runtime: ReaderRuntime
private lateinit var pagerView: PagerView
private lateinit var topBar: TopToolBar
private lateinit var bottomBar: BottomToolBar
private lateinit var searchPanel: SearchPanelView
// Public API
fun openBook(epubUrl: Uri, configuration: ReaderConfiguration = ReaderConfiguration.default)
fun goTo(location: ReadingLocation)
fun goToPageNumber(pageNumber: Int)
fun search(keyword: String)
fun addHighlight(selection: Selection, color: Int, note: String?)
fun addBookmark(note: String?)
// ... 其余 public API
}
```
### 5.2 Coordinator 架构迁移
保持 iOS 的 Coordinator 模式,每个 Coordinator 作为独立的 Kotlin 类:
| iOS Coordinator | Android 类 | 职责 |
|----------------|-----------|------|
| `RDEPUBReaderRuntime` | `ReaderRuntime` | Facade协调所有子 Coordinator |
| `RDEPUBReaderLoadCoordinator` | `LoadCoordinator` | EPUB 文件解析 + Publication 初始化 |
| `RDEPUBReaderPaginationCoordinator` | `PaginationCoordinator` | 分页计算 |
| `RDEPUBReaderLocationCoordinator` | `LocationCoordinator` | 阅读位置管理 + 持久化 |
| `RDEPUBReaderChromeCoordinator` | `ChromeCoordinator` | 工具栏状态同步 |
| `RDEPUBReaderAnnotationCoordinator` | `AnnotationCoordinator` | 高亮/书签/批注 CRUD |
| `RDEPUBReaderSearchCoordinator` | `SearchCoordinator` | 全文搜索协调 |
| `RDEPUBReaderViewportMonitor` | `ViewportMonitor` | 屏幕旋转/尺寸变化检测 |
### 5.3 WebView 内容视图
对应 iOS `RDEPUBWebView` + `RDEPUBWebContentView`
```kotlin
// EpubWebView.kt — 封装 Android WebView
class EpubWebView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : WebView(context, attrs) {
private val bridge = EpubBridge(/* ... */)
fun setup() {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
addJavascriptInterface(bridge, "AndroidBridge")
webViewClient = EpubSchemeHandler(publication)
webChromeClient = WebChromeClient()
}
fun loadChapter(spineIndex: Int, preferences: ReadingPreferences) {
// 生成 HTML 模板 + 注入 CSS + 加载资源
}
fun evaluateScript(script: String, callback: ((String?) -> Unit)? = null) {
evaluateJavascript(script) { result -> callback?.invoke(result) }
}
}
```
### 5.4 顶部工具栏
对应 iOS `RDEPUBReaderTopToolView`
```xml
<!-- top_tool_bar.xml -->
<LinearLayout>
<ImageButton android:id="@+id/btnBack" />
<TextView android:id="@+id/tvTitle" weight="1" />
<ImageButton android:id="@+id/btnSearch" />
<ImageButton android:id="@+id/btnBookmark" />
</LinearLayout>
```
### 5.5 底部工具栏
对应 iOS `RDEPUBReaderBottomToolView`
```xml
<!-- bottom_tool_bar.xml -->
<LinearLayout>
<ImageButton android:id="@+id/btnTOC" /> <!-- 目录 -->
<ImageButton android:id="@+id/btnBookmarks" /> <!-- 书签列表 -->
<ImageButton android:id="@+id/btnHighlights" /> <!-- 高亮列表 -->
<ImageButton android:id="@+id/btnAddHighlight" /> <!-- 添加高亮 -->
<ImageButton android:id="@+id/btnSettings" /> <!-- 设置 -->
</LinearLayout>
```
### 5.6 搜索面板
对应 iOS `RDEPUBReaderSearchBarView`
```kotlin
// SearchPanelView.kt
class SearchPanelView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private val searchField: EditText
private val cancelButton: Button
private val resultsList: RecyclerView // 分组结果列表
private val emptyStateLabel: TextView
fun updateResults(sections: List<SearchSection>, keyword: String, currentMatchIndex: Int?)
fun showNoResults()
fun showSearching()
}
```
### 5.7 设置面板
对应 iOS `RDEPUBReaderSettingsViewController`
使用 `BottomSheetDialogFragment` 实现底部弹出设置面板:
- 亮度调节SeekBar
- 字体大小(+/- 按钮)
- 字体选择
- 行间距
- 主题切换6 种预设)
- 翻页模式切换
### 5.8 目录列表
对应 iOS `RDEPUBReaderChapterListController`
使用 `BottomSheetDialogFragment` + `RecyclerView` 实现目录列表,支持章节标题显示和点击跳转。
---
## 第六阶段:持久化与状态管理(预计 0.5 周)
### 6.1 持久化实现
对应 iOS `RDEPUBReaderPersistence`
```kotlin
// ReaderPersistence.kt
interface ReaderPersistence {
fun loadLocation(bookId: String): ReadingLocation?
fun saveLocation(bookId: String, location: ReadingLocation)
fun loadBookmarks(bookId: String): List<Bookmark>
fun saveBookmarks(bookId: String, bookmarks: List<Bookmark>)
fun loadHighlights(bookId: String): List<Highlight>
fun saveHighlights(bookId: String, highlights: List<Highlight>)
fun loadSettings(): ReaderSettings
fun saveSettings(settings: ReaderSettings)
}
// SharedPreferences 实现
class SharedPrefsPersistence(context: Context) : ReaderPersistence {
// 使用 Gson 序列化/反序列化
}
```
### 6.2 状态管理
使用 Kotlin `StateFlow` 替代 iOS 的手动状态同步:
```kotlin
// ReaderState.kt
data class ReaderUiState(
val isLoading: Boolean = true,
val currentPage: Int = 0,
val totalPages: Int = 0,
val title: String = "",
val isToolbarVisible: Boolean = false,
val isSearchVisible: Boolean = false,
val bookmarks: List<Bookmark> = emptyList(),
val highlights: List<Highlight> = emptyList(),
val searchState: SearchState? = null,
)
class ReaderStateManager {
private val _uiState = MutableStateFlow(ReaderUiState())
val uiState: StateFlow<ReaderUiState> = _uiState.asStateFlow()
}
```
---
## 第七阶段:主题与外观(预计 0.5 周)
### 7.1 主题系统
对应 iOS `RDEPUBReaderTheme`
```kotlin
// ReaderTheme.kt
data class ReaderTheme(
val name: String,
val contentBackgroundColor: Int, // Color int
val textColor: Int,
val toolbarBackgroundColor: Int,
val toolbarForegroundColor: Int,
val isDark: Boolean
) {
companion object {
val LIGHT = ReaderTheme("浅色", Color.WHITE, Color.BLACK, ...)
val DARK = ReaderTheme("深色", Color.rgb(0x1A, 0x1A, 0x1A), Color.WHITE, ...)
val YELLOW = ReaderTheme("纸质", Color.rgb(0xF5, 0xF0, 0xE0), Color.BLACK, ...)
// ... 共 6 种
}
}
```
### 7.2 状态栏适配
根据主题动态设置状态栏颜色(`Window.statusBarColor`)和图标颜色(`WindowInsetsController.isAppearanceLightStatusBars`)。
---
## 第八阶段:测试与 Demo预计 1 周)
### 8.1 单元测试
- `EpubParserTest` — EPUB 解析container.xml、OPF、TOC
- `SearchEngineTest` — 搜索引擎
- `StyleSheetBuilderTest` — CSS 生成
- `ResourceResolverTest` — URL 解析
- `LocationCoordinatorTest` — 位置管理
### 8.2 集成测试
- `EpubReaderIntegrationTest` — 完整阅读流程(打开 → 分页 → 翻页 → 搜索 → 高亮)
### 8.3 Demo App
对应 iOS `ReadViewDemo`
```kotlin
// MainActivity.kt
class MainActivity : AppCompatActivity() {
private fun openBook() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/epub+zip"
}
startActivityForResult(intent, REQUEST_OPEN_BOOK)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_OPEN_BOOK && resultCode == RESULT_OK) {
val uri = data?.data ?: return
val fragment = EpubReaderFragment.newInstance(uri)
// 嵌入 Fragment
}
}
}
```
### 8.4 UI 自动化测试
对应 iOS 的 XCUIApplication 测试24 个测试文件):
- 使用 Espresso + UiAutomator
- 覆盖:目录交互、页码导航、书签管理、高亮管理、搜索、设置效果、工具栏状态等
---
## 实施时间估算
| 阶段 | 内容 | 预计工时 |
|------|------|---------|
| 1 | 项目基础设施 | 1 周 |
| 2 | Core 层解析、模型、搜索、CSS | 2 周 |
| 3 | JS 桥接层 | 1 周 |
| 4 | 翻页容器 | 1.5 周 |
| 5 | UI 组件 | 2 周 |
| 6 | 持久化与状态管理 | 0.5 周 |
| 7 | 主题与外观 | 0.5 周 |
| 8 | 测试与 Demo | 1 周 |
| **合计** | | **约 9.5 周** |
---
## 关键技术决策
1. **统一 WebView 渲染**:不实现 DTCoreText 等效层,所有内容通过 WebView + CSS column 分页。简化实现,与 iOS 的 webInteractive 路径一致。
2. **Coordinator 模式保留**:与 iOS 架构保持一致,便于两端逻辑对照和维护。
3. **JS Bridge 微调**Android 的 `@JavascriptInterface` 与 iOS 的 `WKScriptMessageHandler` 机制不同JS 端需要适配(用 `AndroidBridge.*` 替代 `window.webkit.messageHandlers.*`)。
4. **不使用 Kotlin Multiplatform**:当前阶段直接用 Kotlin 重写,避免 KMP 的额外复杂度。未来如果需要共享逻辑层,可以逐步迁移。
5. **ViewPager2 替代 PageViewController**Android 无 page curl 效果,使用 ViewPager2 + 自定义 PageTransformer 实现翻页动画(可选深度效果或滑动效果)。
---
## 文件映射索引iOS → Android
### 核心映射
| iOS Swift 文件 | Android Kotlin 文件 | 行数参考 |
|---------------|-------------------|---------|
| `RDEPUBParser.swift` + extensions | `EpubParser.kt` + extensions | ~600 |
| `RDEPUBModels.swift` | `EpubModels.kt` | ~200 |
| `RDEPUBPublication.swift` | `EpubPublication.kt` | ~150 |
| `RDEPUBJavaScriptBridge.swift` | `EpubJavaScriptBridge.kt` | ~400 |
| `RDEPUBStyleSheetBuilder.swift` | `StyleSheetBuilder.kt` | ~300 |
| `RDEPUBSearchEngine.swift` | `EpubSearchEngine.kt` | ~150 |
| `RDEPUBPaginator.swift` | `EpubPaginator.kt` | ~200 |
| `RDEPUBReadingSession.swift` | `ReadingSession.kt` | ~250 |
| `RDEPUBReaderController.swift` + extensions | `EpubReaderFragment.kt` + extensions | ~800 |
| `RDEPUBReaderContext.swift` | `ReaderContext.kt` | ~150 |
| `RDEPUBReaderRuntime.swift` | `ReaderRuntime.kt` | ~200 |
| 各 Coordinator 文件 | 对应 `*Coordinator.kt` | 各 ~100-200 |
| `RDEPUBWebView.swift` + extensions | `EpubWebView.kt` + extensions | ~500 |
| `RDReaderView.swift` + extensions | `PagerView.kt` + extensions | ~400 |
| `RDEPUBReaderSearchBarView.swift` | `SearchPanelView.kt` | ~500 |
| `RDEPUBReaderTopToolView.swift` | `TopToolBar.kt` | ~150 |
| `RDEPUBReaderBottomToolView.swift` | `BottomToolBar.kt` | ~200 |
| `RDEPUBReaderSettingsViewController.swift` | `SettingsSheetFragment.kt` | ~300 |
### 直接复用(不需翻译)
| 文件 | 说明 |
|------|------|
| `epub-bridge.js` | JS 核心桥接 |
| `WeReadApi.js` | JS API 门面 |
| `cssInjector.js` | CSS 注入工具 |
| `rangy-core.js` | Range 操作库 |
| `rangy-serializer.js` | Range 序列化 |
### 不迁移Android 不需要)
| iOS 文件/目录 | 原因 |
|-------------|------|
| `EPUBTextRendering/`18 个文件) | DTCoreText/CoreText 无 Android 等价物,统一用 WebView |
| `EPUBUI/TextPage/`7 个文件) | 原生文字渲染层Android 不需要 |
| `RDEPUBTextContentView.swift` | 同上 |
| `RDEPUBTextPageRenderView.swift` | 同上 |
| `RDEPUBWebDecorationOverlayView.swift` | Android WebView 的高亮由 JS 直接处理,不需要原生 overlay |

View File

@ -177,7 +177,7 @@ while location < totalLength:
│ ├─ 加载窗口内相邻章节
│ └─ 应用局部 BookPageMap → 用户可立即阅读
└─ 后台解析(paginateMetadataOnly
└─ 后台解析(RDEPUBMetadataParseWorker.start()
├─ 预计算所有章节 contentHash串行~1-3s
├─ 恢复已有磁盘缓存
├─ 等待用户操作冷却 0.8s
@ -196,7 +196,7 @@ while location < totalLength:
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
let html = parser.htmlString(forRelativePath: href)
contentHashBySpineIndex[spineIndex] = html?.sha256Hex ?? ""
contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? ""
}
```
@ -206,7 +206,7 @@ for spineIndex in allBuildableIndices {
**问题:** 如果用户在后台解析进行中更改字号/行距,`context.currentRenderSignature()` 会返回新值,导致部分章节用旧签名、部分用新签名写入磁盘缓存,造成缓存不一致。
**解决:** 在 `paginateMetadataOnly` 开始时冻结签名:
**解决:** 在 `RDEPUBMetadataParseWorker` 初始化时冻结签名:
```swift
let renderSignature = context.currentRenderSignature()
@ -298,14 +298,14 @@ return builder.build()
**触发条件:** `landscapeDualPageEnabled && isLandscape && !verticalScroll`
**页面配对逻辑RDReaderSpreadResolver**
**页面配对逻辑RDEpubReaderSpreadResolver**
- 有封面页:封面独占一屏,后续页面两两配对
- 配对规则:`(coverIndex, nil)`, `(1, 2)`, `(3, 4)`, ...
- 无封面页:标准偶奇配对
- 配对规则:`(0, 1)`, `(2, 3)`, `(4, 5)`, ...
**封面感知布局RDReaderFlowLayout**
**封面感知布局RDEpubReaderFlowLayout**
```
封面页: [====全屏宽度====]
@ -314,7 +314,7 @@ return builder.build()
### 5.3 页面预加载
`RDReaderPreloadController` 管理两个缓存:
`RDEpubReaderPreloadController` 管理两个缓存:
- `preloadedPageViews` - 预渲染的页面视图
- `pageCurlCachedViews` - 当前显示的页面视图
@ -580,7 +580,7 @@ protocol RDEPUBReaderPersistence {
## 9. 纯文本 (.txt) 支持
`RDPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件:
`RDEpubPlainTextBookBuilder` 复用 EPUB 渲染管线处理 .txt 文件:
1. **解码:** 依次尝试 UTF-8 → GB18030 → GBK
2. **分章:** 正则匹配 `^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$`

314
Doc/CFI_ISSUES_REVIEW.md Normal file
View File

@ -0,0 +1,314 @@
# CFI 实现问题分析报告
> 最后更新2026-06-18
---
## 概述
本报告覆盖 `EPUBCore/CFI/` 目录下全部 13 个 Swift 文件的代码审查结果。共发现 **11 个问题**,按严重程度分为高/中/低三级。
| 严重程度 | 数量 | 说明 |
|----------|------|------|
| **高** | 3 | rawValue 短路、UTF-16 混用、content path 硬编码 |
| **中** | 5 | Resolver 假设不健壮、HTML 正则解析、text assertion 转义、token 匹配宽泛、end offset 边界 |
| **低** | 3 | 重复 nilIfEmpty、线性扫描、parent 静默失败 |
---
## 高优先级
### 1. Serializer 的 rawValue 短路逻辑 — 数据一致性风险
**文件:** `RDEPUBCFISerializer.swift:5-9`、`33-36`
```swift
public static func serialize(_ cfi: RDEPUBCFI) -> String {
if !cfi.rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return cfi.rawValue // 直接返回原始值,不检查组件是否变化
}
// ... 从组件重新构建
}
```
**问题:** `RDEPUBCFI` 同时存储了 `rawValue`(原始字符串)和解析后的组件(`packagePath`、`contentPath`、`characterOffset` 等)。如果有人修改了组件但没有清空 `rawValue`,序列化会返回**过期的旧值**。
`RDEPUBCFIGenerator` 中已经出现了这个 workaround — 先创建 `rawValue: ""` 的 CFI序列化后再创建一个新 CFI 填入 `rawValue`。这说明开发者意识到了问题,但没有从根源修复。
**建议:** 要么移除 `rawValue` 缓存,每次都从组件重新构建;要么让 `rawValue` 成为 `computed property`,在 `packagePath`/`contentPath` 等变化时自动失效。
---
### 2. UTF-16 vs Character Offset 混用
**文件:** `RDEPUBCFIDOMPathBuilder.swift:435-458``RDEPUBNormalizedTextIndex.tokenSamples`)与 `RDEPUBCFIDOMPathBuilder.swift:405-428``RDEPUBNormalizedTextIndex.init`
**问题:** `RDEPUBNormalizedTextIndex` 内部同时维护了两种偏移体系,但没有统一:
1. **`normalizedToChapterOffsets` 数组**`init` 中构建):通过遍历 `nsSource.length`UTF-16 code unit 数)逐个构建,每个 UTF-16 code unit 对应一个数组条目。因此数组下标是 **UTF-16 索引**
2. **`tokenSamples()` 返回的 offset**:使用 `Array(normalizedText)` 生成 token`Array` 按 Swift Character 拆分,返回的 offset 是 `characters` 数组的下标——即 **Character 索引**
3. **`chapterOffset(forNormalizedOffset:)`**:用传入的 offset 直接查表 `normalizedToChapterOffsets[offset]`,期望接收 UTF-16 索引。
`normalizedText` 含多 code unit 字符时,`normalizedText.count`Character 数)< `nsSource.length`UTF-16 code unit 数),导致 `tokenSamples` 返回的 Character 索引在查 UTF-16 索引表时越界或错位。
此外,`calibratedOffset``RDEPUBCFIRecoveryEngine.swift:240-279`)全程使用 `NSString`/`NSRange`UTF-16 偏移)进行搜索和打分,这与 `tokenSamples` 的 Character 偏移体系不一致。
**影响场景:**
- 含 emoji 的书籍(如 😀UTF-16 surrogate pair 占 2 code unit但 Character 计为 1
- 含 CJK 扩展 B 区汉字的古籍
- 含数学符号的教材(如 𝕏,占 2 code unit
**建议:** 统一偏移基准。推荐全部使用 UTF-16 偏移(与 NSRange/NSAttributedString 一致),将 `tokenSamples` 改为基于 UTF-16 索引生成 token或在 `chapterOffset(forNormalizedOffset:)` 入口处显式转换。
---
### 3. Generator 的 content path 硬编码
**文件:** `RDEPUBCFIGenerator.swift:17-19`
```swift
let contentPath = RDEPUBCFIPath(steps: [
RDEPUBCFIStep(index: 4), // <body> 的第 2 个元素子节点
RDEPUBCFIStep(index: 2, idAssertion: fragmentID) // 第 1 个文本节点
])
```
**问题:** `makeOffsetCFI` 硬编码 content path 为 `/4/2`,即假设目标文本在 `<body>` 的第 2 个元素子节点的第 1 个文本子节点中。以下情况会出错:
| 场景 | 预期 content path | 实际生成 |
|------|-------------------|----------|
| 文本直接在 `<body>` 下 | `/4/N`N 为文本节点奇数索引) | `/4/2` ❌ |
| 文本在 3 层嵌套中 | `/4/2/2/2` | `/4/2` ❌ |
| `<body>` 前有注释节点 | 索引需要偏移 | `/4/2` ❌ |
`makeCFI` 方法允许传入自定义 `contentPath`,但 `makeOffsetCFI` 没有这个灵活性,而它是 `makeOffsetRangeCFI` 的内部依赖。
**建议:** `makeOffsetCFI` 应接受可选的 `contentPath` 参数,或从 `RDEPUBCFIMap` 中查找正确的路径。
---
## 中优先级
### 4. Resolver 的 CFI 结构假设不健壮
**文件:** `RDEPUBCFIResolver.swift:23-40`
```swift
public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult {
let manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.isEmpty == false })
let href = manifestStep?.idAssertion
let fileIndex = cfi.packagePath.steps
.dropFirst()
.last
.map { max(($0.index / 2) - 1, 0) }
// ...
}
```
**问题 1 — manifest 步骤查找不可靠:** 用 `last(where:)` 搜索带 id assertion 的步骤。EPUB CFI 规范中 package path 结构固定:`/6`package`/2[n]`spine`/2n[manifest-id]`。应明确取第三个步骤,而非搜索。
**问题 2 — fileIndex 计算错误:** `dropFirst().last` 取的是**最后一个**步骤,不是第二个。如果 package path 有 3 个步骤(如 Fixed Layout spread取到的是 manifest 步骤而非 spine 步骤。正确做法是 `steps.count >= 2 ? steps[1] : nil`
**问题 3 — 可读性差:** `idAssertion?.isEmpty == false` 逻辑取反应,应该是 `idAssertion?.nilIfEmpty != nil`
---
### 5. DOMPathBuilder 用正则解析 HTML — 脆弱且有边界情况
**文件:** `RDEPUBCFIDOMPathBuilder.swift:5-8`
```swift
guard let regex = try? NSRegularExpression(
pattern: #"</?\s*([A-Za-z][A-Za-z0-9:_-]*)([^>]*)>"#,
options: [.caseInsensitive]
) else { return [:] }
```
**边界情况清单:**
| 输入 | 后果 |
|------|------|
| `<!-- <div> -->` | 正则匹配注释内的 `<div>`,导致错误的子节点计数 |
| `<![CDATA[ <tag> ]]>` | 同上 |
| `<div data-value="a>b">` | 属性值中的 `>` 导致正则截断 |
| `<input checked>` | 无值属性解析正常,但 `idAttribute` 正则要求引号 |
| `<script>if (a < b) {}</script>` | `< b` 可能被匹配为标签 |
| `<br/>` vs `<br />` | `hasSuffix("/>")` 可能在属性值以 `/` 结尾时误判 |
**建议:** 考虑使用 `DTCoreText``libxml2` 进行真正的 HTML 解析。如果必须用正则,至少先移除注释和 CDATA。
---
### 6. Text Assertion 解析不处理转义
**文件:** `RDEPUBCFIParser.swift:133-153`
```swift
private static func parseTextAssertion(from body: String) -> RDEPUBCFITextAssertion? {
guard let open = body.firstIndex(of: "["),
let close = body.lastIndex(of: "]"),
open < close else { return nil }
let raw = String(body[body.index(after: open)..<close])
// ...
}
```
**问题:** EPUB CFI 规范中text assertion 内的 `[``]` 可以用反斜杠转义(`\[` 和 `\]`)。当前实现用 `firstIndex(of: "[")` 和 `lastIndex(of: "]")` 找括号,不处理转义。
**示例:** CFI `epubcfi(/6/4[chap01]!/4/2/1:3[前缀,文\[本\],后缀])` 中,精确文本是 `文[本]`,但当前实现会错误地在 `\]` 处截断。
---
### 7. Recovery Engine 的 token 匹配过于宽泛
**文件:** `RDEPUBCFIRecoveryEngine.swift:136-139`
```swift
let candidateAnchors = cfiMap.recoveryMetadata.tokenIndex.filter { anchor in
exact.contains(anchor.token) || anchor.token.contains(exact)
}
```
**问题:** 双向 `contains` 匹配会导致大量误匹配:
- `exact = "的"` 会匹配所有包含 "的" 的 token中文中极其常见
- `anchor.token = "这是一个很长的句子"` 如果 `exact = "是"`,也会匹配
- 英文中 `exact = "the"` 同样会匹配大量 token
**建议:**
1. 设置最小 token 长度阈值(如 ≥ 4 字符)
2. 改为前缀/后缀匹配而非任意包含
3. 要求 token 与 exact 的长度比在合理范围内(如 0.5 ~ 2.0
---
### 8. `makeOffsetRangeCFI` 的 end offset 边界
**文件:** `RDEPUBCFIGenerator.swift:78-93`
```swift
let start = makeOffsetCFI(
href: href, fileIndex: fileIndex,
chapterOffset: startOffset, // startOffset 可能为负
sideBias: .before, ...
)
let end = makeOffsetCFI(
href: href, fileIndex: fileIndex,
chapterOffset: max(startOffset, endOffset), // 基于原始 startOffset
sideBias: .after, ...
)
```
**问题:** 如果 `startOffset` 为负数:
1. `start``chapterOffset``max(chapterOffset, 0)` clamp 到 0
2. `end``chapterOffset``max(startOffset, endOffset)`,如果 `endOffset` 也为负,结果为负数,再被 clamp 到 0
3. 最终 start 和 end 都是 0range 退化为点
但如果 `startOffset = -5``endOffset = 10`
- `start.chapterOffset` = `max(-5, 0)` = 0
- `end.chapterOffset` = `max(-5, 10)` = 10
- 这个结果是正确的
然而如果 `startOffset = 10``endOffset = 5`(反向 range
- `start.chapterOffset` = 10
- `end.chapterOffset` = `max(10, 5)` = 10
- range 退化为点,丢失了反向信息
**建议:** 在入口处验证 `startOffset <= endOffset`,或支持反向 range。
---
## 低优先级
### 9. 重复的 `nilIfEmpty` 扩展
**文件:** 4 个文件各自定义了 `private extension String { var nilIfEmpty }`
| 文件 | 行号 |
|------|------|
| `RDEPUBCFIPath.swift` | 41-46 |
| `RDEPUBCFIParser.swift` | 172-177 |
| `RDEPUBCFIDOMPathBuilder.swift` | 90-95 |
| `RDEPUBCFITextAssertion.swift` | 18-24 |
**建议:** 提取为一个共享的 `internal` 扩展,放在单独文件或 `RDEPUBCFI.swift` 中。
---
### 10. `RDEPUBCFIMap.marker(matching:)` 线性扫描
**文件:** `RDEPUBCFIMap.swift:30-32`
```swift
public func marker(matching path: RDEPUBCFIPath) -> RDEPUBCFIMarker? {
markers.first { $0.cfiPath == path }
}
```
**问题:** 对于大章节(几百个文本节点),每次查找都做 O(n) 线性扫描。在恢复引擎中可能被多次调用,性能成为瓶颈。
**建议:** 在 `RDEPUBCFIMap` 初始化时构建 `[RDEPUBCFIPath: Int]` 索引字典path → marker 数组下标),查找降为 O(1)。
---
### 11. Range 解析的 parent 静默失败
**文件:** `RDEPUBCFIParser.swift:51`
```swift
return RDEPUBCFIRange(
rawValue: rawValue,
parent: try? parse(parentRaw), // 静默忽略解析失败
start: try parse(startRaw),
end: try parse(endRaw)
)
```
**问题:** `parent``try?` 静默忽略解析失败,但 `start``end``try` 抛出。如果 parent 解析失败,`parent` 为 `nil`,后续 `canonicalRangeComponents` 会从 start/end 推导 parent推导逻辑可能与原始 parent 不一致。
**建议:** 统一错误处理策略 — 要么全部抛出,要么全部容错。当前的混合策略会让调试困难。
---
## 附录:受影响文件清单
| 文件 | 涉及问题 |
|------|----------|
| `RDEPUBCFI.swift` | #1rawValue 存储) |
| `RDEPUBCFIParser.swift` | #6text assertion 转义)、#11parent 静默失败)、#9nilIfEmpty |
| `RDEPUBCFISerializer.swift` | #1rawValue 短路) |
| `RDEPUBCFIPath.swift` | #9nilIfEmpty |
| `RDEPUBCFIResolver.swift` | #4(结构假设) |
| `RDEPUBCFIGenerator.swift` | #3content path 硬编码)、#8end offset 边界) |
| `RDEPUBCFIMap.swift` | #10(线性扫描) |
| `RDEPUBCFIDOMPathBuilder.swift` | #2UTF-16/Character 偏移混用)、#5HTML 正则解析)、#9nilIfEmpty |
| `RDEPUBCFIRecoveryEngine.swift` | #2calibratedOffset 使用 UTF-16 偏移)、#7token 匹配) |
| `RDEPUBCFITextAssertion.swift` | #9nilIfEmpty |
| `RDEPUBCFICompatibility.swift` | 无直接问题 |
| `RDEPUBCFIError.swift` | 无直接问题 |
---
## 修复优先级建议
```
Phase 1高风险影响正确性
├── #1 rawValue 短路 → 改为 computed property 或移除缓存
├── #2 UTF-16/Character 偏移混用 → tokenSamples 用 Character 索引查 UTF-16 索引表,统一为 UTF-16 偏移
└── #3 content path 硬编码 → 从 CFIMap 查找或接受参数
Phase 2中风险影响健壮性
├── #4 Resolver 假设 → 明确取固定索引步骤
├── #5 HTML 正则 → 先清理注释/CDATA或换用 DOM 解析器
├── #6 text assertion 转义 → 实现反斜杠转义处理
├── #7 token 匹配 → 加最小长度阈值和长度比约束
└── #8 end offset → 入口验证或支持反向 range
Phase 3低风险代码质量
├── #9 nilIfEmpty → 提取共享扩展
├── #10 线性扫描 → 构建索引字典
└── #11 parent 静默失败 → 统一错误处理
```

597
Doc/CFI_SUBSYSTEM.md Normal file
View File

@ -0,0 +1,597 @@
# CFI 子系统文档
> 最后更新2026-06-18
本文档详细描述 ReadViewSDK 中 EPUB CFICanonical Fragment Identifier子系统的架构、数据模型、解析流程与容错机制。
---
## 1. CFI 规范简介
EPUB CFI 是 EPUB 3 规范定义的标准化片段标识符,用于精确定位 EPUB 内容中的任意位置。其语法形式为:
```
epubcfi(/6/4!ch01.xhtml/4/2/1:3)
├──────┘ ├────────┘ ├──────┘ └─┘
│ │ │ └─ 字符偏移characterOffset
│ │ └─ 内容路径contentPath定位 DOM 节点
│ └─ 包路径packagePath定位 OPF manifest 中的资源
└─ epubcfi() 包装器
```
关键规则:
- **步进step**:以 `/` 分隔,偶数索引表示元素节点,奇数索引表示文本节点
- **id 断言**`[id]` 形式,如 `/4[ch01.xhtml]`,用于增强定位鲁棒性
- **范围 CFI**`epubcfi(/parent,/start,/end)` 三段逗号分隔,表示起止范围
- **侧偏**`;s=b`before`;s=a`after指示锚点偏向
- **文本断言**`[prefix,exact,suffix]`,用于断言定位处的文本内容
---
## 2. 核心数据模型
### 2.1 RDEPUBCFI
顶层 CFI 模型,对应一个完整的 `epubcfi(...)` 字符串。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFI.swift`
```swift
public struct RDEPUBCFI: Codable, Equatable, Hashable {
public var rawValue: String // 原始 CFI 字符串
public var packagePath: RDEPUBCFIPath // 包路径(定位 XHTML 文件)
public var contentPath: RDEPUBCFIPath // 内容路径(定位 DOM 节点)
public var characterOffset: Int? // 文本节点内的字符偏移
public var sideBias: RDEPUBCFISideBias? // 侧偏方向
public var textAssertion: RDEPUBCFITextAssertion? // 文本断言
}
```
**侧偏枚举**
```swift
public enum RDEPUBCFISideBias: String, Codable {
case before = "b" // 锚点偏向起始侧
case after = "a" // 锚点偏向结束侧
}
```
**示例**:解析 `epubcfi(/6/4[ch01.xhtml]!/4/2/1:100;s=b)` 后:
- `packagePath.steps` = `[Step(index: 6), Step(index: 4, idAssertion: "ch01.xhtml")]`
- `contentPath.steps` = `[Step(index: 4), Step(index: 2), Step(index: 1)]`
- `characterOffset` = 100
- `sideBias` = `.before`
### 2.2 RDEPUBCFIPath
路径模型,由一组 `RDEPUBCFIStep` 组成,描述从根节点到目标节点的遍历序列。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIPath.swift`
```swift
public struct RDEPUBCFIPath: Codable, Equatable, Hashable {
public var steps: [RDEPUBCFIStep]
// 计算两条路径的公共前缀
public func commonPrefix(with other: RDEPUBCFIPath) -> RDEPUBCFIPath
// 去除指定前缀后的剩余路径
public func droppingPrefix(_ prefix: RDEPUBCFIPath) -> RDEPUBCFIPath
}
```
**RDEPUBCFIStep**
```swift
public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
public var index: Int // 节点索引(偶数=元素,奇数=文本)
public var idAssertion: String? // 可选的 id 断言,如 "ch01.xhtml"
}
```
`commonPrefix` 方法在范围 CFI 序列化时用于提取起止点的公共父路径;`droppingPrefix` 用于生成相对于父级的路径片段。
### 2.3 RDEPUBCFIRange
范围模型,表示文档中的一个连续区域,由父级 CFI 和起止 CFI 组成。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRange.swift`
```swift
public struct RDEPUBCFIRange: Codable, Equatable, Hashable {
public var rawValue: String // 原始范围 CFI 字符串
public var parent: RDEPUBCFI? // 公共父级(起止共享的前缀路径)
public var start: RDEPUBCFI // 起始位置
public var end: RDEPUBCFI // 结束位置
}
```
序列化时输出格式:`epubcfi(/parent_path,/start_terminal,/end_terminal)`,其中起止路径相对于父级路径输出。
---
## 3. 解析与序列化
### 3.1 RDEPUBCFIParser
将 CFI 字符串解析为结构化模型。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIParser.swift`
```swift
public enum RDEPUBCFIParser {
// 解析单点 CFI
public static func parse(_ rawValue: String?) throws -> RDEPUBCFI
// 解析范围 CFI
public static func parseRange(_ rawValue: String?) throws -> RDEPUBCFIRange
}
```
**解析流程**`parse` 方法):
1. **去包装**:剥离 `epubcfi(...)` 外层,提取 body
2. **拆分包路径与内容路径**:以第一个 `!` 为分隔符
3. **解析路径**:按 `/` 分割为 step每个 step 可含 `[idAssertion]`
4. **解析偏移与限定符**:从内容路径中提取 `:offset`、`;s=b/a`、`[textAssertion]`
**关键细节**
- `firstIndexOutsideBrackets` 方法确保 `:` 分隔符在方括号外才被识别,避免与文本断言中的内容混淆
- `parseRange` 要求恰好 3 个逗号分隔部分parent, start, end否则抛出 `unsupportedRange` 错误
**使用示例**
```swift
let cfi = try RDEPUBCFIParser.parse(
"epubcfi(/6/4[ch01.xhtml]!/4/2/1:50;s=a[前缀,目标文本,后缀])"
)
print(cfi.characterOffset) // Optional(50)
print(cfi.sideBias) // Optional(.after)
print(cfi.textAssertion?.exact) // Optional("目标文本")
```
### 3.2 RDEPUBCFISerializer
将 CFI 模型序列化为标准字符串。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFISerializer.swift`
```swift
public enum RDEPUBCFISerializer {
// 序列化单点 CFI
public static func serialize(_ cfi: RDEPUBCFI) -> String
// 序列化范围 CFI
public static func serializeRange(_ range: RDEPUBCFIRange) -> String
// 序列化路径为 step 字符串
public static func serializePath(_ path: RDEPUBCFIPath) -> String
}
```
**序列化逻辑**
- 若 `rawValue` 非空,直接返回(避免重复序列化)
- 范围序列化先通过 `canonicalRangeComponents` 提取或推导公共父级,再分别序列化起止终端路径(相对于父级)
- 终端路径使用 `droppingPrefix` 去除与父级共享的部分
**示例**
```swift
let cfi = RDEPUBCFIGenerator.makeOffsetCFI(
href: "chapter1.xhtml", fileIndex: 0, chapterOffset: 120
)
let serialized = RDEPUBCFISerializer.serialize(cfi)
// => "epubcfi(/6/2[chapter1.xhtml]!/4/2:120)"
```
---
## 4. DOM 解析与生成
### 4.1 RDEPUBCFIResolver
将 CFI 解析为可直接用于资源定位的结果。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIResolver.swift`
```swift
public struct RDEPUBCFIResolverResult: Equatable {
public var href: String? // XHTML 文件路径(来自 idAssertion
public var fileIndex: Int? // 文件索引
public var chapterOffset: Int? // 章节内字符偏移
public var fragmentID: String? // 片段 ID#section1
}
public enum RDEPUBCFIResolver {
public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult
}
```
**解析规则**
- `href`:从 `packagePath` 中最后一个含 `idAssertion` 的 step 提取
- `fileIndex``packagePath` 中倒数第二个 step 的 `(index / 2) - 1`
- `fragmentID`:从 `contentPath` 中最后一个含 `idAssertion` 的 step 提取
- `chapterOffset`:直接取 `cfi.characterOffset`
**示例**
```swift
let cfi = try RDEPUBCFIParser.parse("epubcfi(/6/4[ch01.xhtml]!/4/2[chap1]:80)")
let result = RDEPUBCFIResolver.resolve(cfi)
print(result.href) // Optional("ch01.xhtml")
print(result.fileIndex) // Optional(0)
print(result.fragmentID) // Optional("chap1")
print(result.chapterOffset) // Optional(80)
```
### 4.2 RDEPUBCFIGenerator
从已知的章节信息生成 CFI。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIGenerator.swift`
```swift
public enum RDEPUBCFIGenerator {
// 根据偏移量生成单点 CFI
public static func makeOffsetCFI(
href: String,
fileIndex: Int,
chapterOffset: Int,
fragmentID: String? = nil,
sideBias: RDEPUBCFISideBias? = nil,
textAssertion: RDEPUBCFITextAssertion? = nil
) -> RDEPUBCFI
// 根据自定义内容路径生成 CFI
public static func makeCFI(
href: String,
fileIndex: Int,
contentPath: RDEPUBCFIPath,
characterOffset: Int,
sideBias: RDEPUBCFISideBias? = nil,
textAssertion: RDEPUBCFITextAssertion? = nil
) -> RDEPUBCFI
// 生成范围 CFI起止偏移量
public static func makeOffsetRangeCFI(
href: String,
fileIndex: Int,
startOffset: Int,
endOffset: Int,
fragmentID: String? = nil,
startTextAssertion: RDEPUBCFITextAssertion? = nil,
endTextAssertion: RDEPUBCFITextAssertion? = nil
) -> RDEPUBCFIRange
}
```
**路径构造规则**
- `packagePath` 固定为 `[Step(index: 6), Step(index: (fileIndex+1)*2, idAssertion: href)]`
- `contentPath` 默认为 `[Step(index: 4), Step(index: 2, idAssertion: fragmentID)]`
- 范围 CFI 的起始点自动附加 `sideBias: .before`,结束点附加 `sideBias: .after`
### 4.3 RDEPUBCFIDOMPathBuilder
从 HTML 源码中提取带有 `id` 属性的元素路径映射。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIDOMPathBuilder.swift`
```swift
public enum RDEPUBCFIDOMPathBuilder {
// 从 HTML 中提取 fragmentID -> CFIPath 映射
public static func fragmentPaths(in html: String) -> [String: RDEPUBCFIPath]
}
```
**实现细节**
- 使用正则表达式匹配 HTML 标签,维护一个模拟 DOM 栈
- 每遇到开标签,计算子节点索引(`childIndex * 2`),生成 `RDEPUBCFIStep`
- 从标签属性中提取 `id``xml:id` 作为 `idAssertion`
- 识别并跳过 void 元素(`br`、`img`、`input` 等)和可忽略标签(`!doctype`
- 闭标签时弹出栈顶,重置该深度的子节点计数
---
## 5. 容错恢复引擎RDEPUBCFIRecoveryEngine
当 CFI 精确定位失败时(如 DOM 结构变更),恢复引擎通过多级降级策略尝试找到最佳匹配位置。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIRecoveryEngine.swift`
### 5.1 恢复置信度
```swift
public struct RDEPUBCFIRecoveryResult: Equatable {
public enum Confidence: Int, Codable {
case exactPath // 精确路径匹配
case assertionCalibrated // 路径匹配 + 文本断言校准
case siblingRecovered // 兄弟节点恢复
case tokenRecovered // 词元索引恢复
case fragmentFallback // 片段 ID 降级
case offsetFallback // 偏移量兜底
}
public var chapterOffset: Int
public var confidence: Confidence
}
```
### 5.2 恢复策略(按优先级)
| 优先级 | 策略 | 说明 |
|--------|------|------|
| 1 | `exactPath` | 在 CFIMap 中查找精确匹配的 marker 或 pathRange |
| 2 | `assertionCalibrated` | 精确路径匹配后,使用文本断言在窗口内校准偏移 |
| 3 | `siblingRecovered` | 查找同父级的兄弟节点,通过 `siblingScore` 选择最近匹配 |
| 4 | `tokenRecovered` | 利用词元索引token index在章节文本中搜索匹配 |
| 5 | `fragmentFallback` | 回退到 fragment ID 对应的已知偏移量 |
| 6 | `offsetFallback` | 使用兜底偏移量 |
### 5.3 核心算法
**校准机制**`calibratedOffset`
- 在给定偏移量附近 ±512 字符窗口内搜索 `textAssertion.exact` 文本
- 对每个候选位置计算 `assertionScore`距离越近分数越低prefix/suffix 匹配则大幅加分(不匹配 +10000
- 选取得分最低的候选位置
**兄弟恢复**`siblingRecovered`
- 查找与目标路径同父级、同深度的已知 marker
- 通过 `siblingSignature``parent_path#childIndex`)和索引距离计算相似度分数
**词元恢复**`tokenRecovered`
- 在预构建的 token index 中查找包含 `textAssertion.exact` 的词元
- 对每个候选锚点执行校准,选择校准距离最小的结果
**调用入口**
```swift
let result = RDEPUBCFIRecoveryEngine.recover(
cfi: someCFI,
cfiMap: chapterCFIMap,
chapterText: "章节纯文本...",
fragmentOffsets: ["section1": 150, "section2": 800],
fallbackOffset: 0,
lastOffset: 5000
)
// result?.confidence 反映恢复质量
```
---
## 6. 文本断言验证RDEPUBCFITextAssertion
文本断言用于在 CFI 定位后验证所指位置的文本内容是否符合预期,增强定位鲁棒性。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFITextAssertion.swift`
```swift
public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable {
public var prefix: String? // 目标文本之前的上下文
public var exact: String? // 精确匹配的目标文本
public var suffix: String? // 目标文本之后的上下文
}
```
**在 CFI 中的表示**`[prefix,exact,suffix]`,位于偏移量和侧偏之后。
**示例**
```swift
// CFI: epubcfi(/6/4[ch01.xhtml]!/4/2/1:100;s=a[这是一段,目标文本,后续内容])
let assertion = cfi.textAssertion
print(assertion?.prefix) // Optional("这是一段")
print(assertion?.exact) // Optional("目标文本")
print(assertion?.suffix) // Optional("后续内容")
```
**容错引擎中的应用**
- `exact` 用于在窗口内搜索实际文本位置
- `prefix``suffix` 用于对候选位置评分,优先选择上下文都匹配的位置
---
## 7. 兼容性处理RDEPUBCFICompatibility
提供宽松解析接口,兼容非标准 CFI 格式。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFICompatibility.swift`
```swift
public enum RDEPUBCFICompatibility {
// 宽松解析单点 CFI解析失败返回 nil 而非抛异常)
public static func parseLossy(_ rawValue: String?) -> RDEPUBCFI?
// 宽松解析范围 CFI
public static func parseRangeLossy(_ rawValue: String?) -> RDEPUBCFIRange?
}
```
**范围 CFI 兼容策略**
- 优先使用标准 `parseRange`(逗号三分段格式)
- 若失败,尝试以 `..``-` 作为分隔符拆分为两个独立 CFI 分别解析
- 支持 `epubcfi(...)-epubcfi(...)``epubcfi(...)..epubcfi(...)` 格式
**使用场景**:处理第三方生成器或旧版本导出的非标准范围标记。
---
## 8. CFI 映射RDEPUBCFIMap
CFI 映射是章节级别的索引结构,将 CFI 路径映射到章节文本偏移量,是容错恢复引擎的核心数据源。
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIMap.swift`
### 8.1 RDEPUBCFIMap
```swift
public struct RDEPUBCFIMap: Codable, Equatable {
public var href: String // 章节文件路径
public var renderVersion: Int // 渲染版本号
public var domVersion: Int // DOM 版本号
public var markers: [RDEPUBCFIMarker] // CFI 路径到偏移量的标记列表
public var textAssertions: [String: RDEPUBCFITextAssertion] // 文本断言缓存
public var pathRanges: [RDEPUBCFIPathRange] // 路径范围列表
public var recoveryMetadata: RDEPUBCFIRecoveryMetadata // 恢复元数据
// 精确匹配 marker
public func marker(matching path: RDEPUBCFIPath) -> RDEPUBCFIMarker?
}
```
### 8.2 RDEPUBCFIMarker
每个 marker 记录一个 CFI 路径对应的章节信息:
```swift
public struct RDEPUBCFIMarker: Codable, Equatable {
public var cfiPath: RDEPUBCFIPath // CFI 路径
public var chapterOffset: Int? // 章节内字符偏移
public var fragmentID: String? // 片段 ID
public var textNodeLength: Int? // 文本节点长度
public var textNodeChecksum: UInt64? // 文本节点校验和FNV-1a 64
public var normalizedTextPreview: String? // 归一化文本预览(前 24 字符)
public var domSiblingSignature: String? // DOM 兄弟签名
}
```
### 8.3 RDEPUBCFIPathRange
描述一个 CFI 路径对应的文本偏移范围:
```swift
public struct RDEPUBCFIPathRange: Codable, Equatable {
public var cfiPath: RDEPUBCFIPath // CFI 路径
public var startOffset: Int // 范围起始偏移
public var endOffset: Int // 范围结束偏移
public var textNodeLength: Int // 文本节点长度
}
```
### 8.4 RDEPUBCFIRecoveryMetadata
恢复元数据,为容错引擎提供多维度恢复依据:
```swift
public struct RDEPUBCFIRecoveryMetadata: Codable, Equatable {
public var domFingerprint: String // DOM 指纹SHA-256
public var normalizedTextChecksum: String // 归一化文本校验和
public var tokenIndex: [RDEPUBCFITokenAnchor] // 词元锚点索引
public var fragmentPathMap: [String: RDEPUBCFIPath] // fragment -> 路径映射
}
```
### 8.5 RDEPUBCFITokenAnchor
词元锚点,用于基于内容的恢复:
```swift
public struct RDEPUBCFITokenAnchor: Codable, Equatable {
public var token: String // 采样词元12 字符窗口96 步长,最多 64 个)
public var occurrence: Int // 该词元的出现次数
public var chapterOffset: Int // 对应的章节偏移
public var cfiPath: RDEPUBCFIPath // 对应的 CFI 路径
}
```
### 8.6 映射构建流程
`RDEPUBCFITextNodeMapBuilder.makeMap` 方法负责构建完整映射:
1. **归一化文本**:解码 HTML 实体、合并连续空白、统一空白字符
2. **提取 fragment 路径**:通过 `RDEPUBCFIDOMPathBuilder.fragmentPaths` 获取 id -> path 映射
3. **构建 markers**:逐标签遍历 HTML维护 DOM 栈,为每个文本节点创建 marker
4. **构建 pathRanges**:将 markers 转换为偏移范围列表
5. **构建恢复元数据**:生成 DOM 指纹、文本校验和、词元锚点索引
---
## 9. 错误类型RDEPUBCFIError
**文件**`Sources/RDEpubReaderView/EPUBCore/CFI/RDEPUBCFIError.swift`
```swift
public enum RDEPUBCFIError: Error, Equatable {
case empty // 输入为空或空白
case invalidWrapper(String) // 缺少 epubcfi() 包装
case invalidPath(String) // 路径格式无效(不以 / 开头)
case unsupportedRange(String) // 范围格式不正确(非三段逗号分隔)
}
```
| 错误 | 触发条件 |
|------|----------|
| `empty` | 输入为 `nil`、空字符串或纯空白 |
| `invalidWrapper` | 未以 `epubcfi(` 开头或未以 `)` 结尾 |
| `invalidPath` | 路径部分非空且不以 `/` 开头 |
| `unsupportedRange` | 范围 CFI 的逗号分隔部分不等于 3 |
---
## 10. 使用场景与调用链
### 10.1 保存阅读位置
```
用户翻页
→ RDEPUBCFIGenerator.makeOffsetCFI(href, fileIndex, chapterOffset)
→ RDEPUBCFISerializer.serialize(cfi)
→ 存储 epubcfi(...) 字符串
```
### 10.2 恢复阅读位置
```
加载 epubcfi(...) 字符串
→ RDEPUBCFIParser.parse(rawValue)
→ RDEPUBCFIResolver.resolve(cfi)
→ 获取 href, fileIndex, chapterOffset
→ 若偏移量无效,进入恢复引擎
→ RDEPUBCFIRecoveryEngine.recover(cfi, cfiMap, chapterText, ...)
→ 依次尝试 exactPath → sibling → token → fragment → offset
```
### 10.3 高亮选中文本
```
用户选择文本范围
→ 获取 start/end DOM 位置
→ RDEPUBCFIGenerator.makeOffsetRangeCFI(href, fileIndex, startOffset, endOffset)
→ RDEPUBCFISerializer.serializeRange(range)
→ 存储范围 CFI
```
### 10.4 章节加载时构建索引
```
章节 HTML 加载完成
→ RDEPUBCFITextNodeMapBuilder.makeMap(href, rawHTML, chapterText, fragmentOffsets)
→ 生成 RDEPUBCFIMapmarkers + pathRanges + recoveryMetadata
→ 缓存供后续恢复使用
```
### 10.5 兼容性解析
```
外部导入非标准 CFI
→ RDEPUBCFICompatibility.parseLossy(rawValue) // 宽松解析
→ RDEPUBCFICompatibility.parseRangeLossy(rawValue) // 支持 .. / - 分隔符
```
---
## 文件清单
| 文件 | 职责 |
|------|------|
| `RDEPUBCFI.swift` | 顶层 CFI 模型与侧偏枚举 |
| `RDEPUBCFIPath.swift` | 路径模型与 step 定义 |
| `RDEPUBCFIRange.swift` | 范围模型 |
| `RDEPUBCFIParser.swift` | 字符串 → 模型解析 |
| `RDEPUBCFISerializer.swift` | 模型 → 字符串序列化 |
| `RDEPUBCFIResolver.swift` | CFI → 资源定位结果 |
| `RDEPUBCFIGenerator.swift` | 章节信息 → CFI 生成 |
| `RDEPUBCFIDOMPathBuilder.swift` | HTML → fragment 路径映射 + 文本节点映射构建 |
| `RDEPUBCFIRecoveryEngine.swift` | 多级容错恢复引擎 |
| `RDEPUBCFITextAssertion.swift` | 文本断言模型 |
| `RDEPUBCFICompatibility.swift` | 非标准格式兼容解析 |
| `RDEPUBCFIMap.swift` | CFI 映射、marker、恢复元数据 |
| `RDEPUBCFIError.swift` | 错误类型定义 |

425
Doc/CHAPTER_RUNTIME.md Normal file
View File

@ -0,0 +1,425 @@
# 章节运行时详解
> 最后更新2026-06-18
本文档详细描述 ReadViewSDK 章节运行时子系统的架构、缓存策略、加载流程和优化机制。
---
## 1. 概述
章节运行时是 EPUBUI 层的核心子系统,负责章节的按需加载、缓存管理和页码映射。它位于 `RDEPUBReaderContext``RDEPUBReaderRuntime` 架构中,是实现大书(如 1000+ 章的网络小说)流畅阅读的关键。
**核心设计目标**
- 快速打开:用户点击书籍后 1-2 秒内可开始阅读
- 按需加载:只加载当前窗口内的章节,内存占用可控
- 渐进补全:后台逐步补全所有章节的页码信息
**关键文件**
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/` 目录
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift`
---
## 2. 三级缓存架构
```
┌─────────────────────────────────────────────────────────┐
│ Tier 1: 内存缓存 (RDEPUBChapterRuntimeStore) │
│ ├─ chapterDataCache: [Int: RDEPUBRuntimeChapter] │
│ ├─ pageCountCache: [CacheKey: RDEPUBRuntimePageCount] │
│ ├─ imageCache: NSCache<NSString, UIImage> (50 上限) │
│ └─ 窗口驱逐:只保留当前章节 ± windowRadius 的章节 │
└──────────────────────────┬──────────────────────────────┘
│ miss
┌──────────────────────────▼──────────────────────────────┐
│ Tier 2: 磁盘摘要缓存 (RDEPUBChapterSummaryDiskCache) │
│ ├─ 路径: ~/Caches/RDEPUBChapterSummaryCache/{bookID}/ │
│ ├─ 文件名: SHA256(bookID_spineIdx_renderSig_contentHash)│
│ ├─ 格式: JSON (RDEPUBChapterSummary) │
│ ├─ 写入: 异步serial DispatchQueue
│ └─ 读取: 同步readAll 批量读取) │
└──────────────────────────┬──────────────────────────────┘
│ miss
┌──────────────────────────▼──────────────────────────────┐
│ Tier 3: 全书分页缓存 (RDEPUBTextBookCache) │
│ ├─ 路径: ~/Caches/RDEPUBTextBookCache/ │
│ ├─ 格式: NSSecureCoding archive │
│ ├─ 内容: 每章的 pageRanges + breakReasons + semanticHints│
│ └─ 失效: schemaVersion 变更时全部失效 │
└─────────────────────────────────────────────────────────┘
```
### 2.1 Tier 1: RDEPUBChapterRuntimeStore
**文件**`RDEPUBChapterRuntimeStore.swift`
内存缓存,存储当前窗口内的章节数据。
```swift
final class RDEPUBChapterRuntimeStore {
// 章节数据缓存(完整 RuntimeChapter
private let chapterDataCache = RDEPUBChapterDataCache()
// 页数缓存(仅页范围,不含 NSAttributedString
private let pageCountCache = RDEPUBPageCountCache()
// 图片缓存NSCache上限 50 张)
let imageCache = NSCache<NSString, UIImage>()
// 章节加载队列串行userInitiated QoS
let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated)
}
```
**窗口驱逐策略**
```swift
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
currentSpineIndex = spineIndex
let lowerBound = max(0, spineIndex - radius)
let upperBound = min(totalSpineCount - 1, spineIndex + radius)
windowSpineIndices = Array(lowerBound...upperBound)
}
```
- 保留范围:`[spineIndex - windowRadius, spineIndex + windowRadius]`
- `evictableSpineIndices()` 返回窗口外的已缓存章节索引
- 章节切换时调用 `setCurrentChapter` 更新窗口,然后驱逐窗口外章节
**内存警告处理**
```swift
func handleMemoryWarning() {
evictAllExceptCurrent() // 驱逐除当前章节外的所有缓存
imageCache.removeAllObjects() // 清空图片缓存
}
```
**导航优先级抢占**
当用户快速翻页时,新的导航请求可以抢占正在构建的章节:
```swift
func setNavigationTarget(spineIndex: Int) // 设置导航目标
func consumeNavigationTarget() -> Int? // 消费导航目标(构建完成后检查)
```
`RDEPUBChapterLoader` 在完成当前章节构建后,会检查是否有新的导航目标,如有则立即切换到新目标。
### 2.2 Tier 2: RDEPUBChapterSummaryDiskCache
**文件**`RDEPUBChapterSummaryDiskCache.swift`
磁盘摘要缓存,存储章节的分页元数据(不含完整 NSAttributedString
**存储路径**`~/Library/Caches/RDEPUBChapterSummaryCache/{bookID}/`
**文件命名**`SHA256(bookID_spineIndex_renderSignature_contentHash).json`
**缓存内容**RDEPUBChapterSummary
- `pageRanges: [NSRange]` — 每页的文本范围
- `pageCount: Int` — 总页数
- `fragmentOffsets: [String: Int]` — fragment ID 到偏移量的映射
- `cfiMap: RDEPUBCFIMap?` — CFI 映射
- `renderSignature: String` — 渲染参数签名
- `chapterContentHash: String` — 章节 HTML 的 SHA-256
- `pageMetadataList: [PageMetadataSummary]` — 每页的语义元数据
**写入策略**
- 异步写入serial DispatchQueue
- 原子写入(先写临时文件,再 rename
**批量读取**
```swift
func readAll(keys: [RDEPUBChapterCacheKey]) -> (
summaries: [Int: RDEPUBChapterSummary],
partialBuilder: RDEPUBBookPageMap.Builder
)
```
一次读取所有章节的摘要,同时构建 `BookPageMap.Builder`,避免重复遍历。
### 2.3 Tier 3: RDEPUBTextBookCache
全书分页缓存,使用 `NSSecureCoding` 归档。包含每章的完整分页信息pageRanges、breakReasons、semanticHints。当 `schemaVersion` 变更时全部失效。
---
## 3. RDEPUBChapterCacheKey — 缓存键设计
**文件**`RDEPUBChapterCacheKey.swift`
```swift
struct RDEPUBChapterCacheKey: Hashable {
let bookID: String // 书籍唯一标识
let spineIndex: Int // 章节索引
let renderSignature: String // 渲染参数签名
let chapterContentHash: String // 章节 HTML 的 SHA-256
}
```
**renderSignature 组成**
```swift
let renderSignature = [
style.font.fontName, // 字体名称
"\(style.font.pointSize)", // 字号
"\(lineHeightMultiple)", // 行距倍数
"\(style.lineSpacing)", // 行间距
layoutConfig.cacheSignature, // 布局参数签名
"\(schemaVersion)" // 缓存 schema 版本
].joined(separator: "|")
```
**缓存失效语义**
- 换字体/字号 → renderSignature 变化 → 缓存失效
- EPUB 内容更新 → contentHash 变化 → 缓存失效
- 不同书籍 → bookID 不同 → 互不干扰
- schemaVersion 变更 → 全部失效
---
## 4. RDEPUBChapterLoader — 章节加载器
**文件**`RDEPUBChapterLoader.swift`
### 4.1 加载流程
```swift
func loadChapter(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority = .navigation,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
)
```
**三级缓存串联**
1. **Tier 1 命中**`store.chapterData(for: spineIndex)` → 直接返回
2. **Tier 1 页数缓存命中**`store.pageCount(for: cacheKey)` → 轻量路径(跳过分页计算)
3. **Tier 2 命中**`summaryDiskCache?.read(for: cacheKey)` → 轻量路径
4. **全部未命中**:完整路径(渲染 + 分页 + 写缓存)
### 4.2 轻量路径 vs 完整路径
**轻量路径**(有缓存页范围时):
1. 读取 HTML 并渲染为 NSAttributedString
2. 使用缓存的 pageRanges 直接构建页面
3. 跳过分页计算(最耗时的步骤)
**完整路径**(无缓存时):
1. 使用 `RDEPUBTextBookBuilder.buildChapter()` 完整构建
2. 包含 HTML→NSAttributedString→分页→尾页规范化全流程
3. 构建完成后写入磁盘摘要缓存
### 4.3 加载优先级
```swift
enum LoadPriority {
case navigation // 用户导航触发(最高优先级,可抢占)
case preview // 预览触发
case prefetch // 预取触发(最低优先级)
}
```
**导航优先级抢占**:当 `priority == .navigation` 时,完成构建后检查 `consumeNavigationTarget()`,如有新目标则立即切换。
### 4.4 同步加载
```swift
func loadChapterSynchronouslyForMigration(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore?
) throws -> RDEPUBRuntimeChapter
```
使用信号量阻塞当前线程,等待章节加载完成。用于快速打开路径中加载首个可渲染章节。
---
## 5. RDEPUBBookPageMap — 轻量页码映射
**文件**`RDEPUBBookPageMap.swift`
轻量级全书页码映射,不持有 NSAttributedString内存占用约 100KB/1000 章。
### 5.1 数据结构
```swift
struct RDEPUBBookPageMapEntry {
let spineIndex: Int
let href: String
let title: String
let pageCount: Int
let absolutePageStart: Int // 该章节的起始绝对页码
let fragmentOffsets: [String: Int]
}
struct RDEPUBBookPageMap {
let entries: [RDEPUBBookPageMapEntry]
let totalPages: Int
var totalChapters: Int { entries.count }
}
```
### 5.2 页码查询
```swift
// 绝对页码 → 章节索引二分查找O(log n)
func spineIndex(forAbsolutePage absolutePage: Int) -> Int?
// 绝对页码 → 章节内本地页码
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int?
// (spineIndex, localPageIndex) → 绝对页码
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int?
```
### 5.3 Builder 增量构建
```swift
struct Builder {
mutating func add(
spineIndex: Int,
href: String,
title: String,
pageCount: Int,
fragmentOffsets: [String: Int]
)
func build() -> RDEPUBBookPageMap
}
```
Builder 模式支持增量添加章节信息,`build()` 时自动按 spineIndex 排序并计算绝对页码。
---
## 6. RDEPUBPageResolver — 页码解析器
**文件**`RDEPUBPageResolver.swift`
将绝对页码解析为章节和本地页码的组合。
```swift
struct RDEPUBResolvedPage {
let spineIndex: Int
let localPageIndex: Int
let page: RDEPUBTextPage?
}
func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage?
```
解析流程:
1. 从 `BookPageMap` 查找 spineIndex 和 localPageIndex
2. 从 `ChapterRuntimeStore` 获取已加载的章节数据
3. 如果章节未加载,触发按需加载
---
## 7. 后台元数据解析优化
**文件**`RDEPUBReaderPaginationCoordinator.swift`
### 7.1 整体流程
```
RDEPUBMetadataParseWorker.start(token)
├─ 预计算所有章节 contentHash串行
├─ readAll(keys:) 批量读取磁盘缓存
├─ refreshBookPageMapInPlace缓存部分
├─ waitForReadingInteractionToSettle0.8s 冷却)
├─ OperationQueue (并发 N=CPU核心数):
│ ├─ buildChapter(spineIndex) // 渲染+分页
│ ├─ chapterCacheKey(spineIndex) // 复用预计算 hash
│ ├─ summary.write() // 异步写盘
│ └─ 每 32 章刷新 BookPageMap
└─ 最终 refreshBookPageMapInPlace
```
### 7.2 预计算 contentHash
**问题**:每个章节在构建缓存键时需要读取 HTML 并计算 SHA-256重复 I/O 开销大。
**优化**:在后台解析开始时,串行预计算所有章节的 contentHash
```swift
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
let html = parser.htmlString(forRelativePath: href)
contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? ""
}
```
后续所有 `chapterCacheKey` 调用都使用预计算值。
### 7.3 冻结 renderSignature
**问题**:用户在后台解析进行中更改字号/行距,会导致部分章节用旧签名、部分用新签名写入缓存。
**解决**:在 `RDEPUBMetadataParseWorker` 初始化时冻结签名:
```swift
let renderSignature = context.currentRenderSignature()
// 后续所有 chapterCacheKey 调用使用此固定值
```
token 机制确保设置变更会触发新的解析任务(新 token旧任务自动废弃。
### 7.4 锁区瘦身
**原始实现**`resultLock` 内调用 `buildPageMap()`,遍历全量 catalog 和 summaries。
**优化**:锁内只做写入和计数,快照数据后锁外构建 pageMap
```swift
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
summariesBySpineIndex[spineIndex] = renderResult
totalResolvedCount += 1
if shouldRefresh {
snapshot = summariesBySpineIndex // 快照
}
resultLock.unlock()
if let snapshot {
let partialMap = buildPageMap(from: catalog, summaries: snapshot) // 锁外
}
```
### 7.5 可配置刷新间隔
```swift
static var pageMapRefreshInterval: Int = 32 // 默认 32 章
```
可实测调优32 / 48 / 64。值越大UI 刷新频率越低,后台解析吞吐越高。
### 7.6 用户交互冷却
等待用户操作冷却 0.8 秒后再开始后台解析,避免与用户翻页操作竞争资源:
```swift
try await Task.sleep(nanoseconds: 800_000_000)
```
---
## 8. 关键配置参数
| 参数 | 位置 | 默认值 | 说明 |
|------|------|--------|------|
| `onDemandChapterWindowSize` | `RDEPUBReaderConfiguration` | `3` | 按需加载窗口大小奇数3-15 |
| `chapterWindowRadius` | 计算属性 | `1` | 内存缓存窗口半径windowSize / 2 |
| `metadataParsingConcurrency` | `RDEPUBReaderConfiguration` | CPU 核心数 | 后台解析并发数 |
| `pageMapRefreshInterval` | 静态变量 | `32` | 每 N 章刷新一次 UI |
| `imageCache.countLimit` | `RDEPUBChapterRuntimeStore` | `50` | 图片缓存上限 |
| 冷却时间 | 硬编码 | `0.8s` | 用户交互冷却时间 |
| `schemaVersion` | `RDEPUBChapterSummary` | - | 缓存 schema 版本 |

View File

@ -105,15 +105,15 @@
## Evidence仓库路径
- Build flags`Podfile`
- Pod metadata/toolchain`RDReaderView.podspec`
- Archive extraction`Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`
- Resource path validationpost-extraction`Sources/RDReaderView/EPUBCore/RDEPUBParser+Resources.swift`
- Scheme handler reads full file bytes`Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
- WebView bridge + message handling`Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
- External link opening`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
- UserDefaults persistence implementation`Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
- Hidden paginator web view`Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
- Debug logging`Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
- Pod metadata/toolchain`RDEpubReaderView.podspec`
- Archive extraction`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Archive.swift`
- Resource path validationpost-extraction`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser+Resources.swift`
- Scheme handler reads full file bytes`Sources/RDEpubReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
- WebView bridge + message handling`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
- External link opening`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift`
- UserDefaults persistence implementation`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
- Hidden paginator web view`Sources/RDEpubReaderView/EPUBCore/RDEPUBPaginator.swift`
- Debug logging`Sources/RDEpubReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
- Vendored dependencies`Pods/`、`ReadViewDemo/Pods/`
---

View File

@ -6,43 +6,43 @@
## 语言与工程约束
- **主要语言**SwiftPodspec 声明 `s.swift_versions = ["5.10"]`,见 `RDReaderView.podspec`
- **最低系统版本**Podspec `iOS 15.0``RDReaderView.podspec`),示例工程 Podfile/构建设置里常见为 `iOS 15.6``Podfile`
- **主要语言**SwiftPodspec 声明 `s.swift_versions = ["5.10"]`,见 `RDEpubReaderView.podspec`
- **最低系统版本**Podspec `iOS 15.0``RDEpubReaderView.podspec`),示例工程 Podfile/构建设置里常见为 `iOS 15.6``Podfile`
- **依赖管理**CocoaPods`Podfile`、`ReadViewDemo/Podfile`、`Podfile.lock`
## 命名约定
**文件/类型命名Swift**
- 以类型名为文件名的单文件组织较常见:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
- 以类型名为文件名的单文件组织较常见:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
- 大量使用前缀区分模块域:
- `RD...`:阅读器 UI/控制器相关(如 `Sources/RDReaderView/RDReaderView.swift`、`Sources/RDReaderView/RDURLReaderController.swift`
- `RDEPUB...`EPUB Core/UI/渲染相关(如 `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`
- Extension 文件使用 `+` 命名:`Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
- `RD...`:阅读器 UI/控制器相关(如 `Sources/RDEpubReaderView/RDEpubReaderView.swift`、`Sources/RDEpubReaderView/RDEpubURLReaderController.swift`
- `RDEPUB...`EPUB Core/UI/渲染相关(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
- Extension 文件使用 `+` 命名:`Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
**变量/函数命名:**
- 基本遵循 Swift lowerCamelCase`parse(epubURL:)``Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
- 常量多用 `static let``kRDEPUBHighlightAttributeName``Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift`
- 基本遵循 Swift lowerCamelCase`parse(epubURL:)``Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
- 常量多用 `static let``kRDEPUBHighlightAttributeName``Sources/RDEpubReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift`
## 代码风格与排版(从现有代码归纳)
**缩进与换行:**
- 多数文件使用 4 空格缩进(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`
- 多数文件使用 4 空格缩进(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`、`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
- 历史代码中更常见”强制换行/多行括号”风格
**空行与分组:**
- UI 相关文件常用空行分隔属性/初始化/布局段落
- `// MARK:` 用于分区组织(示例:`Sources/RDReaderView/RDReaderGestureController.swift`、`Sources/RDReaderView/EPUBTextRendering/RDPlainTextBookBuilder.swift`
- `// MARK:` 用于分区组织(示例:`Sources/RDEpubReaderView/RDEpubReaderGestureController.swift`、`Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift`
**类型组织:**
- 偏好用 `extension` 拆分职责/协议实现(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift` 的 `UITableViewDataSource/Delegate`
- API 暴露处使用 `public`、`public final class`、`public enum/struct`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`
- “对外只读、内部可写”常用 `public internal(set)`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
- API 暴露处使用 `public`、`public final class`、`public enum/struct`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
- “对外只读、内部可写”常用 `public internal(set)`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
## 导入与依赖使用
**import**
- UIKit/UI 文件:`import UIKit`(大量文件)
- Core/模型文件:`import Foundation`(如 `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`
- Core/模型文件:`import Foundation`(如 `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
- 三方依赖按需引入:
- `SnapKit`:布局
- `SSAlertSwift`:弹窗/提示
@ -52,15 +52,15 @@
## 错误处理与日志
- Core 解析层倾向用 `throws` + 自定义 `Error`(示例:`Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`、`Sources/RDReaderView/EPUBCore/RDEPUBModels.swift` 的 `RDEPUBParserError`
- Core 解析层倾向用 `throws` + 自定义 `Error`(示例:`Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`、`Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift` 的 `RDEPUBParserError`
- UI/控制器层常见 `guard` 早返回(示例:`ReadViewDemo/ReadViewDemo/ViewController.swift`
- 未检测到统一日志框架(未发现专用 logging package/config出现时以系统 API/局部输出为主(需按具体文件核对)。
## 注释与文档
- **项目规则(强约束)**:代码标识符保持英文,但**代码注释/文档/提交信息使用中文**(见 `CONTEXT.md`)。
- 历史文件常带 Xcode 头部注释块(示例:`Sources/RDReaderView/ReaderView/RDReaderView.swift`)。
- 公共 API 处存在少量三斜线文档注释(示例:`Sources/RDReaderView/RDReaderView.swift` 的中文说明)。
- 历史文件常带 Xcode 头部注释块(示例:`Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift`)。
- 公共 API 处存在少量三斜线文档注释(示例:`Sources/RDEpubReaderView/RDEpubReaderView.swift` 的中文说明)。
## Lint / Formatter / 静态检查
@ -77,9 +77,9 @@
- `CONTEXT.md`
- `Podfile`
- `RDReaderView.podspec`
- `RDEpubReaderView.podspec`
- `ReadViewDemo/ReadViewDemo/ViewController.swift`
- `Sources/RDReaderView/ReaderView/RDReaderView.swift`
- `Sources/RDReaderView/EPUBCore/RDEPUBParser.swift`
- `Sources/RDReaderView/EPUBCore/RDEPUBModels.swift`
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
- `Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift`
- `Sources/RDEpubReaderView/EPUBCore/RDEPUBParser.swift`
- `Sources/RDEpubReaderView/EPUBCore/RDEPUBModels.swift`
- `Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift`

View File

@ -0,0 +1,642 @@
# EPUBCore 模块代码级参考文档
> 最后更新2026-06-18
---
## 1. 模块概述
`EPUBCore` 是 ReadViewSDK 的**基础层**,负责 EPUB 文件解析、资源管理、WebView 渲染、JavaScript 桥接、CFI 定位、搜索和分页计算。它是上层 EPUBTextRendering 和 EPUBUI 的数据提供者。
**文件清单(~50 个 Swift 文件):**
| 子系统 | 核心文件 | 职责 |
|--------|----------|------|
| **解析器** | `RDEPUBParser*.swift` | EPUB 解压、OPF 解析、TOC 解析、资源提取 |
| **数据模型** | `RDEPUBModels.swift` | Metadata、ManifestItem、SpineItem、TOC 等基础模型 |
| **Publication** | `RDEPUBPublication.swift` | 出版物抽象,聚合 parser 和 resourceResolver |
| **阅读会话** | `RDEPUBReadingSession.swift` | 管理分页快照、阅读位置、待导航状态 |
| **WebView** | `RDEPUBWebView*.swift` | WKWebView 封装,支持 Reflowable 和 Fixed Layout |
| **JS 桥接** | `RDEPUBJavaScriptBridge.swift` | JavaScript 消息协议和脚本注入 |
| **资源解析** | `RDEPUBResourceResolver.swift` | href 归一化、文件路径解析 |
| **URL 方案** | `RDEPUBResourceURLSchemeHandler.swift` | 自定义 `ss-reader://` 协议处理 |
| **分页器** | `RDEPUBPaginator.swift` | WebView 分页计算(按章测量页数) |
| **CFI** | `CFI/RDEPUBCFI*.swift` | EPUB CFI 解析、生成、序列化、恢复 |
| **搜索** | `RDEPUBSearchEngine.swift` | HTML 全文搜索 |
| **样式** | `RDEPUBStyleSheetBuilder.swift` | CSS 注入与分页样式生成 |
| **偏好** | `RDEPUBPreferences.swift` | 阅读偏好设置模型 |
| **资源文件** | `RDEPUBAssetRepository.swift` | JS/CSS/HTML 资源文件加载 |
| **笔记** | `Notes/RDEPUBNote*.swift` | 脚注/尾注检测与解析 |
---
## 2. 解析器子系统
### 2.1 RDEPUBParser
**文件:** `RDEPUBParser.swift` + 扩展文件
EPUB 文件解析的核心类,负责从 .epub 文件中提取结构化数据。
```swift
public final class RDEPUBParser {
public internal(set) var metadata: RDEPUBMetadata // 书籍元数据
public internal(set) var manifest: [String: RDEPUBManifestItem] // 资源清单
public internal(set) var spine: [RDEPUBSpineItem] // 阅读顺序
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] // 目录
public internal(set) var extractionRootURL: URL? // 解压根目录
public internal(set) var opfURL: URL? // OPF 文件路径
public var opfDirectoryURL: URL? // OPF 所在目录
/// 解析 EPUB 文件(解压 + 解析 container.xml + 解析 OPF
public func parse(epubURL: URL) throws
/// 直接解析 OPF 文件
public func parseOPF(at opfURL: URL) throws
/// 解析 Navigation Document 或 NCX
public func parseTOC() -> [EPUBTableOfContentsItem]
/// 从 parser 创建 Publication
public func makePublication() -> RDEPUBPublication
}
```
**扩展文件职责:**
| 文件 | 职责 |
|------|------|
| `RDEPUBParser+Archive.swift` | ZIP 解压ZIPFoundation提取到 Caches 目录 |
| `RDEPUBParser+Package.swift` | OPF XML SAX 解析metadata/manifest/spine |
| `RDEPUBParser+TOC.swift` | Navigation Document 和 NCX 解析 |
| `RDEPUBParser+Resources.swift` | 资源文件读取HTML 字符串、文件 URL |
| `RDEPUBParser+ReadingProfile.swift` | 阅读配置文件检测 |
### 2.2 RDEPUBParserError
```swift
public enum RDEPUBParserError: LocalizedError {
case archiveOpenFailed(URL)
case missingContainerXML
case missingRootFile
case invalidRootFilePath(String)
case invalidXML(URL)
case missingManifestItem(idref: String)
case emptySpine
case invalidArchiveEntryPath(String)
}
```
---
## 3. 数据模型
### 3.1 RDEPUBMetadata
**文件:** `RDEPUBModels.swift`
```swift
public struct RDEPUBMetadata: Codable, Equatable {
public var identifier: String? // ISBN/UUID
public var title: String // 书名
public var author: String? // 作者
public var language: String? // 语言
public var version: String? // EPUB 版本
public var layout: RDEPUBLayout // reflowable / fixed
public var spread: String? // none / auto
public var readingProgression: RDEPUBReadingProgression // ltr / rtl / auto
}
```
### 3.2 RDEPUBManifestItem
```swift
public struct RDEPUBManifestItem: Codable, Equatable {
public var id: String // 资源 ID
public var href: String // 相对路径
public var mediaType: String // MIME 类型
public var properties: [String] // 属性nav, mathml, svg 等)
public var fallback: String? // 回退资源 ID
public var mediaOverlay: String? // 媒体叠加 ID
public var title: String? // 标题
public var isNavigationDocument: Bool // 是否为 Navigation Document
public var isNCX: Bool // 是否为 NCX 文件
}
```
### 3.3 RDEPUBSpineItem
```swift
public struct RDEPUBSpineItem: Codable, Equatable {
public var idref: String // 引用 manifest ID
public var href: String // 解析后的相对路径
public var mediaType: String // MIME 类型
public var title: String // 章节标题
public var linear: Bool // 是否为线性内容
public var properties: [String] // 属性page-spread-left/right
public var pageSpread: RDEPUBPageSpread? // 页面位置
public var layout: RDEPUBLayout? // 章节级布局覆盖
}
```
### 3.4 EPUBTableOfContentsItem
```swift
public struct EPUBTableOfContentsItem: Codable, Equatable {
public var title: String // 目录标题
public var href: String // 链接地址
public var children: [EPUBTableOfContentsItem] // 子目录(支持多级)
}
```
### 3.5 枚举类型
```swift
public enum RDEPUBLayout: String, Codable {
case reflowable // 文本重排
case fixed // 固定布局
}
public enum RDEPUBReadingProfile: String, Codable {
case webInteractive // WebView 交互模式(原 EPUB 渲染)
case webFixedLayout // WebView 固定布局
case textReflowable // 原生文本渲染DTCoreText
}
public enum RDEPUBReadingProgression: String, Codable {
case ltr, rtl, auto
}
public enum RDEPUBPageSpread: String, Codable {
case left, right, center
}
```
---
## 4. Publication
**文件:** `RDEPUBPublication.swift`
聚合 `RDEPUBParser``RDEPUBResourceResolver`,提供统一的出版物访问接口。
```swift
public final class RDEPUBPublication {
public let parser: RDEPUBParser
public let resourceResolver: RDEPUBResourceResolver
public var metadata: RDEPUBMetadata
public var manifest: [String: RDEPUBManifestItem]
public var spine: [RDEPUBSpineItem]
public var tableOfContents: [EPUBTableOfContentsItem]
public var layout: RDEPUBLayout
public var readingProfile: RDEPUBReadingProfile
public var readingProgression: RDEPUBReadingProgression
public var bookIdentifier: String?
/// 判断 Fixed Layout 是否启用双页展开
public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool
/// 生成 Fixed Layout 的 Spread 列表
public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread]
}
```
---
## 5. 阅读会话
**文件:** `RDEPUBReadingSession.swift`
管理阅读过程中的分页快照、阅读位置和待导航状态。
```swift
public final class RDEPUBReadingSession {
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
public let publication: RDEPUBPublication
public private(set) var navigatorState: RDEPUBNavigatorState
public private(set) var activePages: [EPUBPage] // 当前活跃页列表
public private(set) var activeChapters: [EPUBChapterInfo] // 当前活跃章节列表
public private(set) var stagedPages: [EPUBPage]? // 暂存页列表(待应用)
public private(set) var stagedChapters: [EPUBChapterInfo]?
public private(set) var currentViewport: RDEPUBViewport?
public private(set) var currentReadingContext: RDEPUBReadingContext?
// 状态管理
public func transition(to state: RDEPUBNavigatorState)
public func resetRuntimeState()
// 快照管理
public func setActiveSnapshot(_ snapshot: PaginationSnapshot)
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)?
// 导航
public func queueNavigation(to location: RDEPUBLocation, ...) -> Int?
public func updateReadingContext(pageNumber:location:spineIndex:chapterIndex:bookIdentifier:)
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation?
// 分页快照生成
public func makePaginationSnapshot(pageCounts:preferences:layoutContext:) -> PaginationSnapshot
}
```
### 5.1 RDEPUBNavigatorState
```swift
public enum RDEPUBNavigatorState: String, Codable {
case initializing // 初始化中
case loading // 加载中
case idle // 空闲(可应用快照)
case jumping // 跳转中
case moving // 翻页中
case repaginating // 重新分页中
public var isStableForSnapshotApplication: Bool // 仅 idle 状态为 true
}
```
---
## 6. WebView 子系统
### 6.1 RDEPUBWebView
**文件:** `RDEPUBWebView.swift` + 扩展文件
`UIView` 子类,内部封装 `WKWebView`,支持 Reflowable 和 Fixed Layout 两种渲染模式。
```swift
public final class RDEPUBWebView: UIView {
public weak var delegate: RDEPUBWebViewDelegate?
public var onRendered: (() -> Void)?
var publication: RDEPUBPublication?
var currentRenderRequest: RDEPUBRenderRequest?
var webView: WKWebView?
var schemeHandler: RDEPUBResourceURLSchemeHandler?
var currentSpineIndex: Int
var currentHref: String
var currentPageIndex: Int
var currentTotalPagesInChapter: Int
var viewportSize: CGSize
var currentFontSize: CGFloat
var currentLineHeightMultiple: CGFloat
var targetLocation: RDEPUBLocation?
var pendingHighlights: [RDEPUBHighlight]
}
```
**扩展文件:**
| 文件 | 职责 |
|------|------|
| `RDEPUBWebView+Configuration.swift` | WKWebView 配置scheme handler、user script |
| `RDEPUBWebView+Reflowable.swift` | Reflowable 模式渲染逻辑 |
| `RDEPUBWebView+FixedLayout.swift` | Fixed Layout 模式渲染逻辑 |
| `RDEPUBWebView+JavaScriptBridge.swift` | JS 消息接收与处理 |
| `RDEPUBWebView+Search.swift` | 搜索高亮渲染 |
### 6.2 RDEPUBWebViewDelegate
```swift
public protocol RDEPUBWebViewDelegate: AnyObject {
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL)
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
}
```
---
## 7. JavaScript 桥接
**文件:** `RDEPUBJavaScriptBridge.swift`
定义 Native ↔ WebView 的消息协议和脚本注入。
### 7.1 消息类型
```swift
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
case progressionChanged = "ssReaderProgressionChanged" // 阅读进度变化
case selectionChanged = "ssReaderSelectionChanged" // 文本选择变化
case internalLink = "ssReaderInternalLink" // 内部链接点击
case externalLink = "ssReaderExternalLink" // 外部链接点击
case javaScriptError = "ssReaderJSError" // JS 错误
case fixedLayoutReady = "ssReaderFixedLayoutReady" // Fixed Layout 就绪
}
```
### 7.2 注入的 JS 资源
- `rangy-core.js` — 范围与选择 API
- `rangy-serializer.js` — 选择序列化
- `cssInjector.js` — CSS 注入
- `WeReadApi.js` — 阅读器 API分页、跳转、高亮
- `epub-bridge.js` — 消息桥接
### 7.3 关键方法
```swift
enum RDEPUBJavaScriptBridge {
static var messageNames: [String] // 所有消息名称
static var userScript: String // 注入的 User Script
/// 生成 Reflowable 渲染脚本
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String
/// 生成 Fixed Layout 渲染脚本
static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String
}
```
---
## 8. 资源解析
**文件:** `RDEPUBResourceResolver.swift`
负责 href 归一化、文件路径解析和 spine 索引查找。
```swift
public final class RDEPUBResourceResolver {
public var opfDirectoryURL: URL?
/// 将相对路径转为文件 URL
public func fileURL(forRelativePath relativePath: String) -> URL?
/// 将相对路径转为 ss-reader:// 资源 URL
public func resourceURL(forRelativePath relativePath: String) -> URL?
/// href 归一化(去除 fragment解析相对路径
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String?
public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String?
/// 位置归一化(合并 href + fragment + progression
public func normalizedLocation(_ location: RDEPUBLocation, relativeToSpineIndex spineIndex: Int?, bookIdentifier: String?) -> RDEPUBLocation?
/// 根据 href 查找 spine 索引
public func spineIndex(forNormalizedHref normalizedHref: String) -> Int?
public func spineIndex(for location: RDEPUBLocation) -> Int?
/// 获取 spine 项的 href 和 title
public func href(forSpineIndex spineIndex: Int) -> String?
public func title(forSpineIndex spineIndex: Int) -> String?
}
```
---
## 9. URL 方案处理器
**文件:** `RDEPUBResourceURLSchemeHandler.swift`
实现 `WKURLSchemeHandler`,拦截 `ss-reader://book/` 请求,从 EPUB 解压目录流式读取资源文件。
```swift
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
public static let scheme = "ss-reader"
public static let host = "book"
public init(parser: RDEPUBParser)
// WKURLSchemeHandler
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask)
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask)
}
```
---
## 10. 分页器
**文件:** `RDEPUBPaginator.swift`
使用隐藏的 WKWebView 逐章加载 HTML通过 JS 测量每章的页数。
```swift
public final class RDEPUBPaginator: NSObject {
/// 计算所有章节的页数
public func calculate(
parser: RDEPUBParser,
hostingView: UIView,
presentation: RDEPUBPresentationStyle,
completion: @escaping ([Int]) -> Void
)
/// 计算单个章节的页数
public func calculateSingleChapter(
parser: RDEPUBParser,
spineIndex: Int,
hostingView: UIView,
presentation: RDEPUBPresentationStyle,
completion: @escaping (Int) -> Void
)
}
```
---
## 11. 搜索引擎
**文件:** `RDEPUBSearchEngine.swift`
```swift
protocol RDEPUBSearchEngine {
func search(keyword: String) -> [RDEPUBSearchMatch]
}
```
### RDEPUBHTMLSearchEngine
基于 HTML 的搜索引擎,逐章加载 HTML → 转为纯文本 → 关键词匹配。
```swift
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
init(parser: RDEPUBParser, publication: RDEPUBPublication)
func search(keyword: String) -> [RDEPUBSearchMatch]
}
```
### RDEPUBTextSearchEngineEPUBTextRendering 模块)
基于原生文本的搜索引擎,在 `NSAttributedString` 上直接搜索,性能更好。
```swift
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
init(textBook: RDEPUBTextBook, publication: RDEPUBPublication)
func search(keyword: String) -> [RDEPUBSearchMatch]
}
```
---
## 12. CFI 子系统
**文件:** `CFI/RDEPUBCFI*.swift`13 个文件)
实现 EPUB CFICanonical Fragment Identifier标准用于精确定位 EPUB 内容。
| 文件 | 职责 |
|------|------|
| `RDEPUBCFI.swift` | CFI 值类型,核心解析入口 |
| `RDEPUBCFIParser.swift` | CFI 字符串解析器 |
| `RDEPUBCFISerializer.swift` | CFI 序列化为字符串 |
| `RDEPUBCFIPath.swift` | CFI 路径节点 |
| `RDEPUBCFIRange.swift` | CFI 范围(起止点) |
| `RDEPUBCFIResolver.swift` | CFI → DOM 位置解析 |
| `RDEPUBCFIGenerator.swift` | DOM 位置 → CFI 生成 |
| `RDEPUBCFIMap.swift` | 章节级 CFI 映射表 |
| `RDEPUBCFIDOMPathBuilder.swift` | DOM 路径构建 |
| `RDEPUBCFITextAssertion.swift` | 文本断言(偏移校验) |
| `RDEPUBCFIRecoveryEngine.swift` | CFI 恢复引擎(节点变化后重新定位) |
| `RDEPUBCFICompatibility.swift` | CFI 兼容性处理 |
| `RDEPUBCFIError.swift` | 错误类型 |
---
## 13. 样式构建
**文件:** `RDEPUBStyleSheetBuilder.swift`
```swift
public enum RDEPUBStyleSheetBuilder {
/// 向 HTML 注入分页 CSS用于 Paginator 测量)
public static func injectPaginationCSS(into html: String, presentation: RDEPUBPresentationStyle) -> String
/// 生成渲染用 CSSviewport 尺寸、字体、行高、主题色)
public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String
}
```
---
## 14. 偏好设置
**文件:** `RDEPUBPreferences.swift`
```swift
public struct RDEPUBPreferences: Equatable {
public var fontSize: CGFloat // 字体大小
public var lineHeightMultiple: CGFloat // 行高倍数
public var reflowableContentInsets: UIEdgeInsets // Reflowable 内容边距
public var fixedContentInset: UIEdgeInsets // Fixed Layout 内容边距
public var themeBackgroundColor: String? // 主题背景色CSS
public var themeTextColor: String? // 主题文字色CSS
public var fixedBackgroundColor: String? // Fixed 背景色
public var fixedLayoutFit: RDEPUBFixedLayoutFit // Fixed 适配模式
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode // 双页模式
public var numberOfColumns: Int // 列数
public var columnGap: CGFloat // 列间距
/// 生成 PresentationStyle
public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle
/// 生成渲染请求
public func renderRequest(for page: EPUBPage, publication: RDEPUBPublication, viewportSize: CGSize, ...) -> RDEPUBRenderRequest?
}
```
---
## 15. 渲染请求模型
**文件:** `RDEPUBRenderRequest.swift`
```swift
public enum RDEPUBRenderRequest: Equatable {
case reflowable(RDEPUBReflowableRenderRequest)
case fixed(RDEPUBFixedRenderRequest)
public var isFixedLayout: Bool
public var primarySpineIndex: Int
public var primaryHref: String
}
public struct RDEPUBReflowableRenderRequest: Equatable {
public var spineIndex: Int
public var href: String
public var pageIndex: Int
public var totalPagesInChapter: Int
public var presentation: RDEPUBPresentationStyle
public var targetLocation: RDEPUBLocation?
public var highlights: [RDEPUBHighlight]
public var searchPresentation: RDEPUBSearchPresentation?
}
public struct RDEPUBFixedRenderRequest: Equatable {
public var spread: EPUBFixedSpread
public var viewportSize: CGSize
public var contentInset: UIEdgeInsets
public var fit: RDEPUBFixedLayoutFit
}
```
### RDEPUBPresentationStyle
```swift
public struct RDEPUBPresentationStyle: Equatable {
public var viewportSize: CGSize
public var contentInsets: UIEdgeInsets
public var fontSize: CGFloat
public var lineHeightMultiple: CGFloat
public var numberOfColumns: Int
public var columnGap: CGFloat
public var themeBackgroundColor: String?
public var themeTextColor: String?
}
```
---
## 16. 资源仓库
**文件:** `RDEPUBAssetRepository.swift`
管理 SDK 内置的 JS/CSS/HTML 资源文件加载。
```swift
enum RDEPUBAsset: String {
case rangyCoreScript, rangySerializerScript, cssInjectorScript
case weReadAPIScript, bridgeScript, fixedLayoutTemplate
case wxReadDefaultCSS, wxReadReplaceCSS, wxReadDarkCSS, wxReadLatinReplaceCSS
}
enum RDEPUBAssetRepository {
/// 加载资源文件内容,支持模板替换
static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String
}
```
---
## 17. 笔记子系统
**文件:** `Notes/RDEPUBNote*.swift`
| 文件 | 职责 |
|------|------|
| `RDEPUBNoteModels.swift` | 脚注/尾注数据模型 |
| `RDEPUBNoteDetector.swift` | 从 HTML 中检测脚注标记 |
| `RDEPUBNoteResolver.swift` | 解析脚注内容 |
---
## 18. 设计模式总结
| 模式 | 应用 |
|------|------|
| **Builder 模式** | `RDEPUBParser``RDEPUBPublication` 构建链 |
| **Strategy 模式** | `RDEPUBSearchEngine` 协议HTML 和 Text 两种实现 |
| **URL Scheme 拦截** | `RDEPUBResourceURLSchemeHandler` 自定义协议 |
| **消息桥接** | `RDEPUBJavaScriptBridge` Native ↔ JS 双向通信 |
| **快照管理** | `RDEPUBReadingSession` staged/active 双快照 |
| **状态机** | `RDEPUBNavigatorState` 管理导航状态 |

View File

@ -0,0 +1,521 @@
# EPUBTextRendering 模块代码级参考文档
> 最后更新2026-06-18
---
## 1. 模块概述
`EPUBTextRendering` 是 ReadViewSDK 的**文本渲染层**,负责将 EPUB HTML 内容转换为原生 `NSAttributedString`,执行排版分页,并构建全书文本模型。它位于 EPUBCore 之上、EPUBUI 之下,是 `textReflowable` 阅读配置文件的核心引擎。
**文件清单(~30 个 Swift 文件):**
| 子系统 | 核心文件 | 职责 |
|--------|----------|------|
| **排版管线** | `Typesetter/RDEPUBTypesettingPipeline.swift` | HTML → 标记化 HTML 的多阶段管线 |
| **文本渲染** | `RDEPUBTextRenderer.swift`, `RDEPUBDTCoreTextRenderer.swift` | HTML → NSAttributedString 转换 |
| **分页引擎** | `Pagination/*.swift` | CoreText 排版、页帧计算、分页策略 |
| **构建管线** | `BuildPipeline/*.swift` | 全书构建(渲染 → 分页 → 缓存) |
| **搜索** | `RDEPUBTextSearchEngine.swift` | 基于原生文本的全文搜索 |
| **索引** | `RDEPUBTextIndexTable.swift` | 全书字符偏移索引、CFI 映射 |
| **位置转换** | `RDEPUBTextPositionConverter.swift` | 字符偏移 ↔ 页码转换 |
| **章节数据** | `RDEPUBChapterData.swift` | 章节级数据访问封装 |
---
## 2. 排版管线Typesetter
### 2.1 RDEPUBTextTypesettingPipeline
**文件:** `Typesetter/RDEPUBTypesettingPipeline.swift`
多阶段 HTML 处理管线,将原始 HTML 转换为可渲染的标记化 HTML。
```swift
struct RDEPUBTextTypesetterPipeline {
func makeRequest(from input: RDEPUBTypesettingInput) -> RDEPUBTypesettingOutput
}
```
**管线阶段:**
```
原始 HTML
RDEPUBHTMLNormalizer → 清理 HTML移除脚本、修复标签
RDEPUBSemanticMarkerInjector → 注入语义标记(段落、列表、表格等)
RDEPUBCFIMarkerInjector → 注入 CFI 定位标记
RDEPUBStyleSheetComposer → 合并样式表(内联 CSS + 兼容性处理)
RDEPUBFontNormalizer → 注册嵌入字体、字体回退
RDEPUBFragmentMarkerInjector → 注入片段标记(用于锚点定位)
标记化 HTML + 诊断报告
```
### 2.2 管线输入/输出
```swift
struct RDEPUBTypesettingInput {
var href: String // 章节 href
var spineIndex: Int?
var title: String // 章节标题
var rawHTML: String // 原始 HTML
var baseURL: URL? // 资源基础 URL
var style: RDEPUBTextRenderStyle // 渲染样式
var resourceResolver: RDEPUBResourceResolver?
var contentLanguageCode: String?
var pageSize: CGSize?
var layoutConfig: RDEPUBTextLayoutConfig?
}
struct RDEPUBTypesettingOutput {
var request: RDEPUBTextChapterRenderRequest // 渲染请求
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] // 资源引用诊断
var styleCompatibilityReport: RDEPUBCSSCompatibilityReport // CSS 兼容性报告
}
```
### 2.3 各阶段处理器
| 处理器 | 文件 | 职责 |
|--------|------|------|
| `RDEPUBHTMLNormalizer` | `RDEPUBHTMLNormalizer.swift` | 清理 HTML移除 `<script>`、修复未闭合标签、规范化编码 |
| `RDEPUBSemanticMarkerInjector` | `RDEPUBSemanticMarkerInjector.swift` | 为块级元素注入 `data-rd-block-kind` 属性 |
| `RDEPUBCFIMarkerInjector` | `RDEPUBCFIMarkerInjector.swift` | 为文本节点注入 `data-rd-cfi` 属性 |
| `RDEPUBStyleSheetComposer` | `RDEPUBStyleSheetComposer.swift` | 合并 EPUB 原始 CSS + SDK 样式 + 兼容性补丁 |
| `RDEPUBFontNormalizer` | `RDEPUBFontNormalizer.swift` | 检测并注册 `@font-face` 嵌入字体 |
| `RDEPUBFragmentMarkerInjector` | `RDEPUBFragmentMarkerInjector.swift` | 为 `id` 属性注入片段偏移标记 |
| `RDEPUBAttachmentNormalizer` | `RDEPUBAttachmentNormalizer.swift` | 规范化 `<img>`、`<svg>` 等附件元素 |
| `RDEPUBRenderDiagnosticsCollector` | `RDEPUBRenderDiagnosticsCollector.swift` | 收集渲染诊断信息(缺失资源、不支持的特性) |
### 2.4 CSS 兼容性层
**文件:** `Typesetter/Compatibility/`
| 文件 | 职责 |
|------|------|
| `RDEPUBCSSCompatibilityLayer.swift` | CSS 兼容性补丁webkit 前缀、不支持属性替换) |
| `RDEPUBFontFallbackResolver.swift` | 字体回退链解析 |
| `RDEPUBStyleCompatibilityModels.swift` | 兼容性报告模型 |
---
## 3. 文本渲染器
### 3.1 RDEPUBTextRenderer协议
**文件:** `RDEPUBTextRenderer.swift`
```swift
public protocol RDEPUBTextRenderer {
/// 将 HTML 渲染为 NSAttributedString
func render(html: String, baseURL: URL?, style: RDEPUBTextRenderStyle) -> NSAttributedString
}
```
### 3.2 RDEPUBDTCoreTextRenderer
**文件:** `RDEPUBDTCoreTextRenderer.swift`
基于 DTCoreText 的渲染器实现,将 HTML 转换为 `NSAttributedString`
```swift
public final class RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
public init()
public func render(html: String, baseURL: URL?, style: RDEPUBTextRenderStyle) -> NSAttributedString
}
```
### 3.3 渲染样式
```swift
public struct RDEPUBTextRenderStyle {
public var font: UIFont // 字体
public var lineSpacing: CGFloat // 行间距
public var textColor: UIColor? // 文字颜色
public var backgroundColor: UIColor? // 背景颜色
}
```
### 3.4 NSAttributedString 扩展键
```swift
extension NSAttributedString.Key {
static let rdPageBlockRange // 块级元素范围
static let rdPageBlockIndex // 块索引
static let rdPageFragmentID // 片段 ID
static let rdPageAttachmentKind // 附件类型
static let rdPageBlockKind // 块类型paragraph/list/table/code/blockquote/attachment/generic
static let rdPageSemanticHints // 语义提示avoidPageBreakInside/keepWithNext/pageBreakBefore/After
static let rdPageAttachmentPlacement // 附件放置方式inline/baseline/centered
}
```
---
## 4. 分页引擎
### 4.1 RDEPUBTextLayouter
**文件:** `Pagination/RDEPUBTextLayouter.swift`
分页的核心入口,组合 `RDEPUBCoreTextPageFrameFactory``RDEPUBChapterPageCounter`
```swift
struct RDEPUBTextLayouter {
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default)
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame]
}
```
### 4.2 RDEPUBTextLayoutConfig
```swift
public struct RDEPUBTextLayoutConfig: Equatable {
public var frameWidth: CGFloat // 帧宽度
public var frameHeight: CGFloat // 帧高度
public var edgeInsets: UIEdgeInsets // 内边距
public var numberOfColumns: Int // 列数
public var columnGap: CGFloat // 列间距
public var avoidOrphans: Bool // 避免孤行
public var avoidWidows: Bool // 避免寡行
public var avoidPageBreakInsideEnabled: Bool // 避免块内分页
public var hyphenation: Bool // 连字符断词
public static let `default`: RDEPUBTextLayoutConfig
}
```
### 4.3 RDEPUBCoreTextPageFrameFactory
**文件:** `Pagination/RDEPUBCoreTextPageFrameFactory.swift`
使用 CoreText 排版引擎,将 `NSAttributedString` 排列到指定尺寸的帧中。
```swift
struct RDEPUBCoreTextPageFrameFactory {
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig)
func makeFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame]
}
```
### 4.4 RDEPUBChapterPageCounter
**文件:** `Pagination/RDEPUBChapterPageCounter.swift`
基于 `RDEPUBCoreTextPageFrameFactory` 计算页范围。
```swift
struct RDEPUBChapterPageCounter {
init(factory: RDEPUBCoreTextPageFrameFactory)
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame]
}
```
### 4.5 RDEPUBTextLayoutFrame
**文件:** `Pagination/RDEPUBTextLayoutFrame.swift`
表示一个排版帧(一页的内容范围)。
```swift
public struct RDEPUBTextLayoutFrame {
public let range: NSRange // 字符范围
public let frameIndex: Int // 帧索引
public let diagnostics: [String] // 诊断信息
}
```
### 4.6 RDEPUBPageBreakPolicy
**文件:** `Pagination/RDEPUBPageBreakPolicy.swift`
分页策略,决定在何处断页。
```swift
struct RDEPUBPageBreakPolicy {
/// 检查指定位置是否允许分页
func canBreak(at offset: Int, in attributedString: NSAttributedString) -> Bool
}
```
---
## 5. 构建管线
### 5.1 RDEPUBTextBookBuilder
**文件:** `BuildPipeline/RDEPUBTextBookBuilder.swift`
全书构建的核心类,串联渲染、分页、缓存和诊断。
```swift
public final class RDEPUBTextBookBuilder {
public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache?, layoutConfig: RDEPUBTextLayoutConfig)
/// 构建全书文本模型
public func build(
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) throws -> RDEPUBTextBook
// 诊断信息
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic]
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample]
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int)
}
```
**构建流程:**
```
RDEPUBParser + RDEPUBPublication
▼ 遍历 spinelinear 章节)
├── 检查缓存RDEPUBPaginationCacheCoordinator
│ ├── 命中 → 直接使用缓存的分页结果
│ └── 未命中 → 执行渲染 + 分页
├── RDEPUBChapterRenderPipeline渲染单章
│ └── RDEPUBTextTypesetterPipeline → RDEPUBTextRenderer
├── RDEPUBChapterPaginationPipeline分页单章
│ └── RDEPUBTextLayouter → [RDEPUBTextLayoutFrame]
├── RDEPUBChapterTailNormalizer章节尾部规范化
└── 组装 RDEPUBTextChapter + RDEPUBTextPage
RDEPUBTextBook全书模型
```
### 5.2 辅助组件
| 文件 | 职责 |
|------|------|
| `RDEPUBChapterRenderPipeline.swift` | 单章渲染管线 |
| `RDEPUBChapterPaginationPipeline.swift` | 单章分页管线 |
| `RDEPUBChapterTailNormalizer.swift` | 章节尾部空白处理 |
| `RDEPUBPaginationCacheCoordinator.swift` | 分页缓存协调 |
| `RDEPUBTextBookCache.swift` | 分页缓存存储 |
| `RDEPUBTextPerformanceSampler.swift` | 性能采样 |
| `RDEPUBBuildDiagnosticsReporter.swift` | 构建诊断报告 |
| `RDEPUBTextBuildPipelineInterfaces.swift` | 管线接口定义 |
---
## 6. 全书模型
### 6.1 RDEPUBTextBook
**文件:** `BuildPipeline/RDEPUBTextBookModels.swift`
全书文本模型,包含所有章节和页面。
```swift
public struct RDEPUBTextBook {
public var chapters: [RDEPUBTextChapter] // 所有章节
public var pages: [RDEPUBTextPage] // 所有页面(扁平化)
public let indexTable: RDEPUBTextIndexTable // 索引表
public var positionConverter: RDEPUBTextPositionConverter
// 章节查询
public func chapterData(for href: String) -> RDEPUBChapterData?
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData?
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData?
public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData?
// 页码查询
public func page(at pageNumber: Int) -> RDEPUBTextPage?
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int?
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation?
}
```
### 6.2 RDEPUBTextChapter
```swift
public struct RDEPUBTextChapter: Equatable {
public var chapterIndex: Int // 章节索引
public var spineIndex: Int // spine 索引
public var href: String // 章节 href
public var title: String // 章节标题
public var attributedContent: NSAttributedString // 章节完整富文本
public var fragmentOffsets: [String: Int] // 片段 ID → 字符偏移
public var cfiMap: RDEPUBCFIMap? // CFI 映射
public var pageBreakReasons: [RDEPUBTextPageBreakReason] // 分页原因
public var pages: [RDEPUBTextPage] // 章节内页面
}
```
### 6.3 RDEPUBTextPage
```swift
public struct RDEPUBTextPage: Equatable {
public var absolutePageIndex: Int // 全书绝对页码
public var chapterIndex: Int // 章节索引
public var spineIndex: Int // spine 索引
public var href: String // 章节 href
public var chapterTitle: String // 章节标题
public var pageIndexInChapter: Int // 章节内页码
public var totalPagesInChapter: Int // 章节总页数
public var chapterContent: NSAttributedString // 章节完整内容
public var content: NSAttributedString // 页面内容(子范围)
public var contentRange: NSRange // 在章节内容中的范围
public var pageStartOffset: Int // 起始偏移
public var pageEndOffset: Int // 结束偏移
public var metadata: RDEPUBTextPageMetadata // 页元数据
}
```
---
## 7. 索引与位置转换
### 7.1 RDEPUBTextIndexTable
**文件:** `RDEPUBTextIndexTable.swift`
全书字符偏移索引,支持快速查找。
```swift
public struct RDEPUBTextIndexTable {
public let chapterStartOffsets: [Int] // 各章节起始偏移
public let chapterLengths: [Int] // 各章节长度
public let hrefToChapterIndex: [String: Int] // href → 章节索引
public let hrefToFileIndex: [String: Int] // href → 文件索引
public let fragmentOffsetsByHref: [String: [String: Int]] // 片段偏移
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]] // 行列映射
public let fileCFIMap: [Int: RDEPUBCFIMap] // CFI 映射
public var totalCharacterCount: Int // 总字符数
public init(chapters: [RDEPUBTextChapter])
}
```
### 7.2 RDEPUBTextPositionConverter
**文件:** `RDEPUBTextPositionConverter.swift`
字符偏移 ↔ 页码双向转换。
```swift
public struct RDEPUBTextPositionConverter {
init(book: RDEPUBTextBook)
/// 字符偏移 → 页码
public func pageNumber(for offset: Int) -> Int?
/// 页码 → 字符偏移范围
public func offsetRange(forPageNumber pageNumber: Int) -> NSRange?
}
```
### 7.3 RDEPUBChapterData
**文件:** `RDEPUBChapterData.swift`
章节级数据访问封装。
```swift
public struct RDEPUBChapterData {
public let chapter: RDEPUBTextChapter
public let indexTable: RDEPUBTextIndexTable
/// 位置 → 页码
public func pageNumber(for location: RDEPUBLocation) -> Int?
/// 页面 → 位置
public func location(for page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation?
/// 字符范围 → 范围锚点
public func rangeAnchor(for range: NSRange) -> RDEPUBRangeAnchor
}
```
---
## 8. 搜索引擎
**文件:** `RDEPUBTextSearchEngine.swift`
基于原生 `NSAttributedString` 的搜索引擎,比 HTML 搜索更高效。
```swift
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
init(textBook: RDEPUBTextBook, publication: RDEPUBPublication)
func search(keyword: String) -> [RDEPUBSearchMatch]
}
```
搜索结果包含href、progression、previewText、rangeAnchor、cfi 等定位信息。
---
## 9. 纯文本构建器
**文件:** `RDEpubPlainTextBookBuilder.swift`
从纯文本(.txt文件构建 `RDEPUBTextBook`,用于支持 TXT 格式阅读。
---
## 10. 设计模式总结
| 模式 | 应用 |
|------|------|
| **管线模式** | `RDEPUBTextTypesettingPipeline` 多阶段 HTML 处理 |
| **策略模式** | `RDEPUBTextRenderer` 协议,`RDEPUBDTCoreTextRenderer` 实现 |
| **Builder 模式** | `RDEPUBTextBookBuilder` 全书构建 |
| **缓存协调** | `RDEPUBPaginationCacheCoordinator` 分页结果缓存 |
| **索引表** | `RDEPUBTextIndexTable` 全书字符偏移快速查找 |
| **关注点分离** | 渲染/分页/缓存/诊断各司其职 |
---
## 11. 数据流图
```
EPUB HTML 文件
RDEPUBTypesettingPipeline
│ HTMLNormalizer → SemanticMarker → CFI → StyleSheet → Font → Fragment
标记化 HTML + CSS
RDEPUBDTCoreTextRenderer
│ DTCoreText HTML → NSAttributedString
NSAttributedString带语义属性
RDEPUBTextLayouter
│ CoreText 排版 → 帧计算 → 分页策略
[RDEPUBTextLayoutFrame](页面范围列表)
RDEPUBTextChapter + RDEPUBTextPage
RDEPUBTextBook全书模型 + 索引表)
```

View File

@ -0,0 +1,731 @@
# EPUBUI 模块代码级参考文档
> 最后更新2026-06-18
---
## 1. 模块概述
`EPUBUI` 是 ReadViewSDK 的**用户界面层**,位于架构最顶层。它包含阅读器控制器、协调器模式、章节运行时系统、设置面板、文本页面渲染和标注管理等子系统。
**文件清单(~60 个 Swift 文件):**
| 子系统 | 目录 | 核心文件 | 职责 |
|--------|------|----------|------|
| **阅读器控制器** | `EPUBUI/` | `RDEPUBReaderController*.swift` | 主控制器及其扩展 |
| **协调器** | `ReaderController/` | `RDEPUBReader*Coordinator.swift` | 各功能域协调器 |
| **运行时** | `ReaderController/` | `RDEPUBReaderRuntime.swift`, `RDEPUBReaderContext.swift` | 运行时状态与依赖 |
| **章节运行时** | `ReaderController/ChapterRuntime/` | `RDEPUBChapter*.swift` | 按需加载、缓存、页图 |
| **设置** | `Settings/` | `RDEPUBReaderSettings*.swift` | 配置、主题、设置面板 |
| **文本页面** | `TextPage/` | `RDEPUBTextContentView*.swift` | 原生文本渲染页面 |
| **UI 组件** | `EPUBUI/` | `RDEPUBReader*ToolView.swift` | 工具栏、搜索栏、目录 |
| **笔记弹层** | `Notes/` | `RDEPUBNotePopup*.swift` | 脚注弹层 |
| **WebView** | `EPUBUI/` | `RDEPUBWebContentView.swift` | WebView 内容页面 |
---
## 2. 阅读器控制器
### 2.1 RDEPUBReaderController
**文件:** `RDEPUBReaderController.swift` + 扩展文件
主控制器,`UIViewController` 子类,是整个阅读器的入口。
```swift
public final class RDEPUBReaderController: UIViewController {
public weak var delegate: RDEPUBReaderDelegate?
public var configuration: RDEPUBReaderConfiguration // 配置(变化时自动重新分页/刷新)
public var currentLocation: RDEPUBLocation? // 当前阅读位置
public var currentPageNumber: Int? // 当前页码1-based
public var currentSelection: RDEPUBSelection? // 当前选中文本
public var highlights: [RDEPUBHighlight] // 高亮列表
public var bookmarks: [RDEPUBBookmark] // 书签列表
public var annotations: [RDEPUBAnnotation] // 标注列表(高亮+书签,按时间排序)
public var tableOfContents: [EPUBTableOfContentsItem] // 目录树
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] // 扁平化目录
let epubURL: URL // EPUB 文件路径
let persistence: RDEPUBReaderPersistence? // 持久化代理
let dependencies: RDEPUBReaderDependencies // 依赖注入
let readerView = RDEpubReaderView() // 翻页容器
}
```
**扩展文件职责:**
| 文件 | 职责 |
|------|------|
| `RDEPUBReaderController+PublicAPI.swift` | 公开 API跳转、搜索、标注、书签 |
| `RDEPUBReaderController+DataSource.swift` | `RDEpubReaderPageProvider` 实现 |
| `RDEPUBReaderController+ContentDelegates.swift` | WebView/TextContentView 代理路由 |
| `RDEPUBReaderController+LocationResolution.swift` | 页码/位置解析、阅读状态同步 |
| `RDEPUBReaderController+ExternalLinks.swift` | 外部链接处理(白名单、确认弹窗) |
| `RDEPUBReaderController+AttachmentTooltip.swift` | 附件 alt 文本 tooltip 展示 |
| `RDEPUBReaderController+RenderSupport.swift` | 渲染辅助WebView/TextContent 创建) |
| `RDEPUBReaderController+RuntimeBridge.swift` | Runtime 桥接 |
| `RDEPUBReaderController+TableOfContents.swift` | 目录处理 |
### 2.2 RDEPUBReaderDelegate
**文件:** `RDEPUBReaderDelegate.swift`
```swift
public protocol RDEPUBReaderDelegate: AnyObject {
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
func epubReaderDidReachEnd(_ reader: UIViewController)
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
func epubReader(_ reader: UIViewController, shouldOpenExternalURL url: URL) -> Bool
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
}
```
所有方法均有默认空实现。
### 2.3 RDEPUBReaderPersistence
**文件:** `RDEPUBReaderPersistence.swift`
持久化协议,由宿主 App 实现。
```swift
public protocol RDEPUBReaderPersistence: AnyObject {
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
}
```
---
## 3. 运行时与上下文
### 3.1 RDEPUBReaderContext
**文件:** `ReaderController/RDEPUBReaderContext.swift`
阅读器的共享上下文,持有所有运行时状态。协调器通过 `unowned` 引用访问。
```swift
final class RDEPUBReaderContext {
weak var controller: RDEPUBReaderController?
weak var readerView: RDEpubReaderView?
var dependencies: RDEPUBReaderDependencies
var runtime: RDEPUBReaderRuntime?
// 解析状态
var parser: RDEPUBParser?
var publication: RDEPUBPublication?
var readingSession: RDEPUBReadingSession?
var textBook: RDEPUBTextBook?
var bookPageMap: RDEPUBBookPageMap?
// 标注状态
var activeBookmarks: [RDEPUBBookmark]
var activeHighlights: [RDEPUBHighlight]
var currentBookIdentifier: String?
var selectionState: RDEPUBSelectionState
var currentSelection: RDEPUBSelection?
// 搜索状态
var searchState: RDEPUBSearchState?
}
```
### 3.2 RDEPUBReaderDependencies
**文件:** `ReaderController/RDEPUBReaderDependencies.swift`
依赖注入容器,支持测试替身。
```swift
public struct RDEPUBReaderDependencies {
public var environment: any RDEPUBReaderDisplayEnvironment
public var makeParser: () -> RDEPUBParser
public var makePaginator: () -> RDEPUBPaginator
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDEpubPlainTextBookBuilder
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
public static var live: RDEPUBReaderDependencies // 默认实现
}
```
### 3.3 RDEPUBReaderRuntime
**文件:** `ReaderController/RDEPUBReaderRuntime.swift`
运行时管理器,持有所有协调器和子系统。
```swift
final class RDEPUBReaderRuntime {
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
lazy var summaryDiskCache: RDEPUBChapterSummaryDiskCache
lazy var chapterLoader: RDEPUBChapterLoader
lazy var pageResolver: RDEPUBPageResolver
lazy var loadCoordinator: RDEPUBReaderLoadCoordinator
lazy var paginationCoordinator: RDEPUBReaderPaginationCoordinator
lazy var locationCoordinator: RDEPUBReaderLocationCoordinator
lazy var searchCoordinator: RDEPUBReaderSearchCoordinator
lazy var chromeCoordinator: RDEPUBReaderChromeCoordinator
lazy var annotationCoordinator: RDEPUBReaderAnnotationCoordinator
lazy var viewportMonitor: RDEPUBReaderViewportMonitor
lazy var jumpSessionManager: RDEPUBJumpSessionManager
lazy var backgroundPriorityManager: RDEPUBBackgroundPriorityManager
lazy var backgroundCoverageStore: RDEPUBBackgroundCoverageStore
lazy var reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
var isSettingsPanelOpen: Bool
var needsFullRepaginationAfterSettingsClose: Bool
}
```
---
## 4. 协调器子系统
采用 **Coordinator 模式**,每个功能域由独立的协调器管理。
### 4.1 RDEPUBReaderLoadCoordinator
**文件:** `ReaderController/RDEPUBReaderLoadCoordinator.swift`
负责初始加载流程:解析 EPUB → 创建 Publication → 恢复阅读位置。
```swift
final class RDEPUBReaderLoadCoordinator {
func startInitialLoadIfNeeded() // 开始初始加载
func loadPublication() // 解析 EPUB 文件
func applyParsedPublication(...) // 应用解析结果
}
```
### 4.2 RDEPUBReaderPaginationCoordinator
**文件:** `ReaderController/RDEPUBReaderPaginationCoordinator.swift`
负责分页调度WebView 分页 → 文本构建 → 页图生成。
```swift
final class RDEPUBReaderPaginationCoordinator {
func startPagination(...) // 开始分页
func applyPageCounts(...) // 应用页数结果
func repaginatePreservingCurrentLocation() // 重新分页(保持位置)
}
```
### 4.3 RDEPUBReaderLocationCoordinator
**文件:** `ReaderController/RDEPUBReaderLocationCoordinator.swift`
负责位置管理:恢复位置、记录位置变化、目录跳转。
```swift
final class RDEPUBReaderLocationCoordinator {
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool, ...) -> Bool
func currentVisibleLocation() -> RDEPUBLocation?
func recordPageChangeIfNeeded()
}
```
### 4.4 RDEPUBReaderSearchCoordinator
**文件:** `ReaderController/RDEPUBReaderSearchCoordinator.swift`
负责搜索管理:执行搜索、导航到匹配项、清除搜索。
```swift
final class RDEPUBReaderSearchCoordinator {
func search(keyword: String)
func searchNext() -> Bool
func searchPrevious() -> Bool
func selectSearchMatch(at index: Int) -> Bool
func clearSearch()
}
```
### 4.5 RDEPUBReaderChromeCoordinator
**文件:** `ReaderController/RDEPUBReaderChromeCoordinator.swift`
负责 UI Chrome工具栏、搜索栏的创建和状态更新。
```swift
final class RDEPUBReaderChromeCoordinator {
func makeTopToolView() -> RDEPUBReaderTopToolView
func makeBottomToolView() -> RDEPUBReaderBottomToolView
func updateReaderChrome()
func toggleSearchBar()
func presentTableOfContents()
func presentSettings()
}
```
### 4.6 RDEPUBReaderAnnotationCoordinator
**文件:** `ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
负责标注管理:高亮、书签的增删改查。
```swift
final class RDEPUBReaderAnnotationCoordinator {
func addHighlight(from selection: RDEPUBSelection?, color: String, note: String?) -> RDEPUBHighlight?
func removeHighlight(withID id: String)
func addBookmark(for location: RDEPUBLocation, ...) -> RDEPUBBookmark?
func removeBookmark(withID id: String)
func toggleBookmark() -> Bool
func updateCurrentSelection(_ selection: RDEPUBSelection?)
}
```
---
## 5. 章节运行时子系统
**目录:** `ReaderController/ChapterRuntime/`
按需加载章节、管理章节缓存、维护全书页图。
### 5.1 RDEPUBChapterRuntimeStore
**文件:** `ChapterRuntime/RDEPUBChapterRuntimeStore.swift`
章节数据的核心存储,管理内存缓存和窗口。
```swift
final class RDEPUBChapterRuntimeStore {
let chapterLoadQueue: DispatchQueue // 后台加载队列
private(set) var currentSpineIndex: Int?
private(set) var windowSpineIndices: [Int] // 当前窗口内的 spine 索引
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter?
func insertChapter(_ chapter: RDEPUBRuntimeChapter)
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int)
func evictableSpineIndices() -> [Int]
func evict(spineIndex: Int)
}
```
### 5.2 RDEPUBChapterLoader
**文件:** `ChapterRuntime/RDEPUBChapterLoader.swift`
章节按需加载器,支持优先级和磁盘摘要缓存。
```swift
final class RDEPUBChapterLoader {
enum LoadPriority {
case navigation // 导航(最高优先级)
case preview // 预览
case prefetch // 预取(最低优先级)
}
func loadChapter(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
)
}
```
**加载流程:**
1. 检查内存缓存(`RDEPUBChapterDataCache`
2. 检查页数缓存(`RDEPUBPageCountCache`
3. 检查磁盘摘要缓存(`RDEPUBChapterSummaryDiskCache`
4. 构建章节(排版 + 分页)
5. 写入缓存
### 5.3 RDEPUBChapterWindowCoordinator
**文件:** `ChapterRuntime/RDEPUBChapterWindowCoordinator.swift`
管理章节窗口(当前章 + 前后各 N 章),协调加载和驱逐。
```swift
final class RDEPUBChapterWindowCoordinator {
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int?)
func navigateToChapter(at spineIndex: Int)
}
```
### 5.4 RDEPUBBookPageMap
**文件:** `ChapterRuntime/RDEPUBBookPageMap.swift`
全书页图,映射绝对页码 ↔ spine 索引 + 本地页码。
```swift
struct RDEPUBBookPageMap {
let entries: [RDEPUBBookPageMapEntry]
let totalPages: Int
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int?
func spineIndex(forAbsolutePage absolutePage: Int) -> Int?
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int?
func chapterIndex(forSpineIndex spineIndex: Int) -> Int?
}
```
### 5.5 RDEPUBPageResolver
**文件:** `ChapterRuntime/RDEPUBPageResolver.swift`
将绝对页码解析为具体的页面数据。
```swift
final class RDEPUBPageResolver {
func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage?
}
struct RDEPUBResolvedPage {
let page: RDEPUBTextPage
let chapter: RDEPUBRuntimeChapter
let chapterIndex: Int
}
```
### 5.6 其他 ChapterRuntime 组件
| 文件 | 职责 |
|------|------|
| `RDEPUBRuntimeChapter.swift` | 运行时章节模型(包含分页后的页面列表) |
| `RDEPUBRuntimePageCount.swift` | 页数缓存模型 |
| `RDEPUBChapterDataCache.swift` | 章节数据内存缓存NSCache |
| `RDEPUBPageCountCache.swift` | 页数缓存 |
| `RDEPUBChapterCacheKey.swift` | 缓存键pageSize + fontSize + lineHeight |
| `RDEPUBChapterSummaryDiskCache.swift` | 章节摘要磁盘缓存 |
| `RDEPUBChapterWindowSnapshot.swift` | 窗口快照(当前可见章节集合) |
| `RDEPUBChapterLocation.swift` | 章节位置模型 |
| `RDEPUBChapterOffsetMap.swift` | 章节偏移映射 |
| `RDEPUBBackgroundTrace.swift` | 后台任务追踪日志 |
| `RDEPUBMetadataParseWorker.swift` | 后台元数据解析 worker |
| `RDEPUBMetadataParseCancellationController.swift` | 解析取消控制器 |
---
## 6. 设置子系统
**目录:** `Settings/`
### 6.1 RDEPUBReaderConfiguration
**文件:** `Settings/RDEPUBReaderConfiguration.swift`
阅读器配置模型。
```swift
public struct RDEPUBReaderConfiguration: Equatable {
public var fontSize: CGFloat
public var lineHeightMultiple: CGFloat
public var fontChoice: RDEPUBReaderFontChoice
public var numberOfColumns: Int
public var columnGap: CGFloat
// ... 更多配置项
}
public enum RDEPUBReaderFontChoice: String, Codable, CaseIterable {
case system // 系统字体
case serif // 宋体
case rounded // 圆体
case monospaced // 等宽
}
public enum RDEPUBTextRenderingEngine: Equatable {
case dtCoreText
}
```
### 6.2 RDEPUBReaderSettings
**文件:** `Settings/RDEPUBReaderSettings.swift`
设置状态管理。
### 6.3 RDEPUBReaderSettingsViewController
**文件:** `Settings/RDEPUBReaderSettingsViewController.swift`
设置面板 VC字体大小、行高、字体选择、主题等
### 6.4 RDEPUBReaderTheme
**文件:** `Settings/RDEPUBReaderTheme.swift`
主题定义(日间/夜间/护眼等)。
---
## 7. 文本页面子系统
**目录:** `TextPage/`
原生文本渲染模式(`textReflowable`)的页面视图。
### 7.1 RDEPUBTextContentView
**文件:** `TextPage/RDEPUBTextContentView.swift`
文本内容视图,使用 CoreText 渲染 `NSAttributedString`
```swift
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
weak var delegate: RDEPUBTextContentViewDelegate?
func configure(page: RDEPUBTextPage, ...)
func applyHighlights(_ highlights: [RDEPUBHighlight])
func applySearchState(_ state: RDEPUBSearchState?)
}
```
### 7.2 RDEPUBTextPageRenderView
**文件:** `TextPage/RDEPUBTextPageRenderView.swift`
CoreText 渲染视图,绘制 `NSAttributedString` 到屏幕上。
### 7.3 RDEPUBTextAnnotationOverlay
**文件:** `TextPage/RDEPUBTextAnnotationOverlay.swift`
标注叠加层,绘制高亮和下划线。
### 7.4 RDEPUBSelectionOverlayView
**文件:** `TextPage/RDEPUBSelectionOverlayView.swift`
文本选择叠加层(选择手柄)。
### 7.5 RDEPUBTextSelectionController
**文件:** `TextPage/RDEPUBTextSelectionController.swift`
文本选择手势控制器。
### 7.6 RDEPUBPageInteractionController
**文件:** `TextPage/RDEPUBPageInteractionController.swift`
页面交互控制器(长按选择、点击标注等)。
### 7.7 RDEPUBPageLayoutSnapshot
**文件:** `TextPage/RDEPUBPageLayoutSnapshot.swift`
页面布局快照(用于选择定位)。
### 7.8 RDEPUBTextPageDecorationView
**文件:** `TextPage/RDEPUBTextPageDecorationView.swift`
页面装饰视图(页码、页眉等)。
---
## 8. UI 组件
### 8.1 工具栏
| 文件 | 职责 |
|------|------|
| `RDEPUBReaderTopToolView.swift` | 顶部工具栏(返回、搜索、书签) |
| `RDEPUBReaderBottomToolView.swift` | 底部工具栏(目录、书签、高亮、设置) |
| `RDEPUBReaderToolView.swift` | 工具栏基类 |
### 8.2 搜索栏
**文件:** `RDEPUBReaderSearchBarView.swift`
搜索输入栏(输入框 + 上一个/下一个 + 关闭)。
### 8.3 目录
**文件:** `RDEPUBReaderChapterListController.swift`
目录列表 VC支持多级目录展开。
**文件:** `RDEPUBReaderTableOfContentsItem.swift`
目录项模型。
### 8.4 高亮管理
**文件:** `RDEPUBReaderHighlightsViewController.swift`
高亮列表 VC。
### 8.5 笔记弹层
**目录:** `Notes/`
| 文件 | 职责 |
|------|------|
| `RDEPUBNotePopupCoordinator.swift` | 脚注弹层协调器 |
| `RDEPUBNotePopupViewController.swift` | 脚注弹层 VC |
### 8.6 WebView 内容视图
**文件:** `RDEPUBWebContentView.swift`
WebView 模式的内容页面视图。
### 8.7 装饰叠加层
**文件:** `RDEPUBWebDecorationOverlayView.swift`
WebView 模式的装饰叠加层(高亮、搜索高亮)。
---
## 9. 状态模型
### 9.1 RDEPUBSelectionState
```swift
enum RDEPUBSelectionState: Equatable {
case idle // 无选择
case selecting(anchor: Int) // 选择中
case selected(RDEPUBSelection) // 已选择
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction) // 执行操作中
}
```
### 9.2 RDEPUBReaderUIState
```swift
struct RDEPUBReaderUIState {
let canToggleBookmark: Bool
let hasBookmarkAtCurrentLocation: Bool
let canShowBookmarks: Bool
let canAddHighlight: Bool
let canShowHighlights: Bool
let showsTableOfContents: Bool
let allowsHighlights: Bool
let showsSettingsPanel: Bool
}
```
### 9.3 RDEPUBViewportTypes
**文件:** `RDEPUBViewportTypes.swift`
视口相关模型。
---
## 10. 其他组件
### 10.1 RDEPUBReaderViewportMonitor
**文件:** `ReaderController/RDEPUBReaderViewportMonitor.swift`
视口变化监听器(旋转、尺寸变化)。
### 10.2 RDEPUBJumpSession
**文件:** `ReaderController/RDEPUBJumpSession.swift`
跳转会话管理(远距跳转时的加载状态)。
### 10.3 RDEPUBBackgroundPriorityPolicy
**文件:** `ReaderController/RDEPUBBackgroundPriorityPolicy.swift`
后台加载优先级策略。
### 10.4 RDEPUBBackgroundCoverageStore
**文件:** `ReaderController/RDEPUBBackgroundCoverageStore.swift`
后台加载覆盖范围追踪。
### 10.5 RDEPUBPageMapReconciliationCoordinator
**文件:** `ReaderController/RDEPUBPageMapReconciliationCoordinator.swift`
页图协调器(按需分页与全量分页的结果合并)。
### 10.6 RDEpubURLReaderController
**文件:** `RDEpubURLReaderController.swift`
URL 阅读器控制器(用于打开单个 URL
### 10.7 UIColor+RDEPUBHex
**文件:** `UIColor+RDEPUBHex.swift`
UIColor 十六进制扩展。
---
## 11. 设计模式总结
| 模式 | 应用 |
|------|------|
| **Coordinator 模式** | 7 个独立协调器分管各功能域 |
| **Context 模式** | `RDEPUBReaderContext` 共享上下文,避免循环依赖 |
| **依赖注入** | `RDEPUBReaderDependencies` 工厂方法注入 |
| **按需加载** | `RDEPUBChapterLoader` 章节级按需加载 |
| **窗口管理** | `RDEPUBChapterWindowCoordinator` 滑动窗口驱逐 |
| **快照管理** | `RDEPUBBookPageMap` 全书页图快照 |
| **状态机** | `RDEPUBSelectionState` 选择状态管理 |
| **磁盘缓存** | `RDEPUBChapterSummaryDiskCache` 章节摘要持久化 |
---
## 12. 数据流图
```
用户打开 EPUB
RDEPUBReaderController.init(epubURL:)
▼ viewDidLoad()
RDEPUBReaderLoadCoordinator.startInitialLoadIfNeeded()
├── RDEPUBParser.parse(epubURL:) → 解析 EPUB
├── RDEPUBPublication 创建
├── 恢复阅读位置persistence.loadLocation
RDEPUBReaderRuntime.applyParsedPublication()
├── 根据 readingProfile 选择渲染模式
│ ├── webInteractive → WebView 分页 → RDEPUBPaginator
│ └── textReflowable → 文本构建 → RDEPUBTextBookBuilder
├── RDEPUBChapterWindowCoordinator.openBook()
│ └── RDEPUBChapterLoader.loadChapter() → 按需加载
├── RDEPUBBookPageMap 生成
└── RDEpubReaderView.transitionToPage() → 显示页面
用户翻页
RDEpubReaderView.currentPage 变化
├── RDEPUBReaderLocationCoordinator.recordPageChangeIfNeeded()
│ └── persistence.saveLocation()
├── RDEPUBChapterWindowCoordinator 检查是否需要加载新章节
└── RDEPUBReaderChromeCoordinator.updateReaderChrome()
```

View File

@ -1,265 +0,0 @@
# Reflowable EPUB 使用 WXRead 风格原生渲染:详细设计
> 文档目的:讨论并固化“将 ReadViewSDK 的 reflowable EPUB 渲染/排版/分页改为参考 Doc/WXRead 的读书WXRead原生渲染方式”的可落地设计供后续开发与回归使用。
> 版本v0设计草案
> 日期2026-05-21
## 0. 背景与结论(先说人话)
ReadViewSDK 当前对 EPUB 有三类渲染路径:
- `RDEPUBReadingProfile.webFixedLayout`Fixed Layout EPUB → `WKWebView`(保持不变)
- `RDEPUBReadingProfile.webInteractive`:交互式 EPUBJS/音视频/表单/iframe/外链/bridge`WKWebView`(保持不变)
- `RDEPUBReadingProfile.textReflowable`:普通 reflowable EPUB → **当前走 DTCoreText → NSAttributedString → CoreText 分页 → 原生文本视图**(这是我们要“升级成 WXRead 风格”的主战场)
本次改造的最小可落地方向是:**保留三分流策略不变**,只增强 `.textReflowable` 分支使其在“CSS 分层、样式一致性、资源解析、分页稳定性”等方面更接近 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 所描述的 WXRead 管线,而不是引入新的 WebView 渲染。
## 1. 目标 / 非目标
### 1.1 目标In Scope
- G1reflowable EPUB 的正文渲染/排版/分页改为“WXRead 风格原生渲染管线”:
- XHTML/HTML →CSS 分层 + 解析 + 后处理)→ `NSAttributedString`
- `NSAttributedString`CoreText 分页)→ 单页内容
- 单页内容 → 原生绘制/展示
- G2保持 Fixed Layout 与交互式 EPUB 的 `WKWebView` 路径不回归。
- G3不破坏 `RDURLReaderController` 打开 `.epub` / `.txt` 的主流程。
- G4Demo 可用于验证:至少 2-3 本典型 reflowable EPUB 在段落/标题/图片/链接等常见内容下可稳定阅读。
### 1.2 非目标Out of Scope
- N1Fixed Layout EPUB 切换为原生渲染(明确不做)。
- N2交互式 EPUB 切换为原生渲染(明确不做)。
- N3一次性复刻 WXRead 对 DTCoreText 的所有深度魔改(例如自定义 CSS 属性体系、复杂后处理、分页避断规则等)——本次先按“问题驱动”逐步对齐。
## 2. 关键事实核验(当前代码真实路径)
> 纠偏说明项目初始化时曾把”reflowable EPUB 当前路径”概括为偏 `WKWebView` 的历史性表述。经本次代码核验,当前真实主路径是 `.textReflowable``RDEPUBTextBookBuilder``RDEPUBDTCoreTextRenderer` → CoreText 分页 → `RDEPUBTextContentView`。本设计以代码事实为准,并默认后续实现都按此理解推进。
### 2.1 渲染路径分流(已存在)
- 判定在 `Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift`
- `metadata.layout == .fixed``.webFixedLayout`
- `hasInteractiveContent() == true``.webInteractive`
- 否则 → `.textReflowable`
### 2.2 `.textReflowable` 当前实现(已存在,且是正确切入点)
`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
- `publication.readingProfile == .textReflowable` 时:
- 使用 `RDEPUBTextBookBuilder(renderer: resolvedTextRenderer())`
- 默认 renderer 是 `RDEPUBDTCoreTextRenderer``#if canImport(DTCoreText)`
- 分页使用 `NSAttributedString.ss_pageRanges(size:)``CTFramesetterCreateFrame` + `CTFrameGetVisibleStringRange`
- UI 展示使用 `RDEPUBTextContentView`
结论:我们不需要“新起一个阅读器”,只要把 `.textReflowable` 的 **渲染typesetter层**与部分 **分页策略**升级即可。
## 3. WXRead 参考模型(我们要对齐的最小子集)
来自 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 的管线:
1) `WREpubParser`:解析 EPUB 结构spine、manifest、resourceMap
2) `WREpubTypesetter`XHTML → `NSAttributedString`CSS 级联 + HTML 解析 + 后处理)
3) `WRCoreTextLayouter``NSAttributedString` → 分页布局(`CTTypesetter` + 分页算法)
4) `WRCoreTextLayoutFrame`:单页 layout frame
5) `WRPageView`:绘制到屏幕
本次设计对齐重点(最小集合):
- A**CSS 分层与合成**default/replace/dark/epub/user并注入到渲染输入
- B**资源解析**(图片/CSS 的相对路径 baseURL与稳定性保障
- C在现有分页基础上逐步迭代先可用后对齐“避免断页”等高级策略
### 3.1 第一阶段实施假设(必须遵守)
- H1**第一期只对齐“管线形态”和“CSS 分层策略”**,即把现有 `.textReflowable` renderer 增强为更接近 WXRead 的 typesetter 输入与样式组织方式。
- H2**第一期不实现 WXRead 对 DTCoreText 的深度魔改**,包括但不限于自定义 CSS 属性体系、复杂附件布局规则、完整的 `WRCoreTextLayouter` / `WRCoreTextLayoutFrame` 等价分页器。
- H3当开发过程中遇到图片断页、复杂样式缺失、附件布局异常等问题时默认先作为“第二阶段问题清单”记录只有在它阻塞 `REND-01` / `STAB-02` 的最小验收时,才允许做局部补丁,而不是扩展为全面重写分页引擎。
## 4. 是否能直接使用 Doc/WXRead 中的 JS/CSS
结论:**不建议、也不应该直接把“来自读书 App bundle 的私有 JS/CSS”拷贝进 SDK 作为产品代码**;但可以按以下原则“选择性使用”:
### 4.1 可以使用的情况(需满足其一)
- 文件本身带有明确开源许可证声明,且我们按许可证要求引入(保留 license、署名、NOTICE 等),并建议从官方 upstream 获取:
- 例如 `Doc/WXRead/resources/js/rangy-core.js` 明确标注 MIT
- 例如 `Doc/WXRead/resources/js/Readability.js` 明确标注 Apache-2.0
> 建议:即便文件里有 license 头,也优先从其原始开源仓库拉取对应版本,而不是从逆向提取的副本直接入库,以降低合规风险。
### 4.2 不建议/不能直接使用的情况
- 无明确许可证头、看起来是读书私有逻辑/样式(例如 `weread-highlighter.js`、`MediaPlatform.js`、`replace.css` 等):默认视为私有作品,不应直接拷贝使用。
- 即便是“Safari 默认样式”类文件(例如 `default.css` 的注释提到 Safari也不建议直接照搬我们可以根据需求写一份“SDK 自己的 default.css / replace.css”只实现必要规则。
### 4.3 对本次需求的实际影响
本次 reflowable EPUB 走原生渲染,不依赖 WebView因此 **JS 不是本次必需**
CSS 方面我们需要的是“分层策略”和一小部分通用排版规则,可在 SDK 内重写为“WXRead 风格的默认样式集合”。
## 5. 详细设计(核心)
### 5.1 总体架构:在现有 `.textReflowable` 上增量替换 renderer
新增一个 renderer实现 `RDEPUBTextRenderer`
- `RDEPUBWXReadTextRenderer`(新)
- 输入:`html: String`, `baseURL: URL?`, `style: RDEPUBTextRenderStyle`
- 输出:`RDEPUBRenderedChapterContent``NSAttributedString` + `fragmentOffsets`
- 内部职责:
1) 读取/生成 CSS 各层default/replace/dark/user
2) 与 EPUB 自带 CSS 共同作用(通过 HTML 注入 + baseURL
3) 调用 DTCoreText builder 生成 attributedString
4) 做最小后处理(段落间距/字体/颜色标准化、fragment marker 提取等)
切换点:
- 在 `RDEPUBReaderController.resolvedTextRenderer()`(或其配置位置)增加策略:当开关启用时选择 `RDEPUBWXReadTextRenderer()`,否则沿用 `RDEPUBDTCoreTextRenderer()`
- 建议默认先提供“实验开关”(仅 Demo / debug 可见),降低回归风险。
### 5.2 CSS 分层策略WXRead 风格)
我们在 SDK 内实现与 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 一致的分层概念,但不直接照搬其私有样式文件:
- Layer 1`default.css`SDK 自己维护的基础排版规则)
- Layer 2`replace.css`SDK 自己维护的替换/增强规则:标题、代码块、图片最大宽度等)
- Layer 3`dark.css`(暗色主题覆盖,仅在暗色主题启用)
- Layer 4EPUB 嵌入 CSS书籍自带DTCoreText 解析 HTML 时自然生效;相对路径靠 baseURL
- Layer 5用户设置 CSS`RDEPUBTextRenderStyle` 动态生成:字体、字号、行高、背景色、文字色等)
实现方式(建议):
1) 新增 `RDEPUBWXReadStyleSheetBuilder`
- `func makeDefaultCSS() -> String`
- `func makeReplaceCSS() -> String`
- `func makeDarkCSS(theme: RDEPUBTheme) -> String?`
- `func makeUserCSS(style: RDEPUBTextRenderStyle, theme: RDEPUBTheme) -> String`
- `func composeCSS(...) -> String`(按层拼接,后层覆盖前层)
2) 在 renderer 中将合成后的 CSS 注入到 HTML
- 若存在 `<head>`:插入 `<style id="rd-wxread-layered-style">...`
- 若不存在:在 `<html>` 后插入 `<head>...`
- 保持原 HTML 内容尽量不改动(外部脚本/交互内容已被 readingProfile 判定剔除到 web 分支)
### 5.3 baseURL 与资源解析
目前 `RDEPUBTextBookBuilder` 传入:
- `baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent()`
原则:
- baseURL 必须是“章节文件所在目录”,以确保:
- `<img src="...">` 相对路径可解析
- `<link href="...">` CSS 相对路径可解析(如果 DTCoreText 支持)
待核验点(实现时做小实验):
- DTCoreText 对 `<link rel="stylesheet">` 的解析策略是否完整;若不完整:
- 兜底策略:在渲染前解析 HTML 中的 `<link rel="stylesheet">`,读取 CSS 内容并内联到 `<style>`(仅限 `file://` 且位于 EPUB 解压目录内)。
### 5.4 分页策略(阶段性)
现状:
- `NSAttributedString.ss_pageRanges(size:)` 使用 `CTFramesetterCreateFrame` + `CTFrameGetVisibleStringRange`,属于“最小可用分页”。
WXRead 的更高阶策略(参考 `Doc/WXRead/analysis/DTCoreText自定义修改分析.md`)可能包含:
- 避免孤行/断页
- 图片/附件的分页边界处理
- 特定块元素的分页规则
本次建议:
- Phase 2先保持现有分页算法只要渲染输入CSS 分层 + 后处理)到位,就能显著改善一致性。
- Phase 3针对真实书籍出现的问题逐条补齐分页规则问题驱动避免一开始就引入复杂分页器导致风险扩大。
> 范围约束:如果某个分页问题需要引入“新的复杂分页器”或大规模模拟 `WRCoreTextLayouter` / `WRCoreTextLayoutFrame`,应先暂停并回到方案讨论,不默认并入第一期实现。
### 5.5 与现有高亮/搜索/位置映射的兼容
当前 `.textReflowable` 路径:
- 高亮/搜索依赖 `RDEPUBTextBook``fragmentOffsets``location/progression` 映射(见 `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift``RDEPUBTextBook``pageNumber(for:)` / `location(forPageNumber:)`)。
兼容策略:
- 继续使用现有的 fragment marker 注入与提取:
- `RDEPUBTextRendererSupport.injectFragmentMarkers(...)`
- `RDEPUBTextRendererSupport.extractFragmentOffsets(...)`
- renderer 只改变“CSS 注入与 DTCoreText options/后处理”,不改变 marker 体系与 `RDEPUBTextBook` 数据结构,以降低 UI 层回归。
## 6. 开发落点(文件 / 类型 / 目录)
### 6.1 新增文件(建议位置)
放在 `Sources/RDReaderView/EPUBTextRendering/`(因为它是 textReflowable 的渲染与分页域):
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadTextRenderer.swift`(新)
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadStyleSheetBuilder.swift`(新)
- (可选)`Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadHTMLPreprocessor.swift`(新:仅当需要内联 `<link>` CSS 时)
资源文件(建议):
- `Sources/RDReaderView/Resources/WXRead/default.css`SDK 自己写)
- `Sources/RDReaderView/Resources/WXRead/replace.css`SDK 自己写)
- `Sources/RDReaderView/Resources/WXRead/dark.css`SDK 自己写)
> 注意:这些资源需被 `RDReaderView.podspec` 的 resource bundle 覆盖到(当前资源 bundle 为 `RDReaderViewAssets`,来源 `Sources/RDReaderView/Resources/**`)。
### 6.2 改动文件(建议最小改动)
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
- 在 `resolvedTextRenderer()` 或相邻配置处增加选择逻辑(开关 / 版本策略)
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift`
- 如需内联 link CSS在获取 `rawHTML` 后做预处理(保持接口不变)
## 7. 验收标准与验证方式(对应 REQUIREMENTS
### 对应 REND-01 / REND-03
- 在 Demo 中打开 reflowable EPUB
- 正文渲染不依赖 `WKWebView`(可通过日志/断点确认不走 `RDEPUBWebContentView`
- CSS 分层生效:默认样式可控、主题/字号/行高变化可控
- 图片/链接至少可正确显示/响应(链接行为按现有 text 内容策略)
### 对应 REND-02
- Fixed Layout EPUB仍走 `.webFixedLayout``WKWebView` 路径
- 交互式 EPUB仍走 `.webInteractive``WKWebView` 路径(桥接与外链不回归)
### 对应 STAB-01 / STAB-02
- `RDURLReaderController` 打开 `.epub` / `.txt` 主流程不回归
- 至少使用以下 3 类 reflowable EPUB 样本进行回归:
- 样本 A纯文本/小说类章节为主,验证基础段落、标题、分页与阅读位置恢复
- 样本 B包含内嵌图片与多段样式的章节验证图片显示、图片前后分页、基础 CSS 生效
- 样本 C包含外链与多个 CSS 文件引用的章节,验证 baseURL、样式解析与链接呈现稳定性
- 对以上样本的共同要求:不崩溃、不白屏、不无限加载;分页/翻页可用
## 8. 风险清单与降级策略
### 8.1 主要风险
- R1DTCoreText 对 EPUB 内嵌 CSS / `<link>` CSS 支持不足,导致样式缺失
- R2分页质量不足断页不美观、图片分页异常
- R3`hasInteractiveContent()` 判定过宽,导致大量书被误判为 `.webInteractive`,覆盖率不足
### 8.2 降级/灰度(建议)
- D1增加一个“渲染引擎开关”仅 debug 或 demo 可配置),可在出现严重问题时快速回退到现有 `RDEPUBDTCoreTextRenderer`
- D2`hasInteractiveContent()` 的判定提供可配置白名单/黑名单(例如按 manifest properties、按 tag 命中级别)
## 9. 下一步(交接到开发)
建议按 `Doc/ARCHITECTURE-CONTEXT.md` 中的架构决策逐步推进:
- 先基于 `Doc/WXRead/analysis/*` 提炼“我们要实现的 CSS 分层最小集合”
- 再在 `.textReflowable` renderer 里实现“分层 CSS 注入 + baseURL/资源解析兜底”
- 最后用 Demo 书籍做回归,按问题驱动补齐分页/样式细节
---
*Last updated: 2026-05-21 after discuss-feature-solution*

View File

@ -1,365 +0,0 @@
# 将高亮选区实现 1:1 复刻为 WXRead 架构
## Context
当前 ReadViewSDK 的高亮选区实现与 WXRead 存在根本性架构差异。需要将选区系统从"UITextView 透明代理 + 独立 overlay 层"迁移到 WXRead 的"自定义手势 + CoreText 直接命中测试 + 统一 drawRect 绘制"架构。
## 核心差异对比
| 维度 | 当前 ReadViewSDK | WXRead |
|------|-----------------|--------|
| 选择触发 | UITextView 原生长按(透明文本) | 自定义 long-press(0.5s) + pan 手势 |
| 命中测试 | DTCoreText `stringIndex(forPosition:)` | `CTLineGetStringIndexForPosition` + 坐标翻转 |
| 选区绘制 | 独立 `RDEPUBSelectionOverlayView` overlay 层 | 同一 `drawRect:` 内绘制(文字+高亮+选区) |
| 高亮绘制 | overlay 层计算 rect 后 CG 填充 | `com.weread.highlight` 自定义属性注入 NSAttributedString`drawInContext:` 中读取绘制 |
| 菜单系统 | 自定义 `selectionActionBar` UIStackView | `UIMenuController` + 自定义 items |
| 手势模型 | 无 pan 手势UITextView 自带拖拽) | long-press 启动 + pan 扩展,`isSelecting` 控制 pan 启停 |
| 视图层级 | 3 层 overlaybackground + text + foreground | 单一 WRPageView 统一绘制 |
---
## Phase 1: 移除 UITextView改用自定义手势 + CoreText 命中测试
### 1.1 修改 `RDEPUBPageInteractionController.swift`
当前已正确封装 DTCoreText 的 `stringIndex(forPosition:)``offset(forStringIndex:)`,算法与 WXRead 一致。
**新增方法:**
- `characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView)` — 将 UIKit 坐标转为相对于 content view 的坐标后调用 `characterIndex(at:)`,对应 WXRead 的 `stringIndexForPoint:` + `WRSFlipPointForCoreText`
> 注意DTCoreText 已在内部处理了 UIKit↔CoreText 坐标翻转(`line.baselineOrigin` 是 UIKit 坐标),所以不需要手动翻转 Y 轴。但 WXRead 的自定义 DTCoreText 需要手动翻转。当前项目用的是原版 DTCoreText pod行为已正确。
### 1.2 重构 `RDEPUBTextSelectionController.swift`
**当前状态:** 遵循 `UITextViewDelegate`,通过 `textViewDidChangeSelection` 接收选区变化。`handleLongPress` 方法存在但未被任何手势调用。
**改为:**
- 移除 `UITextViewDelegate` 遵循
- 移除 `textViewDidChangeSelection(_:)``textViewDidChangeSelection(_:, page:)`
- 新增状态属性:`selectionStartIndex: Int = NSNotFound`、`selectionEndIndex: Int = NSNotFound`、`isSelecting: Bool = false`
- 重构 `handleLongPress`
- `.began`:调用 `characterIndex(at:)` 设置 `selectionStartIndex = selectionEndIndex = index`,设 `isSelecting = true`
- `.ended`:设 `isSelectionFromInteraction = false`(保留,供后续扩展)
- 新增 `handlePan(_ gesture:, page:, renderView:, interactionController:)`
- `.changed`:计算字符索引,更新 `selectionEndIndex`,计算 range = `(min, max - min)`,计算 rects更新 renderView
- 移除 `clearSelection``textView:` 参数
- `makeSelection(from:, page:)` 保持不变(已正确基于绝对偏移构建 `RDEPUBSelection`
### 1.3 重构 `RDEPUBTextContentView.swift` — 移除 UITextView
**删除:**
- `textView: RDEPUBSelectableTextView` 属性及其初始化
- `selectionProxyContent(from:)` 方法
- `textView.delegate = selectionController` 等 textView 配置代码
- `textView.frame = ...``layoutSubviews` 中的设置
- `configure(page:...)` 中所有 `textView.attributedText = ...`、`textView.selectedRange = ...`、`textView.isHidden = ...` 赋值
**新增手势识别器(对齐 WXRead 的 WRPageView**
```swift
private let longPressGR = UILongPressGestureRecognizer(target: ..., action: #selector(handleLongPress))
private let panGR = UIPanGestureRecognizer(target: ..., action: #selector(handlePan))
private let tapGR = UITapGestureRecognizer(target: ..., action: #selector(handleTap))
```
- `longPressGR.minimumPressDuration = 0.5`(与 WXRead 一致)
- `panGR.isEnabled = false`初始禁用long-press began 时启用)
- `tapGR.require(toFail: longPressGR)`(与 WXRead 一致)
- 三个手势都添加到 contentView 自身
**手势响应:**
- `handleLongPress`:转发给 `selectionController.handleLongPress`,启用 `panGR`
- `handlePan`:转发给 `selectionController.handlePan`
- `handleTap`:如果 `selectionController.isSelecting``clearSelection()`,否则转发给 delegate 做工具栏切换
**菜单改为 UIMenuController对齐 WXRead**
- 删除 `selectionActionBar: UIStackView` 及相关方法(`showSelectionActionBarIfNeeded`、`hideSelectionActionBar`、`updateSelectionActionBarFrame`、`selectionMenuButton`
- 在 `selectionController.onSelectionChanged` 回调中,当 selection 非 nil 时调用 `showSelectionMenu(in:anchorRect:)`
- `RDEPUBTextContentView` 设为 `canBecomeFirstResponder = true`override `canPerformAction` 仅允许三个自定义 selector
- 使用 `UIMenuController.shared` 配置 "拷贝"/"高亮"/"批注" 三个 `UIMenuItem`
**调整 `clearSelection()`**
```swift
func clearSelection() {
currentSelection = nil
menuSelection = nil
panGR.isEnabled = false
selectionController.clearSelection(overlayView: overlayView, backgroundOverlayView: backgroundOverlayView)
UIMenuController.shared.setMenuVisible(false, animated: true)
}
```
### 1.4 删除 `RDEPUBSelectableTextView.swift`
该文件的功能(屏蔽系统菜单、暴露自定义 action已被 UIMenuController 方案替代,直接删除。
### 1.5 更新 `RDEPUBReaderController+ContentDelegates.swift`
`textContentView(_:, didRequestSelectionAction:, selection:)` 中的 `contentView.clearSelection()` 调用无需改动,新的 `clearSelection()` 签名兼容。
### Phase 1 验证
- UI 测试 `ReaderAnnotationTests.testSelectionMenuCreatesHighlight` 必须通过
- 手动验证:长按选词 → 弹出 UIMenuController → 点击"高亮" → 高亮创建成功
- 手动验证:拖拽扩展选区 → 蓝色选区矩形正确绘制
- 手动验证:单击空白处 → 选区清除
---
## Phase 2: 统一绘制循环 — 高亮/选区在 draw(_:) 中绘制
### 2.1 扩展 `RDEPUBTextPageRenderView.swift`
**当前状态:** 仅调用 `layoutFrame.draw(in: context, options:)` 绘制文字。
**新增属性:**
```swift
var highlightRanges: [(range: NSRange, color: UIColor)] = [] { didSet { setNeedsDisplay() } }
var underlineRanges: [(range: NSRange, color: UIColor, style: Int)] = [] { didSet { setNeedsDisplay() } }
var selectionRects: [CGRect] = [] { didSet { setNeedsDisplay() } }
var selectionColor: UIColor = UIColor(red: 70/255, green: 140/255, blue: 1, alpha: 0.24)
```
**扩展 `draw(_:)`**
```swift
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(), let layoutFrame else { return }
context.saveGState()
// 1. 绘制高亮背景(在文字下方,匹配 WXRead 的 drawHighlightsInContext:
drawHighlights(in: context, layoutFrame: layoutFrame)
// 2. 绘制文字
layoutFrame.draw(in: context, options: drawOptions)
// 3. 绘制选区(在文字上方,匹配 WXRead 的 _drawSelectionInContext:
drawSelection(in: context)
context.restoreGState()
}
```
**`drawHighlights` 算法(对齐 WXRead `WRCoreTextLayoutFrame.drawHighlightsInContext:`**
```swift
private func drawHighlights(in context: CGContext, layoutFrame: DTCoreTextLayoutFrame) {
for (range, color) in highlightRanges {
let lines = layoutFrame.lines as! [DTCoreTextLayoutLine]
for line in lines {
let overlap = NSIntersectionRange(range, line.stringRange)
guard overlap.length > 0 else { continue }
let startX = line.offset(forStringIndex: overlap.location)
let endX = line.offset(forStringIndex: overlap.location + overlap.length)
let rect = CGRect(
x: line.baselineOrigin.x + startX,
y: line.baselineOrigin.y - line.ascent,
width: endX - startX,
height: line.ascent + line.descent
)
color.withAlphaComponent(0.35).setFill() // WXRead 使用 35% alpha
context.fill(rect)
}
}
}
```
> 说明WXRead 使用 `[color colorWithAlphaComponent:0.3]`WRPageHighlight 的预设色本身已是 35% alpha最终效果等同。当前项目使用 0.45 alpha需调整为 0.35 以完全对齐。
**`drawSelection` 算法(对齐 WXRead `_drawSelectionInContext:`**
```swift
private func drawSelection(in context: CGContext) {
guard !selectionRects.isEmpty else { return }
selectionColor.setFill()
for rect in selectionRects {
context.fill(rect)
}
}
```
### 2.2 简化 `RDEPUBTextContentView.swift` 视图层级
**删除/保留:**
- 删除 `backgroundOverlayView` 属性(高亮背景现在由 renderView 在文字下方绘制)
- 保留 `overlayView`(用于非 DTCoreText 回退路径和搜索高亮)
- `configure(page:...)` 中,将 highlight 数据传给 `coreTextContentView` 而非 overlayView
```swift
// DTCoreText 路径
coreTextContentView.highlightRanges = highlights.compactMap { highlight -> (NSRange, UIColor)? in
guard let rangeInfo = highlight.rangeInfo,
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { return nil }
let absoluteRange = info.nsRange
let overlap = NSIntersectionRange(absoluteRange, pageAbsoluteRange)
guard overlap.length > 0 else { return nil }
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
return (relativeRange, highlight.uiColor)
}
```
### 2.3 更新 `RDEPUBTextSelectionController.swift` — 直接更新 renderView
- `handleLongPress``handlePan` 现在直接更新 `renderView.selectionRects` 并调用 `renderView.setNeedsDisplay()`
- 移除 `overlayView.updateSelection(absoluteRange:, rects:)` 调用
### Phase 2 验证
- 视觉对比:高亮矩形与文字像素对齐
- 性能测试:单次 `draw(_:)` 耗时应与之前持平或更快
- 回归测试:搜索高亮仍正常显示
---
## Phase 3: 高亮属性注入 NSAttributedString对齐 WXRead `com.weread.highlight`
### 3.1 定义自定义属性常量
```swift
// 对齐 WXRead 的 kWRHighlightAttributeName / kWRUnderlineAttributeName
let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
```
### 3.2 新增 `RDEPUBChapterData.applyHighlights(to:page:highlights:)`
对齐 WXRead 的 `WRChapterData.addHighlightInRange:key:itemId:color:`
```swift
func applyHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
highlights: [RDEPUBHighlight]
) {
for highlight in highlights {
guard let rangeInfo = highlight.rangeInfo,
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { continue }
let absoluteRange = info.nsRange
let pageRange = NSRange(location: page.pageStartOffset, length: page.pageEndOffset - page.pageStartOffset)
let overlap = NSIntersectionRange(absoluteRange, pageRange)
guard overlap.length > 0 else { continue }
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
switch highlight.style {
case .highlight:
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
case .underline:
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
}
}
}
```
### 3.3 `RDEPUBTextPageRenderView.draw(_:)` 从属性读取高亮
替代 Phase 2 的 `highlightRanges` 属性方案,改为在 `draw(_:)` 中枚举 attributed string 的自定义属性:
```swift
private func drawHighlightsFromAttributes(in context: CGContext, attributedString: NSAttributedString) {
let fullRange = NSRange(location: 0, length: attributedString.length)
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
guard let color = value as? UIColor else { return }
let rects = computeRects(for: range) // 复用 line 迭代 + CTLineGetOffsetForStringIndex
color.withAlphaComponent(0.35).setFill()
for rect in rects { context.fill(rect) }
}
}
```
### 3.4 更新 `RDEPUBTextContentView.configure(page:...)`
```swift
// 在传给 renderView 之前注入高亮属性
let displayContent = darkImageAdjustedContentIfNeeded(...)
chapterData.applyHighlights(to: displayContent, page: page, highlights: highlights)
coreTextContentView.attributedDisplayContent = displayContent // 新增属性
```
### Phase 3 验证
- 高亮在翻页后仍正确显示(属性嵌入 attributed string不依赖外部状态
- 高亮颜色、alpha、rect 与 WXRead 截图一致
---
## Phase 4: 手势模型对齐 — long-press + pan + isSelecting
### 4.1 手势冲突处理
**风险:** pan 手势(选区扩展)可能与 RDReaderView 的翻页手势冲突。
**解决方案(对齐 WXRead**
- `panGR` 初始 `isEnabled = false`,仅在 `isSelecting = true` 时启用
- `clearSelection()` 时禁用 `panGR`
- 新增 delegate 方法通知父视图:
```swift
func textContentViewDidBeginSelection(_ contentView: RDEPUBTextContentView)
func textContentViewDidEndSelection(_ contentView: RDEPUBTextContentView)
```
- `RDReaderView``didBeginSelection` 时禁用翻页手势,在 `didEndSelection` 时恢复
### 4.2 WXRead 手势时序对齐
WXRead 的手势流程:
1. long-press `.began` → 设置 `selectionStartIndex = selectionEndIndex = index``isSelecting = true`,启用 panGR`setNeedsDisplay`
2. long-press `.ended` → 无额外操作(保留选区)
3. pan `.changed` → 更新 `selectionEndIndex`,计算 rects`setNeedsDisplay`
4. single tap → `clearSelection()`,禁用 panGR
### Phase 4 验证
- 长按选词 → 拖拽扩展 → 单击取消,全流程流畅
- 无选区时翻页手势正常
- 有选区时翻页手势被禁用
---
## Phase 5: 菜单系统对齐 + 清理
### 5.1 UIMenuController 替换 selectionActionBar
已在 Phase 1 中完成。此阶段仅做清理:
- 删除 `RDEPUBSelectableTextView.swift`
- 标记 `RDEPUBSelectionOverlayView.swift``RDEPUBTextPageDecorationView.swift` 为 deprecated保留给非 DTCoreText 回退路径)
### 5.2 更新 UI 测试
`ReaderAnnotationTests` 中查找菜单项的方式需更新:
- 当前:`app.buttons["高亮"]`UIStackView 中的按钮)
- 改为:`app.menuItems["高亮"]`UIMenuController 的菜单项)
- 或者:保留 `accessibilityIdentifier` 在 RDEPUBTextContentView 上以便测试定位
### 5.3 高亮颜色对齐
WXRead 的 5 种预设色35% alpha
- Yellow: `(1.0, 0.92, 0.23, 0.35)`
- Blue: `(0.26, 0.65, 0.96, 0.35)`
- Red: `(0.96, 0.26, 0.26, 0.35)`
- Green: `(0.30, 0.85, 0.39, 0.35)`
- Purple: `(0.67, 0.33, 0.97, 0.35)`
当前项目使用 CSS hex 颜色 + 0.45 alpha需对齐为 WXRead 的 RGBA 值。
---
## 文件变更清单
| 文件 | 操作 | Phase |
|------|------|-------|
| `RDEPUBPageInteractionController.swift` | 修改:新增 `characterIndexForViewPoint` | 1 |
| `RDEPUBTextSelectionController.swift` | 重构:移除 UITextViewDelegate新增 pan 处理、状态机 | 1 |
| `RDEPUBTextContentView.swift` | 重构:移除 textView新增手势替换菜单简化层级 | 1,2,4 |
| `RDEPUBSelectableTextView.swift` | **删除** | 1 |
| `RDEPUBTextPageRenderView.swift` | 扩展:新增高亮/选区/下划线绘制逻辑 | 2,3 |
| `RDEPUBChapterData.swift` | 新增:`applyHighlights(to:page:highlights:)` | 3 |
| `RDEPUBReaderController+ContentDelegates.swift` | 小改:适配新 clearSelection 签名 | 1 |
| `RDReaderView.swift` | 新增:选区期间禁用翻页手势 | 4 |
| `RDEPUBSelectionOverlayView.swift` | 保留deprecated for DTCoreText path | 5 |
| `RDEPUBTextPageDecorationView.swift` | 保留deprecated for DTCoreText path | 5 |
| `RDEPUBTextAnnotationOverlay.swift` | 保留deprecated for DTCoreText path | 5 |
| `ReaderAnnotationTests.swift` | 更新:菜单项查找方式 | 5 |
## 验证方案
1. **UI 测试**`ReaderAnnotationTests` 全部通过
2. **手动测试**
- 长按选词 → 蓝色选区高亮显示
- 拖拽扩展选区 → 选区跟随手指
- 点击"高亮" → 黄色高亮创建成功
- 翻页后返回 → 高亮仍存在
- 点击"拷贝" → 文本已复制
- 点击"批注" → 弹出笔记输入框
- 单击空白处 → 选区清除
3. **性能测试**`draw(_:)` 耗时 ≤ 之前(单次绘制 vs 三次绘制)
4. **对比验证**:与 WXRead 截图对比高亮颜色、alpha、rect 位置

View File

@ -0,0 +1,154 @@
# 长章节内存优化 — 代码实施清单
> 背景DTCoreText 文本页已采用"整章上下文布局",分页与显示边界一致(不可回退)。现状代价:每次翻页/预加载都整章重拷贝 + 重排版后丢弃,且章节缓存里每页常驻一份子串副本。
>
> 总原则:保留"整章参与排版",去掉"整章按页复制、按页可变、按页缓存"。不改分页器,不上窗口化。
---
## P0立即可做
### P0-1 削减每页 `content` 子串常驻副本
**现状**:分页产物为每页保存整段 `attributedSubstring`全章加总约等于又一份整章副本随邻接窗口radius 1共 3 章)常驻。
生成点:
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift:494``buildPagesFromRanges`
- `Sources/RDEpubReaderView/EPUBTextRendering/BuildPipeline/RDEPUBTextBookBuilder.swift:294`
- `Sources/RDEpubReaderView/EPUBTextRendering/RDEpubPlainTextBookBuilder.swift:78`
现存全部消费点(已盘点确认,仅 3 处):
- `RDEPUBTextContentView.swift:562` — 封面检测 `coverImage(from: page.content)`,仅 `pageIndexInChapter == 0` 且 href 含 "cover"
- `RDEPUBTextContentView.swift:641``normalizedPageContent`,仅非 DTCoreText 的 `#else` 回退路径调用
- `RDEPUBTextBookBuilder.swift:93` — 构建期 debug 日志 preview
**任务**
- [x] 将 `RDEPUBTextPage.content` 改为按需构造:已改为基于 `chapterContent` + `contentRange` 的计算属性(带范围钳制),公开读取 API 不变
- [x] 三个构造点(`RDEPUBChapterLoader` / `RDEPUBTextBookBuilder` / `RDEpubPlainTextBookBuilder`)不再生成每页子串;消费点经计算属性透明按需构造
- [x] `Equatable` 确认:`content` 为派生值,由 `contentRange` + `chapterContent` 判等覆盖,语义不变
**API 兼容性注意**
- `RDEPUBTextPage` 当前是 `public struct``content` 也是公开存储属性;如果 SDK 已存在外部接入方直接读取 `page.content`,则不应直接移除该字段
- 优先方案是保留 `content` 对外访问语义,但将其改为按需构造的计算属性或受控访问器,避免对外 API 断裂
- 若确认当前版本尚无稳定外部依赖,才可以评估把 `content` 从存储属性调整为内部实现细节
- 在实施前应补做一次全仓库与接入侧检索,确认是否有外部调用依赖 `page.content` 的“可变存储”特性
**验收**:打开 3 章窗口后常驻内存下降约"一整章文本规模 × 窗口章数";封面页显示正常;非 DTCoreText 回退路径回归通过;`--demo-pagination-validate` 命中数不回升。
### P0-2 增加内存打点
- [x] 已新增 `RDEPUBMemoryProbe``--demo-memory-probe` 启动参数开启,输出 phys_footprint挂载点`RDEPUBChapterRuntimeStore.insertChapter`、`pageNum` 回调每 20 次翻页、`invalidateAllForSettingsChange`、`viewWillTransition` 转屏完成
- [x] 用 `--demo-memory-probe` 运行 Demo 即可采集对比基线
### P0-3 保持现有缓存限制,防止错误复用
- [x] `shouldAvoidReaderPageCaching``RDEPUBTextContentView.swift:319`)保持现状,已补注释说明放开的前置条件是 P1-1 落地
- [x] 本阶段未改 `RDEpubReaderPreloadController` 行为
---
## P1第二波P1-1 与 P1-2 必须同批落地)
### P1-1 章节级共享 displayContent
**现状**`RDEPUBTextContentView.configure` 每次创建整章可变副本(`RDEPUBTextContentView.swift:443`并按页写入三类属性页范围主题色453、页范围暗黑图附件替换448、按页裁剪的高亮属性459 `applyHighlightsToContent`)。
**约束**:共享字符串一旦被任一页的 `DTCoreTextLayouter`/framesetter 持有,就不得再修改——所有属性必须在共享内容构建时一次性注入,或转为 overlay见 P1-2
**任务**
- [x] 已新建 `RDEPUBChapterDisplayContentCache`LRU 容量 2主线程限定`RDEPUBReaderController` 持有):`content` + `layouter` + `signature`
- [x] 构建共享内容时一次性注入:全章范围主题色;高亮/下划线因 P1-2 转 overlay不进共享内容也不进签名
- [x] 暗黑图策略选 a构建时全章一次性替换复用 `RDEPUBDarkImageAdjuster` 的 NSCache
- [x] `RDEPUBTextContentView.configure` 已改为接收 `displayCache` 参数并引用共享 entry整章拷贝已删除
- [x] 签名 = 章节内容对象标识 + 长度 + 主题双色 + 暗黑图配置;设置/主题变化经签名自动失效重建(高亮变化不触发重建,仅重建 overlay 装饰)
- [x] 控制器在 `didReceiveMemoryWarning` 清空 display cache设置变更重建 chapterContent 实例后由签名兜底换代
**验收**连续翻页不再出现整章拷贝打点确认翻页延迟下降高亮显示、高亮点击菜单、underline、搜索 currentMatch、附件点击、选区拖拽全部回归通过。
> 2026-07-08 回归记录:高亮/批注/选区/翻页/设置 13 项 UI 用例全过SearchTests 10 项失败,但已在会话前提交 5a41066 与 P0 提交 e4e629a 上复现同样失败(搜索 0 命中),确认为先于本优化存在的独立回归(最后一次已知通过是 2026-06-08 全量跑),需单独排查,与 P1 改动无关。
### P1-2 高亮/下划线剥离为 overlay与 P1-1 绑定)
**现状**:搜索高亮已走 overlay`RDEPUBTextContentView.swift:759` 处 `buildDecorations``highlights: []`,注释明确为避免双画);普通高亮/下划线仍靠写 `kRDEPUBHighlightAttributeName` / `kRDEPUBUnderlineAttributeName` 属性由 render view 绘制。
**任务**
- [x] `buildDecorations` 已传入真实 highlights高亮进背景层文字下方、下划线进前景层
- [x] `applyHighlightsToContent` 与 render view 的 `drawHighlights`/`computeHighlightRects`/`attributedDisplayContent` 已整体移除,无双画
- [x] 高亮命中与菜单锚点继续走 `interactionController.selectionRects`,坐标保持 chapter-absolute未改动
**验收**:高亮/下划线视觉与改前一致(含跨页高亮的页内裁剪);高亮点击弹菜单正常;与搜索高亮叠加时无双画。
### P1-3 章节级共享 display layouter
**现状**:每个页面视图各建一个 `DTCoreTextLayouter``RDEPUBTextContentView.swift:465`);章节运行时已有分页用 `RDEPUBTextLayouter``RDEPUBRuntimeChapter.layouter`display layouter 向同一模式靠拢。
**任务**
- [x] `DTCoreTextLayouter` 已随共享内容放入 display cache与 signature 同生命周期
- [x] 页面按 `bounds` + `page.contentRange``layoutFrame``shouldCacheLayoutFrames = false` 保持layoutFrame 按页新建VerticalJustifier 的修改不会污染共享对象)
- [x] framesetter 只依赖字符串、与 bounds 无关bounds 变化无需失效共享对象layoutFrame 随页面视图释放
- [x] `RDEPUBTextPageBoundaryValidator` 校验路径不变
**验收**:翻页时不再重建 framesetter打点确认横竖屏切换后布局正确`--demo-pagination-validate` 不回升。
### P1-4 评估重新放开文本页预加载缓存
前置P1-1P1-3 全部落地后,页面视图不再持有整章副本。
- [ ] 调整 `shouldAvoidReaderPageCaching` 判定,允许轻量化后的文本页进入 `RDEpubReaderPreloadController` 缓存
- [ ] 对比放开前后的翻页流畅度与峰值内存,数据不佳则回退此项
---
## P2视 P0/P1 打点数据决定
### P2-1 章节级 LRU 分层淘汰
**现状**`RDEPUBChapterRuntimeStore` 已有邻接窗口(`windowSpineIndices`radius 1、`evictableSpineIndices` / `evict` / `evictAllExceptCurrent` / `handleMemoryWarning` / `invalidateAllForSettingsChange`;磁盘侧有 `RDEPUBChapterSummaryDiskCache`(分页结果 + cfiMap + metadata。当前邻接章节仍持有完整 `RDEPUBRuntimeChapter`typesetString + pages + layouter
**任务**
- [ ] 邻接章节降级:保留 page map / chapter summary / 分页结果,释放 `typesetAttributedString`、display content、layouter
- [ ] 进入邻接章节时按 summary 重建,测量重建延迟;延迟不可接受则维持现状
- [ ] 远距章节仅保留磁盘缓存 + 轻量 metadata现有 evict 已覆盖,确认即可)
### P2-2 图片附件深化
**现状**:暗黑图已走 `NSCache`50 MB / 100 张,`RDEPUBDarkImageAdjuster.swift:9`)且仅处理当前页范围;章节运行时另有 100 MB `imageCache`
**任务**
- [ ] 大图按显示尺寸下采样后再参与 attachment
- [ ] 页面离屏后释放 attachment 强引用的已解码大图;避免共享 content 长期持有
- [ ] 回归:图片点击查看、暗黑模式切换、附件位置与命中
---
## P3仅在 P0P2 之后数据仍不达标时
- [ ] 上下文窗口化布局(当前页所在段落块 + 前后足以影响断行的文本窗口)。风险最高:易重新引入分页/显示边界不一致CTTypesetter 断行上下文敏感,为已知已解 bug 的根因),选区/搜索/高亮/附件索引需全部重验。默认不做。
---
## 明确不做
1. 回退"页内子串重新布局"(重新引入显示不全/页末孤字)
2. 取消 chapter-absolute 索引(破坏点击/选区/高亮定位一致性)
3. 先改分页器(正确性与性能问题混杂,无法回归)
4. 未落地 P1 前放开文本页 reader 级缓存(会驻留多份整章副本)
---
## 各阶段统一验收指标
**正确性**`--demo-pagination-validate` 命中数不回升;长章节页末无孤字/缺字;高亮显示、搜索高亮、附件点击、选区拖拽行为一致。
**内存**(对比 P0-2 基线):打开长章节后常驻;连续翻页 20 页峰值;设置变更前后峰值;横竖屏切换峰值。
**性能**:首次打开章节耗时;连续翻页帧稳定性;设置切换恢复时间。

View File

@ -0,0 +1,123 @@
# 分页问题调查记录2026-07-06
调查载体《宝山辽墓材料与释读》textReflowable / DTCoreText 路径iPhone 15 Pro Max 尺寸430×932内容区 398×815pt。当日共调查两个独立问题
---
## 问题一:页底留空一行多,下一页首行未上移(已修复)
### 现象
第 6 页底部留有约 1.7 行高的空白,第 7 页首行"人目为帝羓,信有之也。[注]"本可容纳在第 6 页。截图实测:行距 75px@3x25pt第 6 页底部空隙 138px足够再排一行。
### 根因链
1. `RDEPUBSemanticMarkerInjector.inferredHints` 给**所有** `<img>` 打上 `avoidPageBreakInside` 提示——包括行内脚注小图标(`<img class="qqreader-footnote">`,即"注"字图标)。
2. `RDEPUBPageBreakPolicy.shouldTreatAvoidHintAsBlockProtection` 本有豁免:`blockKind == .attachment && placement != .centered → 不保护`。但 `applyPaginationSemantics` 按**结束标记的字符串顺序**应用属性:`<img>` 的结束标记先于外层 `<p>`,段落随后把图标位置的 `.rdPageBlockKind``attachment` 覆盖为 `paragraph`;而 `hints``placement` 因段落不携带这两个属性而幸存。豁免条件因此失效。
3. `trimmedRangeForAvoidPageBreakInside` 把含注图标的行当作"不可分页块"的行,从页底裁掉(最多 3 行),推到下一页,留下空白。
### 验证手段
- Demo 启动参数 `--demo-pagination-debug` 输出 `[PAGINATION-DEBUG] avoidPageBreakInside removed N lines: …`;修复前被裁的行**全部**含 ``(脚注图标)。
- 复现命令:`--demo-book-title 宝山 --demo-page 6 --demo-reset-state --demo-clear-cache --demo-pagination-debug`。
### 修复
`RDEPUBPageBreakPolicy.swift`:豁免条件放宽为 `(blockKind == .attachment || placement != nil) && placement != .centered`——不再依赖易被覆盖的 blockKind只要附件 placement 是行内/基线非居中就不锁行。居中插图bodyPic的整块保护不受影响同时覆盖 `s-pic`/`h-pic`/`g-pic` 等生僻字行内小图。
修复后验证:含 `` 的裁剪从多处降为 0"人目为帝羓"行回到第 6 页且下一段首行补齐;全书页数 280 → 273消除欠填页
> 备选方案(未采用):修 `applyPaginationSemantics` 的嵌套覆盖顺序(内层优先)。会改变列表/表格的 blockRange 语义,风险大。
---
## 问题二页范围与显示内容度量错配行中断页2026-07-07 已定位根因,见文末新增章节)
### 现象
第 10 页最后一行只有孤字"相",第 11 页从"对简化,且无构造细致的翼墙。"开始——断点落在句子中间而非行边界。
### 铁证推理
当前设置下满行 26 字(字号 15、内容宽 398pt。第 10 页范围要在"…而2号墓的门楼则相"后断开,要求该行装下 **27 字**——当前排版下不可能。**结论:页范围产生于另一套度量(更小字号或更宽版面),与显示时的排版不一致。** 同一套排版中分页断点必然落在行边界,孤字不可能出现。
### 已排除(受控实验)
`RDEPUBChapterLoader` 临时加 `--demo-chapter-dump` 转储typesetString 逐属性段 + 页范围,写入 app Documents实验后已还原
- 冷启动(`MISS(fullRender)` 全量渲染分页)与热启动(`HIT(diskSummary)` 复用磁盘页范围)两条路径的字符串与页范围**完全一致**。字体压平、双重 `normalizeReadingAttributes`、语言码缺失(`makeChapterRenderRequest` 未传 `contentLanguageCode`)、`TailNormalizer` 尾页合并均验证为无影响或幂等。
- 持久缓存键控正确:`RDEPUBChapterCacheKey.renderSignature` 含字体名/字号/行距倍数/lineSpacing/`layoutConfig.cacheSignature`宽高、四边距、分栏、hyphenation、schemaVersion=17+ 章节内容哈希。同设置跨启动复用安全。
### 主要嫌疑:会话中改设置的换表过渡态
- 用户两组截图之间调过行距(行距 25pt/页 32 行 → 28pt/页 28 行,字号未变);出现问题时显示总页数 196既非 1.6 档全量分页(~273也非 1.8 档(~290——当时显示的是**未收敛的中间页表**。
- 代码窗口:
- `RDEPUBReaderRuntime.scheduleSettingsPreviewRepagination`:设置面板打开期间只重排**当前章**并 `applySettingsPreviewPageMap` 换部分页表,全量重排推迟到面板关闭(`needsFullRepaginationAfterSettingsClose`)。
- `RDEPUBChapterRuntimeStore.chapterDataCache` 仅按 spineIndex 键控(无样式维度);`invalidateAllForSettingsChange` 清空后,**变更前已在飞行中的构建**完成时会把旧样式章节重新 `insertChapter` 插回。
- 这些窗口里可能出现"旧页范围 + 新排版内容"的错配。
### 待办 / 复现所需
1. 确认触发操作:是否在设置面板调整行距/字号后(未关面板或刚关)翻页出现。
2. 确认重启 app 后是否自愈(受控实验表明缓存路径本身一致,理应自愈;若不自愈则有持久化的脏状态)。
3. 修复方向待复现后定设置变更代generation标记贯穿构建流程飞行中的旧代构建完成后丢弃、不得插回 store`chapterDataCache` 键加入 renderSignature。
### 经验教训
- 跨启动比较"第 N 页"截图无效:章节渐进加载过程中全局页码会漂移。
- 判断断点是否合法的快捷方法:数满行字数。断点不在行边界 ⟺ 范围与显示度量不一致。
---
## 相关工具与命令备忘
| 用途 | 方法 |
|---|---|
| 分页裁剪日志 | 启动参数 `--demo-pagination-debug` |
| 自动打开书籍/翻页 | `--demo-book-title 宝山 --demo-page N --demo-clear-cache --demo-reset-state` |
| 注入阅读设置 | `simctl spawn <sim> defaults write cn.shen.ReadViewDemo ssreader.epub.settings -data <hex(JSON)>`JSON 形如 `{"fontSize":15,"lineHeightMultiple":1.8}` |
| 截屏 | `xcrun simctl io <sim> screenshot <绝对路径>`(相对路径会因只读文件系统失败) |
| 模拟器 | 预装仅 16/17 系列;`simctl create` 可建 iPhone 15 Pro Max430×932@3x 与问题截图同尺寸) |
| 页边界/换行一致性校验 | 启动参数 `--demo-pagination-validate`RDEPUBTextPageBoundaryValidator逐页比对显示换行与全章上下文换行输出 STALE-RANGE / DISPLAY-DIVERGE / DIAGNOSE / ATTR-DIFF |
| 设置面板自动翻转 | 启动参数 `--demo-settings-flip`RDEPUBSettingsFlipAutomation自动开面板→改行距→关面板→翻页三种时序 |
---
## 问题二根因2026-07-07 续查确认)
### 结论
**分页与显示使用同一引擎DTCoreText/CoreText但输入字符串不同分页在“全章字符串”上下文中断行显示把页内容取成子串后重新断行。`CTTypesetterSuggestLineBreak` 在同一文字、同一属性、同一宽度下,会因字符串上下文不同而在 CJK 标点压缩/悬挂处产生 ±1 字的断点漂移。** 页范围(全章上下文产物)与显示换行(子串产物)在页内任何一行发生漂移,累积到页尾就出现孤字(用户看到的「相」)或半空行。
与缓存、设置换表竞态无关:设置变更只是**搬动了页边界位置**,让某个边界恰好落在漂移点上,症状因此看似随设置变化出现/消失。这同时解释了此前所有观察:冷/热启动转储完全一致(分页自身是一致的,错配发生在分页 vs 显示);重启后"第 N 页"不复现(边界挪走了)。
### 证据(宝山书 spine 3字号 15 / 行距 1.6 / 宽 398pt
`--demo-pagination-validate` 基线运行(无任何设置操作)即命中,前 8 页中 5 页分叉。决定性样本 page 8/55 第 11 行(行首 offset 4224
- 显示端断点 4252行尾"…可以相"——孤字机制当场复现),全章上下文断点 4253"…可以相当"
- 逐字符属性 diff**完全一致**(无 ATTR-DIFF
- 对照排版:原始子串 = 4252段落级上下文 = 4252**只有全章上下文 = 4253**
- page 6/55 的上下文探针行宽 408.39 > 框宽 398 —— 标点悬挂/压缩越界的直接证据
即断点差异既不是首行缩进归一化(`normalizedPageContent` 的 firstLineHeadIndent 处理本身是正确的),也不是属性差异,而是 CoreText 断行对字符串上下文(跨段落!)敏感。
### 修复(方案 A2026-07-07 已实施)
显示端改为**全章上下文排版**`RDEPUBTextContentView.configure` 取 `page.chapterContent` 的整章副本作为显示内容,`DTCoreTextLayouter(章节副本).layoutFrame(bounds, range: page.contentRange)` 只排当页范围。断行与分页器按构造一致(同串、同起点、同宽)。
配套改动:
- layoutFrame 的 stringRange 因此为**章节绝对坐标**。`RDEPUBPageLayoutSnapshot.build` 去掉 +pageStartOffset 归一(本就输出绝对坐标给消费方),`RDEPUBPageInteractionController` 去掉全部 -pageOffset 反换算;选区/高亮/点击测试/装饰层的消费接口本来就是绝对坐标,无需变动。
- 主题前景色、暗色图片调整(`RDEPUBDarkImageAdjuster` 新增 `in range:` 参数)、高亮标记只施加在章节副本的当页范围上;`applyHighlightsToContent` 改用绝对 overlap 范围。
- 删除 DTCoreText 路径对 `normalizedPageContent` 的调用——续段首行缩进/段前距归一化 hack 由上下文排版天然取代(非 DTCoreText 回退路径仍保留)。
- layouterframesetter随显示内容缓存于 cell`coreTextLayouter`bounds 变化只重建 layoutFrame避免每次 layout pass 对整章重建 framesetter。
- `RDEPUBTextPageBoundaryValidator` 适配绝对坐标,保留为回归警报。
验证:`--demo-pagination-validate` 基线(原先 spine 3 前 8 页 5 处分叉)修复后 **0 命中**;第 10 页首行断点与分页一致「…使用了更多」Selection/Annotation UI 测试回归通过(见下)。
### 遗留(独立的潜在缺陷,本次未触发)
- `RDEPUBChapterRuntimeStore.chapterDataCache` 仅按 spineIndex 键控;设置变更时飞行中的旧构建完成后仍会插回(`RDEPUBChapterLoader.loadChapter` 的 `insertChapter` 无代际校验)。
- `RDEPUBChapterLoader.pendingLoads` 按 spineIndex 合流,跨设置变更合流会把旧样式章节交给新请求。
- 建议修复问题二后用 `--demo-settings-flip` + `--demo-pagination-validate` 回归验证这两个窗口。

View File

@ -0,0 +1,455 @@
# ReaderView 模块代码级参考文档
> 最后更新2026-06-18
---
## 1. 模块概述
`ReaderView` 是 ReadViewSDK 的**通用翻页容器层**,位于 EPUBUI 层之下。它提供与 EPUB 内容无关的页面展示、翻页动画、手势识别、页面预加载和双页布局能力。上层通过 `RDEpubReaderPageProvider` 协议提供页面内容视图ReaderView 负责容器管理和翻页调度。
**文件清单14 个 Swift 文件):**
| 文件 | 核心类型 | 职责 |
|------|----------|------|
| `RDEpubReaderView.swift` | `RDEpubReaderView` | 主容器视图,协调所有子组件 |
| `RDEpubReaderViewProtocols.swift` | 协议 + 枚举 | 数据源、代理、导航协议定义 |
| `RDEpubReaderFlowLayout.swift` | `RDEpubReaderFlowLayout` | UICollectionView 自定义布局 |
| `RDEpubReaderGestureController.swift` | `RDEpubReaderGestureController` | 手势控制器(预留) |
| `RDEpubReaderContentCell.swift` | `RDEpubReaderContentCell` | CollectionView 内容 Cell |
| `RDEpubReaderPageChildViewController.swift` | `RDEpubReaderPageChildViewController` | PageCurl 模式子 VC |
| `RDEpubReaderView+PageCurl.swift` | Extension | UIPageViewController 数据源/代理 |
| `RDEpubReaderView+CollectionView.swift` | Extension | UICollectionView 数据源/布局代理 |
| `RDEpubReaderView+ContentAccess.swift` | Extension | 内容视图访问与复用 |
| `RDEpubReaderView+ToolView.swift` | Extension | 工具栏安装与动画 |
| `Paging/RDEpubReaderPagingController.swift` | `RDEpubReaderPagingController` | 翻页状态机 |
| `Paging/RDEpubReaderPreloadController.swift` | `RDEpubReaderPreloadController` | 页面预加载与缓存 |
| `Paging/RDEpubReaderSpreadResolver.swift` | `RDEpubReaderSpreadResolver` | 双页展开计算 |
| `Paging/RDEpubReaderTapRegionHandler.swift` | `RDEpubReaderTapRegionHandler` | 点击区域判定 |
---
## 2. 协议定义RDEpubReaderViewProtocols.swift
### 2.1 RDEpubReaderDataSource旧版数据源已废弃
```swift
@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
@objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
@objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
}
```
> 向后兼容保留,新代码应使用 `RDEpubReaderPageProvider`
### 2.2 RDEpubReaderPageProvider推荐数据源
```swift
@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
func numberOfPages(in readerView: RDEpubReaderView) -> Int
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
}
```
| 方法 | 说明 |
|------|------|
| `numberOfPages(in:)` | 返回总页数 |
| `readerView(_:viewForPageAt:reusableView:)` | 为指定页码提供内容视图,`reusableView` 可复用 |
| `pageIdentifier(in:index:)` | 返回页视图复用标识符,用于 CollectionView 注册 |
| `readerViewTopChrome(_:)` | 返回顶部工具栏视图 |
| `readerViewBottomChrome(_:)` | 返回底部工具栏视图 |
### 2.3 RDEpubReaderDelegate
```swift
@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
func pageNum(readerView: RDEpubReaderView, pageNum: Int)
@objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
}
```
### 2.4 RDEpubReaderPageNavigating
```swift
public protocol RDEpubReaderPageNavigating: AnyObject {
var currentPage: Int { get }
func reloadPages()
func transition(to page: Int, animated: Bool)
}
```
`RDEpubReaderView` 遵循此协议,提供统一的页面导航接口。
### 2.5 枚举类型
```swift
extension RDEpubReaderView {
public enum DisplayType {
case pageCurl // 仿真翻页UIPageViewController
case horizontalScroll // 水平滑动UICollectionView
case verticalScroll // 垂直滚动UICollectionView
}
public enum PageDirection {
case leftToRight // LTR
case rightToLeft // RTL
}
}
```
---
## 3. 核心类RDEpubReaderView
**文件:** `RDEpubReaderView.swift`
`RDEpubReaderView` 是一个 `UIView` 子类,作为翻页容器的主入口。内部管理两种翻页引擎:
- **PageCurl 模式**:使用 `UIPageViewController` 实现仿真翻页
- **Scroll 模式**:使用 `UICollectionView` + 自定义 `RDEpubReaderFlowLayout` 实现滑动翻页
### 3.1 关键属性
| 属性 | 类型 | 说明 |
|------|------|------|
| `currentPage` | `Int` | 当前页码,变化时通知 delegate |
| `currentDisplayType` | `DisplayType` | 当前显示模式 |
| `pageDirection` | `PageDirection` | 页面方向LTR/RTL |
| `landscapeDualPageEnabled` | `Bool` | 是否启用横屏双页 |
| `coverPageIndex` | `Int?` | 封面页索引(独占一屏) |
| `pagesPerScreen` | `Int` | 每屏页数(横屏双页时为 2 |
| `preloadRadius` | `Int` | 预加载半径(默认 1 |
| `dataSource` | `RDEpubReaderDataSource?` | 旧版数据源 |
| `pageProvider` | `RDEpubReaderPageProvider?` | 推荐数据源 |
| `delegate` | `RDEpubReaderDelegate?` | 事件代理 |
| `toolViewAnimationDuration` | `TimeInterval` | 工具栏动画时长0.3s |
### 3.2 关键方法
```swift
/// 切换显示模式pageCurl / horizontalScroll / verticalScroll
public func switchReaderDisplayType(_ displayType: RDEpubReaderView.DisplayType)
/// 跳转到指定页
public func transitionToPage(pageNum: Int, animated: Bool = false)
/// 重新加载所有页面
public func reloadData()
/// 仅重载页数(不重建内容)
public func reloadPageCountOnly()
/// 判断指定页是否为全屏页(封面页独占一屏)
public func isFullScreenPage(_ pageNum: Int) -> Bool
```
### 3.3 内部子组件
| 组件 | 类型 | 职责 |
|------|------|------|
| `pageViewController` | `UIPageViewController` | PageCurl 翻页引擎 |
| `collectionView` | `UICollectionView` | Scroll 翻页引擎 |
| `layout` | `RDEpubReaderFlowLayout` | CollectionView 自定义布局 |
| `spreadResolver` | `RDEpubReaderSpreadResolver` | 双页配对计算 |
| `tapRegionHandler` | `RDEpubReaderTapRegionHandler` | 点击区域判定 |
| `preloadController` | `RDEpubReaderPreloadController` | 页面预加载与缓存 |
| `pagingController` | `RDEpubReaderPagingController` | 翻页状态管理 |
---
## 4. 翻页状态机RDEpubReaderPagingController
**文件:** `Paging/RDEpubReaderPagingController.swift`
管理 PageCurl 模式下的翻页请求队列,防止动画冲突。
```swift
struct RDEpubReaderPagingController {
struct PageTransitionRequest: Equatable {
let pageNum: Int
let animated: Bool
}
var pendingTransitionRequest: PageTransitionRequest? // 待处理请求
var isTransitioning: Bool // 是否正在翻页动画中
var didBuildUI: Bool // UI 是否已构建
/// 判断是否应排队请求PageCurl 模式下动画中返回 true
mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDEpubReaderView.DisplayType) -> Bool
/// 完成翻页动画,返回待处理的请求
mutating func finishPageCurlTransition() -> PageTransitionRequest?
/// 重置状态(用于故障恢复)
mutating func resetPendingState()
}
```
---
## 5. 页面预加载RDEpubReaderPreloadController
**文件:** `Paging/RDEpubReaderPreloadController.swift`
负责在当前页周围预渲染页面视图,减少翻页时的白屏时间。
### 5.1 核心机制
- **缓存签名CacheSignature**:基于 `displayType + isLandscape + pagesPerScreen + boundsSize` 生成签名,签名变化时清空缓存
- **双缓存池**`preloadedPageViews`(预加载池)和 `pageCurlCachedViews`PageCurl 缓存池)
- **预测性预加载**:根据 `preferredForward` 方向多预加载一页
### 5.2 关键方法
```swift
/// 为指定页获取视图(优先从缓存取)
func pageViewForDisplay(pageNum: Int, environment: Environment, contentViewProvider: (Int, UIView?) -> UIView?) -> UIView
/// 在指定页周围预加载
func prime(around pageNum: Int, preferredForward: Bool?, parentView: UIView, environment: Environment, contentViewProvider: (Int, UIView?) -> UIView?)
/// 取走预加载的视图(用于 CollectionView 复用)
func takePreloadedView(for pageNum: Int) -> UIView?
/// 使缓存失效
func invalidate(environment: Environment)
```
### 5.3 Environment 结构体
```swift
struct Environment {
let displayType: RDEpubReaderView.DisplayType
let isLandscape: Bool
let pagesPerScreen: Int
let boundsSize: CGSize
let landscapeDualPageEnabled: Bool
let coverPageIndex: Int?
let totalPages: Int
let spreadResolver: RDEpubReaderSpreadResolver
}
```
---
## 6. 双页展开计算RDEpubReaderSpreadResolver
**文件:** `Paging/RDEpubReaderSpreadResolver.swift`
纯函数式结构体,负责双页模式下的页面配对和导航计算。
```swift
struct RDEpubReaderSpreadResolver {
/// 判断是否为全屏页(封面页独占一屏)
func isFullScreenPage(_ pageNum: Int, landscapeDualPageEnabled: Bool, isLandscape: Bool, coverPageIndex: Int?) -> Bool
/// 计算双页配对left, right?right 为 nil 表示独占一屏
func dualPagePair(for pageNum: Int, totalPages: Int, coverPageIndex: Int?) -> (left: Int, right: Int?)
/// 计算相邻双页的起始页码
func adjacentDualPage(from pageNum: Int, totalPages: Int, coverPageIndex: Int?, forward: Bool) -> Int?
/// 计算下一页(支持单页和双页模式)
func nextPage(from currentPage: Int, totalPages: Int, pagesPerScreen: Int, coverPageIndex: Int?, forward: Bool) -> Int?
}
```
**封面页逻辑:**
- 封面页(`coverPageIndex`)独占一屏,不与其他页配对
- 封面后的页面从 `coverIndex + 1` 开始两两配对
---
## 7. 点击区域判定RDEpubReaderTapRegionHandler
**文件:** `Paging/RDEpubReaderTapRegionHandler.swift`
将屏幕三等分,判定点击属于左/中/右区域。
```swift
struct RDEpubReaderTapRegionHandler {
func resolveTapEvent(point: CGPoint, viewFrame: CGRect, isToolViewVisible: Bool) -> RDEpubReaderView.TapEvent
}
```
**逻辑:**
- 左 1/3 → `.left`(上一页),工具栏可见时改为 `.center`
- 中 1/3 → `.center`(切换工具栏)
- 右 1/3 → `.right`(下一页),工具栏可见时改为 `.center`
---
## 8. 流式布局RDEpubReaderFlowLayout
**文件:** `RDEpubReaderFlowLayout.swift`
`UICollectionViewFlowLayout` 子类,支持水平滚动和垂直滚动两种模式。
### 8.1 关键属性
| 属性 | 类型 | 说明 |
|------|------|------|
| `displayType` | `RDEpubReaderView.DisplayType` | 布局模式 |
| `isLandscapeDualPage` | `Bool` | 是否横屏双页 |
| `coverPageIndex` | `Int?` | 封面页索引 |
| `pagesPerScreen` | `Int` | 每屏页数 |
### 8.2 协议
```swift
public protocol RDEpubReaderFlowLayoutDataSoure: NSObjectProtocol {
func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat?
}
@objc public protocol RDEpubReaderFlowLayoutDelegate: NSObjectProtocol {
func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int)
}
```
### 8.3 关键方法
```swift
/// 计算指定页码的 contentOffset
func currentContentOffset(count: Int) -> CGPoint
```
### 8.4 封面页布局逻辑
双页模式下,封面页占满整屏宽度,后续页面两两配对占半屏宽度。`coverAwareFrame(for:screenWidth:halfWidth:height:)` 方法根据页码计算对应的 frame。
---
## 9. 内容 CellRDEpubReaderContentCell
**文件:** `RDEpubReaderContentCell.swift`
`UICollectionViewCell` 子类,用于 Scroll 模式下承载页面内容视图。
```swift
class RDEpubReaderContentCell: UICollectionViewCell {
var containerView: UIView? // 设置时自动添加到 contentView移除旧视图
}
```
---
## 10. PageCurl 子控制器RDEpubReaderPageChildViewController
**文件:** `RDEpubReaderPageChildViewController.swift`
`UIViewController` 子类,作为 `UIPageViewController` 的页面 VC。
```swift
class RDEpubReaderPageChildViewController: UIViewController {
var contentView: UIView? // 内容视图,设置时自动安装到容器
var pageNum: Int // 对应页码
init(contentView: UIView?, pageNum: Int = 0)
}
```
---
## 11. Extension 汇总
### 11.1 RDEpubReaderView+PageCurl
实现 `UIPageViewControllerDataSource``UIPageViewControllerDelegate`
- `pageViewController(_:viewControllerBefore:)` — 提供前一页 VCRTL 时逻辑反转)
- `pageViewController(_:viewControllerAfter:)` — 提供后一页 VC
- `pageViewController(_:didFinishAnimating:...)` — 完成动画后更新 currentPage
- `pageViewController(_:willTransitionTo:)` — 即将翻页时预加载
**特殊页码:**
- `RDEpubReaderView.blankPageNum``Int.max`)— 双页模式下的空白页
- `RDEpubReaderView.blankEndPageNum``Int.max - 1`)— 末尾空白页
### 11.2 RDEpubReaderView+CollectionView
实现 `UICollectionViewDataSource`、`RDEpubReaderFlowLayoutDelegate`、`RDEpubReaderFlowLayoutDataSoure`
- `collectionView(_:cellForItemAt:)` — 复用预加载视图或创建新 Cell
- `collectionView(_:numberOfItemsInSection:)` — 返回总页数
- `pageNum(flowLayout:pageIndex:)` — 滚动时更新当前页码
### 11.3 RDEpubReaderView+ContentAccess
提供内容视图的注册、复用和查询:
```swift
/// 注册内容视图类型(类似 UICollectionView 的 register
public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String)
/// 获取可复用的内容视图
public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView
/// 获取指定页的内容视图
public func pageContentView(pageNum: Int) -> UIView?
/// 计算单页尺寸(考虑双页模式)
public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize
```
### 11.4 RDEpubReaderView+ToolView
管理顶部/底部工具栏的安装、显示/隐藏动画:
```swift
/// 切换工具栏显示状态(点击中心区域触发)
func tapCenter()
/// 安装工具栏视图到指定位置
func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition)
/// 更新工具栏高度约束
func updateToolViewHeightConstraintsIfNeeded()
```
**动画效果:** 顶部工具栏从上方滑入,底部工具栏从下方滑入。
---
## 12. 设计模式总结
| 模式 | 应用 |
|------|------|
| **策略模式** | `DisplayType` 切换 PageCurl / Scroll 两种翻页策略 |
| **适配器模式** | `RDEpubReaderLegacyDataSourceAdapter` 将旧 `RDEpubReaderDataSource` 适配为 `RDEpubReaderPageProvider` |
| **命令队列** | `RDEpubReaderPagingController` 管理翻页请求队列 |
| **缓存签名** | `RDEpubReaderPreloadController.CacheSignature` 检测环境变化自动失效 |
| **关注点分离** | Extension 将不同功能拆分到独立文件 |
| **纯函数** | `RDEpubReaderSpreadResolver``RDEpubReaderTapRegionHandler` 无状态计算 |
---
## 13. 数据流图
```
用户点击屏幕
RDEpubReaderTapRegionHandler.resolveTapEvent()
├── .left → goPreviousPage() ──→ spreadResolver.nextPage(forward: false)
├── .right → goNextPage() ──→ spreadResolver.nextPage(forward: true)
└── .center → tapCenter() ──→ 显示/隐藏工具栏
transitionToPage(pageNum:)
├── PageCurl 模式
│ ├── pagingController.shouldQueuePageTransition() → 排队或执行
│ ├── pageViewForDisplay() → preloadController 取缓存视图
│ ├── pageViewController.setViewControllers()
│ └── primePageCache() → 预加载周围页面
└── Scroll 模式
├── collectionView.reloadData()
├── collectionView.setContentOffset()
└── primePageCache() → 预加载周围页面
```

View File

@ -1,6 +1,6 @@
# 测试说明
**分析日期:** 2026-06-09(更新)
**分析日期:** 2026-06-22(更新)
## 测试框架与现状
@ -102,6 +102,25 @@ xcodebuild test \
测试报告输出到 `.artifacts/ui-tests/{timestamp}/UI-Test-Report.md`
## 架构整改回归重点
在 2026-06-22 的阅读器架构整改后,以下场景应作为 smoke 回归最小集合:
- 打开大书后首次进入阅读页
- 章节尾页连续翻页,确认跨章节动画无明显卡顿
- 目录远跳到未预热章节,再返回当前阅读流
- 关闭并重新打开书籍,恢复上次阅读位置
- 长按选区、高亮、批注后再次翻页
- 打开设置面板调整字号/间距,观察预览与最终全量 repagination
建议同时记录以下观测项:
- `prepareOnDemandChapter` 主线程 wall clock
- `RDEpubReaderPreloadController` 预加载命中率
- `bookPageMap` partial extension / full replacement 次数
- 页面静态底图缓存命中率
- CFI 延迟构建完成次数与耗时
## 覆盖率Coverage
- 当前未启用代码覆盖率收集

349
Doc/TYPESetter_PIPELINE.md Normal file
View File

@ -0,0 +1,349 @@
# 排版管线详解
> 最后更新2026-06-18
本文档详细描述 ReadViewSDK 文本排版管线Typesetter Pipeline的 8 个处理阶段、核心算法和数据流。
---
## 1. 概述
排版管线是 EPUBTextRendering 层的核心组件,负责将 EPUB 原始 HTML 转换为可分页的 NSAttributedString。它是 textReflowable 渲染路径(本 SDK 核心路径)的关键环节。
**入口**`RDEPUBTextTypesetterPipeline.makeRequest(from:)`
**输入**`RDEPUBTypesettingInput`(原始 HTML、样式、布局配置
**输出**`RDEPUBTypesettingOutput`(渲染请求、诊断信息、兼容性报告)
**关键文件**`Sources/RDEpubReaderView/EPUBTextRendering/Typesetter/`
---
## 2. 管线总览
```
Raw HTML (从 EPUB 解压目录读取)
▼ [阶段 1] RDEPUBHTMLNormalizer
│ 去 CR、合并空行、规范化附件 HTML
▼ [阶段 2] RDEPUBSemanticMarkerInjector
│ 注入分页语义标记 ${rd-sem-start/end}
▼ [阶段 3] RDEPUBCFIMarkerInjector
│ 注入 CFI 路径标记
▼ [阶段 4] RDEPUBStyleSheetComposer
│ 内联 <link stylesheet>、构建 5 层 CSS、注入 <base href>
▼ [阶段 5] RDEPUBFontNormalizer
│ 解析 @font-face、注册嵌入字体
▼ [阶段 6] RDEPUBFragmentMarkerInjector
│ 注入 fragment 锚点标记 ${id=xxx}
▼ [阶段 7] RDEPUBRenderDiagnosticsCollector
│ 收集图片诊断、资源引用检查
▼ 构建 RDEPUBTextChapterRenderRequest
▼ [阶段 8] RDEPUBDTCoreTextRenderer
HTML → NSAttributedString → 后处理 → 分页
```
---
## 3. 阶段 1HTML 规范化RDEPUBHTMLNormalizer
**文件**`RDEPUBHTMLNormalizer.swift`
### 3.1 处理内容
| 规则 | 说明 |
|------|------|
| CR → LF | 统一换行符 |
| 合并连续空行 | 多个空行合并为一个 |
| 删除分页标记 | `<hr lang="zh-CN">分页符</hr>` |
| 附件 HTML 规范化 | 见下表 |
### 3.2 附件 HTML 规范化规则
| 原始 HTML | 规范化结果 |
|-----------|-----------|
| `div.qrbodyPic / div.bodyPic` | 合并样式到 `<img>` |
| `img.qqreader-footnote` | 行内 1em×1em |
| `h1.frontCover > img` | 封面图片 100% 宽度 |
### 3.3 Base URL 注入
`<head>` 开头注入 `<base href="...">` 用于相对路径解析:
```swift
static func injectBaseHref(into html: String, baseURL: URL?) -> String
```
---
## 4. 阶段 2语义标记注入RDEPUBSemanticMarkerInjector
**文件**`RDEPUBSemanticMarkerInjector.swift`
### 4.1 标记语法
```html
${rd-sem-start:id=X;block=...;hints=...;placement=...}
...内容...
${rd-sem-end:id=X}
```
### 4.2 注入规则
- 遍历所有 HTML 标签,维护开标签栈
- 为有分页语义的标签注入标记:
- `block`块类型paragraph、heading、list 等)
- `hints`分页提示avoidPageBreakInside、keepWithNext 等)
- `placement`附件位置inline、block
- 空标签img, br, hr同时注入开始和结束标记
### 4.3 语义提示类型
| 提示 | 说明 |
|------|------|
| `avoidPageBreakInside` | 避免在块内分页 |
| `keepWithNext` | 与下一个块保持同页 |
| `attachmentBlock` | 块级附件(图片等) |
---
## 5. 阶段 3CFI 标记注入RDEPUBCFIMarkerInjector
**文件**`RDEPUBCFIMarkerInjector.swift`
注入 CFI 路径标记,用于后续 CFI 映射构建。在每个文本节点前注入 CFI 路径信息,使渲染后的 NSAttributedString 能够建立 CFI 路径到文本偏移量的映射。
---
## 6. 阶段 4CSS 层合成RDEPUBStyleSheetComposer
**文件**`RDEPUBStyleSheetComposer.swift`
### 6.1 五层 CSS 架构
按优先级从低到高:
| 层 | Kind | 说明 | 注入位置 |
|----|------|------|----------|
| 1 | `.default` | 基础阅读器样式(隐藏 head/title/style默认字体大小 | `<head>` 开头 |
| 2 | `.replace` | 格式化样式(代码块、标题、引用、列表) | `<head>` 开头 |
| 3 | `.dark` | 暗色模式覆盖(仅暗色主题时注入) | `<head>` 开头 |
| 4 | `.epub` | EPUB 自带样式(内联后的 `<link stylesheet>` | `<head>` 末尾 |
| 5 | `.user` | 用户设置(字号、行距、颜色,!important | `<head>` 末尾 |
### 6.2 语言检测
```swift
static func prefersLatinLanguageCSS(
languageCode: String?,
sourceHTML: String
) -> Bool
```
检测逻辑:
1. 检查 `lang` 属性(如 `lang="en"`
2. 对 HTML 文本采样,统计拉丁字符比例
3. 拉丁语言使用专用 CSS`wxread-replace-latin.css`
### 6.3 样式表内联
`RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets()` 负责:
1. 查找 `<link rel=stylesheet href=...>`
2. 读取 CSS 文件内容
3. 重写 CSS 中的相对 `url()` 引用
4. 内联到 HTML 的 `<head>`
---
## 7. 阶段 5字体注册RDEPUBFontNormalizer
**文件**`RDEPUBFontNormalizer.swift`
### 7.1 处理流程
1. 解析 `@font-face { url(...) }`
2. 提取字体文件路径和 family 名称
3. 通过 `CTFontManagerRegisterFontsForURL(.process)` 注册嵌入字体
4. 已注册字体路径缓存在 `registeredFontPaths` 集合中,避免重复注册
### 7.2 注册结果
```swift
struct RDEPUBFontRegistrationResult {
let descriptor: RDEPUBFontDescriptor
let didRegister: Bool
let errorDescription: String?
}
```
注册失败的字体不会阻断管线,但会记录到兼容性报告中。
---
## 8. 阶段 6Fragment 标记注入RDEPUBFragmentMarkerInjector
**文件**`RDEPUBFragmentMarkerInjector.swift`
扫描 HTML 中的 `id` 属性,在其前面注入 `${id=xxx}` 标记:
```html
<h2 id="section1">标题</h2>
${id=section1}<h2 id="section1">标题</h2>
```
渲染后这些标记会被转换为 NSAttributedString 属性,用于 fragment 偏移量提取。
---
## 9. 阶段 7诊断收集RDEPUBRenderDiagnosticsCollector
**文件**`RDEPUBRenderDiagnosticsCollector.swift`
### 9.1 图片诊断
- 扫描 `<img src="...">` 标签
- 解析引用路径,检查文件是否存在
- 收集诊断信息用于调试
### 9.2 资源引用检查
- 检查 CSS 中的 `url()` 引用
- 验证字体文件是否存在
- 记录缺失资源的诊断信息
---
## 10. 阶段 8渲染与后处理RDEPUBDTCoreTextRenderer
**文件**`RDEPUBDTCoreTextRenderer.swift`
### 10.1 HTML → NSAttributedString
```swift
func renderChapter(request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent
```
1. 将 HTML 编码为 `Data`
2. 使用 `DTHTMLAttributedStringBuilder` 构建 `NSAttributedString`
3. 在 `willFlushCallback` 中对每个 DOM 元素调用 `RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering()`
### 10.2 后处理
**applyPaginationSemantics()**
- 将 `${rd-sem-start/end}` 标记转为 NSAttributedString 属性
- 属性键:`.rdPageSemanticHints`、`.rdPageBlockKind`、`.rdPageAttachmentPlacement`
**extractFragmentOffsets()**
- 提取 `${id=xxx}` 标记
- 生成 fragment ID → 字符偏移量映射
- 删除标记文本
**normalizeReadingAttributes()**
- 规范化字体(应用用户选择的字体)
- 调整行距(应用 lineHeightMultiple
- 设置文字颜色(应用主题颜色)
- 处理附件(图片缩放、对齐)
---
## 11. 分页计算
### 11.1 RDEPUBChapterPageCounter
**文件**`Pagination/RDEPUBChapterPageCounter.swift`
使用 CoreText 迭代分页:
```swift
func layoutFrames(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame]
```
**分页循环**
```
location = 0
while location < totalLength:
1. 创建 CTFrame通过 CTFramesetter
2. 获取可见范围 (CTFrameGetVisibleStringRange)
3. 应用 avoidPageBreakInside 规则(最多移除 3 行尾部)
4. 应用 keepWithNext 规则(最多移除 3 行尾部)
5. 应用 widow/orphan 控制
6. 应用 pageBreakPolicy 调整
7. 记录页面范围
8. location = 调整后的范围末尾
```
### 11.2 RDEPUBPageBreakPolicy
**文件**`Pagination/RDEPUBPageBreakPolicy.swift`
分页规则优先级:
| 规则 | 说明 | 最大调整行数 |
|------|------|-------------|
| `avoidPageBreakInside` | 块内不分页(标题、图片等) | 3 行 |
| `keepWithNext` | 标题与正文不分离 | 3 行 |
| widow control | 段落最后一行不留到下一页 | 1 行 |
| orphan control | 段落第一行不单独在上一页 | 1 行 |
| attachment boundary | 块级图片前后分页 | 0精确切分 |
**判断方法**
```swift
func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool
func adjustedRange(from:totalLength:lineRanges:factory:) -> (range, breakReason, ...)
```
### 11.3 RDEPUBChapterTailNormalizer
**文件**`BuildPipeline/RDEPUBChapterTailNormalizer.swift`
三遍处理:
1. **删除空白中间帧**:无可见字符且无附件的帧
2. **删除空白尾部帧**:从末尾开始删除同类空白帧
3. **合并短尾帧**:如果最后一帧 ≤2 个可见字符且前一帧 ≥8 倍长,合并
---
## 12. 诊断与调试
### 12.1 兼容性报告
```swift
struct RDEPUBCSSCompatibilityReport {
let unsupportedRules: [String] // 不支持的 CSS 规则
let normalizedRules: [String] // 已规范化的规则
let fontFailures: [String] // 字体注册失败
}
```
### 12.2 资源诊断
```swift
struct RDEPUBTextResourceReferenceDiagnostic {
let href: String // 引用路径
let exists: Bool // 文件是否存在
let type: String // 资源类型image/font/stylesheet
}
```
### 12.3 语义摘要
`RDEPUBReaderController.nativeTextSemanticSummary()` 返回当前页面的语义摘要,包含:
- 页码
- 分页原因breakReason
- 块类型blockKinds
- 语义提示semanticHints
- 附件位置attachmentPlacements

View File

@ -31,12 +31,12 @@ graph TB
IndexTable[RDEPUBTextIndexTable]
end
subgraph RDReaderView
ReaderView[RDReaderView]
FlowLayout[RDReaderFlowLayout]
Preload[RDReaderPreloadController]
Spread[RDReaderSpreadResolver]
TapRegion[RDReaderTapRegionHandler]
subgraph RDEpubReaderView
ReaderView[RDEpubReaderView]
FlowLayout[RDEpubReaderFlowLayout]
Preload[RDEpubReaderPreloadController]
Spread[RDEpubReaderSpreadResolver]
TapRegion[RDEpubReaderTapRegionHandler]
end
subgraph EPUBUI
@ -297,11 +297,11 @@ classDiagram
```mermaid
classDiagram
class RDReaderView {
class RDEpubReaderView {
+currentPage: Int
+currentDisplayType: DisplayType
+pageProvider: RDReaderPageProvider?
+delegate: RDReaderDelegate?
+pageProvider: RDEpubReaderPageProvider?
+delegate: RDEpubReaderDelegate?
+landscapeDualPageEnabled: Bool
+pageDirection: PageDirection
+coverPageIndex: Int?
@ -318,14 +318,14 @@ classDiagram
verticalScroll
}
class RDReaderPageProvider {
class RDEpubReaderPageProvider {
<<protocol>>
+numberOfPages(in:) Int
+readerView(_:viewForPageAt:reusableView:) UIView
+pageIdentifier(in:index:) String?
}
class RDReaderFlowLayout {
class RDEpubReaderFlowLayout {
+displayType: DisplayType
+isLandscapeDualPage: Bool
+coverPageIndex: Int?
@ -333,7 +333,7 @@ classDiagram
+currentPage: Int
}
class RDReaderPreloadController {
class RDEpubReaderPreloadController {
+radius: Int
+pageViewForDisplay(pageNum:environment:contentViewProvider:) UIView?
+takePreloadedView(for:) UIView?
@ -341,13 +341,13 @@ classDiagram
+invalidate(environment:)
}
class RDReaderSpreadResolver {
class RDEpubReaderSpreadResolver {
+isFullScreenPage(...) Bool
+dualPagePair(for:totalPages:coverPageIndex:) (Int, Int?)
+nextPage(from:totalPages:pagesPerScreen:coverPageIndex:forward:) Int?
}
class RDReaderTapRegionHandler {
class RDEpubReaderTapRegionHandler {
+resolveTapEvent(point:viewFrame:isToolViewVisible:) TapEvent
}
@ -359,7 +359,7 @@ classDiagram
right
}
class RDReaderPagingController {
class RDEpubReaderPagingController {
+isTransitioning: Bool
+didBuildUI: Bool
+pendingTransitionRequest: PageTransitionRequest?
@ -367,25 +367,25 @@ classDiagram
+finishPageCurlTransition() PageTransitionRequest?
}
class RDReaderContentCell {
class RDEpubReaderContentCell {
+containerView: UIView?
}
class RDReaderPageChildViewController {
class RDEpubReaderPageChildViewController {
+contentView: UIView?
+pageNum: Int
}
RDReaderView --> DisplayType : uses
RDReaderView --> RDReaderPageProvider : delegates
RDReaderView --> RDReaderFlowLayout : owns
RDReaderView --> RDReaderPreloadController : owns
RDReaderView --> RDReaderSpreadResolver : owns
RDReaderView --> RDReaderTapRegionHandler : owns
RDReaderView --> RDReaderPagingController : owns
RDReaderView --> RDReaderContentCell : creates
RDReaderView --> RDReaderPageChildViewController : creates
RDReaderTapRegionHandler --> TapEvent : produces
RDEpubReaderView --> DisplayType : uses
RDEpubReaderView --> RDEpubReaderPageProvider : delegates
RDEpubReaderView --> RDEpubReaderFlowLayout : owns
RDEpubReaderView --> RDEpubReaderPreloadController : owns
RDEpubReaderView --> RDEpubReaderSpreadResolver : owns
RDEpubReaderView --> RDEpubReaderTapRegionHandler : owns
RDEpubReaderView --> RDEpubReaderPagingController : owns
RDEpubReaderView --> RDEpubReaderContentCell : creates
RDEpubReaderView --> RDEpubReaderPageChildViewController : creates
RDEpubReaderTapRegionHandler --> TapEvent : produces
```
---
@ -403,7 +403,7 @@ classDiagram
+highlights: [RDEPUBHighlight]
+bookmarks: [RDEPUBBookmark]
+tableOfContents: [EPUBTableOfContentsItem]
+readerView: RDReaderView
+readerView: RDEpubReaderView
+readerContext: RDEPUBReaderContext
+runtime: RDEPUBReaderRuntime?
}
@ -441,10 +441,20 @@ classDiagram
class RDEPUBReaderPaginationCoordinator {
-context: RDEPUBReaderContext
+paginatePublication(restoreLocation:)
+paginateMetadataOnly(token:restoreLocation:)
+repaginatePreservingCurrentLocation()
-restoreBookPageMapIfPossible(publication:) RDEPUBBookPageMap?
-buildPageMap(from:summaries:) RDEPUBBookPageMap
}
class RDEPUBMetadataParseWorker {
-context: RDEPUBReaderContext
-cancellationController: RDEPUBMetadataParseCancellationController
+start(token:restoreLocation:)
}
class RDEPUBMetadataParseCancellationController {
+token: UUID
+attach(queue:)
+cancel()
+isCancelled: Bool
}
class RDEPUBReaderLoadCoordinator {
@ -660,7 +670,7 @@ sequenceDiagram
participant OQ as OperationQueue (N workers)
participant Disk as Disk Cache
Main->>BG: paginateMetadataOnly(token)
Main->>BG: RDEPUBMetadataParseWorker.start(token)
BG->>BG: 预计算 contentHash (串行)
BG->>Disk: readAll(catalog) 批量读取缓存
BG->>Main: refreshBookPageMapInPlace (缓存部分)

View File

@ -1,6 +1,6 @@
# ReadViewSDK 文档索引
> 最后更新2026-06-12
> 最后更新2026-06-18
---
@ -12,6 +12,15 @@
| [UML_CLASS_DIAGRAMS.md](UML_CLASS_DIAGRAMS.md) | UML 类图Mermaid 格式):模块关系、核心类图、并发模型、缓存键设计 |
| [BUSINESS_LOGIC.md](BUSINESS_LOGIC.md) | 业务逻辑详解EPUB 解析、文本渲染管线、后台解析优化、翻页容器、标注系统、搜索、设置 |
## 代码级参考文档
| 文档 | 说明 |
|------|------|
| [EPUBCore_CODE_REFERENCE.md](EPUBCore_CODE_REFERENCE.md) | EPUBCore 模块代码级文档解析器、Publication、WebView、JS 桥接、CFI、搜索、资源解析~50 文件) |
| [EPUBTextRendering_CODE_REFERENCE.md](EPUBTextRendering_CODE_REFERENCE.md) | EPUBTextRendering 模块代码级文档:排版管线、文本渲染、分页引擎、构建管线、索引表(~30 文件) |
| [EPUBUI_CODE_REFERENCE.md](EPUBUI_CODE_REFERENCE.md) | EPUBUI 模块代码级文档:阅读器控制器、协调器、章节运行时、设置、文本页面、标注(~60 文件) |
| [ReaderView_CODE_REFERENCE.md](ReaderView_CODE_REFERENCE.md) | ReaderView 模块代码级文档翻页容器、预加载、双页布局、手势、流式布局14 文件) |
## 工程规范与风险
| 文档 | 说明 |
@ -19,19 +28,27 @@
| [CONCERNS.md](CONCERNS.md) | 代码库风险与关注点12 项安全/性能/可维护性风险 |
| [TESTING.md](TESTING.md) | 测试基础设施UI 测试文件清单、运行方式、覆盖率 |
| [CONVENTIONS.md](CONVENTIONS.md) | 编码规范:命名约定、代码风格、导入规范、错误处理 |
| [ARCHITECTURE-CONTEXT.md](ARCHITECTURE-CONTEXT.md) | WXRead vs ReadViewSDK 架构差距分析4 个领域、14 项决策 |
## 专题文档
| 文档 | 说明 |
|------|------|
| [TYPESetter_PIPELINE.md](TYPESetter_PIPELINE.md) | Typesetter 排版管线详解HTML 规范化、语义标记注入、CFI 标记、样式合成为、字体规范化、片段标记 |
| [CHAPTER_RUNTIME.md](CHAPTER_RUNTIME.md) | 章节运行时详解:按需加载、章节窗口协调、页图管理、磁盘缓存、后台补全 |
| [CFI_SUBSYSTEM.md](CFI_SUBSYSTEM.md) | CFI 子系统详解EPUB CFI 解析、生成、序列化、范围、恢复引擎 |
| [CFI_ISSUES_REVIEW.md](CFI_ISSUES_REVIEW.md) | CFI 实现问题分析报告11 个问题3 高 / 5 中 / 3 低),含修复优先级建议 |
| [PAGINATION_ISSUES_2026-07-06.md](PAGINATION_ISSUES_2026-07-06.md) | 分页问题调查记录:脚注图标 avoid-trim 裁行(已修复)+页范围/显示度量错配行中断页(待复现),含调试命令备忘 |
| [LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md](LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md) | 长章节内存优化低风险实施方案:在保留整章上下文布局正确性的前提下,分阶段收敛整章副本、共享 layouter、治理缓存与图片内存 |
| [API_REFERENCE.md](API_REFERENCE.md) | 公开 API 参考RDEPUBReaderController 公开接口、委托协议、配置模型 |
| [当前阅读器问题修复开发清单.md](当前阅读器问题修复开发清单.md) | 基于当前代码实现整理的逐任务开发计划10 个任务、修改位置、实施步骤、风险点与验收标准 |
| [大书远距目录跳转与后台补全优化方案.md](大书远距目录跳转与后台补全优化方案.md) | 面向大书按需分页模式的完整优化方案:远距目录跳转、后台补全优先级、页图接管协议与分阶段实施路径 |
| [标准级EPUB定位与兼容能力开发蓝图.md](标准级EPUB定位与兼容能力开发蓝图.md) | 面向商业级 EPUB 阅读器的可执行蓝图:完整版 CFI、无 id 节点双向恢复、脚注弹层、字体回退与 CSS 兼容层 |
| [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s |
## 项目信息
- **模块总数:** 4 个EPUBCore、EPUBTextRendering、RDReaderView、EPUBUI
- **Swift 文件数:** 139 个SDK Sources
- **模块总数:** 4 个EPUBCore、EPUBTextRendering、RDEpubReaderView、EPUBUI
- **Swift 文件数:** 142SDK Sources
- **测试用例数:** 23 个测试类,约 99 个测试方法UI 测试)
- **最低 iOS 版本:** 15.6
- **构建方式:** CocoaPods本地 pod

View File

@ -1,757 +0,0 @@
# 当前阅读器问题修复开发清单
> 最后更新2026-06-13
> 依据说明:本清单仅基于当前仓库代码实现整理,不以现有说明文档是否准确为前提。
---
## 1. 文档目标
本文将“当前阅读器还存在的问题”展开为可执行的逐任务开发清单,目标是把工作从“方向建议”落到“具体改哪里、怎么改、如何验收”。
本文聚焦以下四类问题:
1. 核心交互链路不稳定:选区、高亮、书签、工具栏状态分散。
2. 默认安全策略偏弱:外链、解压、日志、持久化默认行为需要收口。
3. 资源与分页稳定性不足:大资源内存峰值、离屏分页 WebView 边界控制不足。
4. SDK 契约与回归手段不足:默认 no-op 容易误用,自动化观测点不够结构化。
---
## 2. 总体实施顺序
建议按以下顺序执行,减少返工:
1. `bookmark-chrome-state-unification`
2. `selection-state-refactor`
3. `external-link-policy-hardening`
4. `epub-archive-path-validation`
5. `webview-debug-sanitization`
6. `persistence-defaults-hardening`
7. `resource-scheme-streaming-and-limits`
8. `pagination-webview-hardening`
9. `cache-hardening`
10. `sdk-contract-and-test-hardening`
原因:
- 任务 2 是已确认的重复 UI 状态写入问题,收益最高、风险相对低,应优先处理。
- 任务 1 更偏“状态模型收口优化”,问题真实存在,但严重度低于任务 2。
- 第 3-6 项收口默认安全行为,改动局部、收益高。
- 第 7-9 项涉及资源与分页底层,副作用更大,放在前面稳定后更容易验证。
- 最后一项负责把前面改动固化为稳定契约和测试基线。
---
## 3. 任务清单
## 任务 1`selection-state-refactor`
### 3.1 目标
在不破坏当前清晰分层的前提下,收口选区相关状态的冗余表达,降低 view 层与 controller 层各持有一份选区状态所带来的维护成本和时序问题。
### 3.2 主要问题代码
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift`
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
### 3.3 修改方案
当前代码并不是“混乱散写”,而是相对清晰的分层流:
- gesture
- view
- coordinator
- context/controller
其中真正值得优化的点主要有两个:
1. view 层和 controller 层各自持有一份 `currentSelection`
2. `menuSelection` 是为 `UIMenuController` 时序保底引入的 workaround属于合理存在但可以尝试被更明确的状态模型替代
新增一个统一的选区状态模型仍然是可行方案,例如:
```swift
enum RDEPUBSelectionState: Equatable {
case idle
case selecting(anchor: Int)
case selected(RDEPUBSelection)
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
}
```
建议新增文件:
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift`
建议在 `RDEPUBReaderAnnotationCoordinator` 内新增或抽出一个单一入口:
- `applySelectionState(_ state: RDEPUBSelectionState)`
- `currentSelectionState`
具体修改:
1. `RDEPUBTextSelectionController`
- 移除“自己就是最终状态源”的职责。
- 保留字符范围计算与 `RDEPUBSelection?` 构建。
- `isSelecting` 可保留为手势内部临时状态,但不再作为 UI 的最终依据。
2. `RDEPUBTextContentView`
- 不再直接通过 `currentSelection != nil` 作为所有 UI 行为的唯一判断条件。
- 长按、拖拽、结束只上报“开始选择 / 更新选择 / 结束选择 / 取消选择”事件。
- `menuSelection` 不建议直接删除。
- 第一版应先保留 `menuSelection`,并把它明确标注为菜单时序缓冲状态。
- 待状态模型稳定后,再评估是否能安全移除。
3. `RDEPUBReaderAnnotationCoordinator.updateCurrentSelection(_:)`
- 改造成 `applySelectionState(_:)`
- 在 `.selected` 时统一:
- 更新 `controller.currentSelection`
- 决定是否展示工具栏
- 更新底部高亮按钮可用性
- 通知 delegate
- 在 `.idle` 时统一清理:
- 清空 `controller.currentSelection`
- 关闭菜单
- 刷新按钮状态
4. `RDEPUBReaderController`
- `currentSelection` 继续保留为公开只读语义,但底层来源改为 `selectionState` 推导,而不是多处直接赋值。
### 3.4 实施步骤
1. 新增 `RDEPUBSelectionState.swift`
2. 在 `RDEPUBReaderAnnotationCoordinator` 中接管选区状态
3. 重写 `RDEPUBTextContentView` 中选区相关回调
4. 先收口零散的 `currentSelection != nil` 控制逻辑
5. 保留 `menuSelection`,待验证不再需要后再移除
6. 统一 delegate 通知路径
### 3.5 风险点
- 选区菜单弹出时机可能变化。
- `menuSelection` 是现有时序补丁,若贸然删除,菜单动作可能拿不到正确 selection。
- Web 路径和 Native Text 路径都要走统一状态流,避免只修一条。
### 3.6 验收标准
- 长按后必然进入“选区中”状态。
- 松手后有有效文本时必然进入“已选中”状态。
- 复制/高亮/批注后必然回到空闲状态。
- `selection=1` 的 Demo 状态与按钮启用状态始终一致。
### 3.7 优先级修正
本任务应视为“结构优化 + 降低后续维护成本”,不是当前代码库里最严重的缺陷。建议优先级下调到与任务 2 同级,排在任务 2 之后实施。
---
## 任务 2`bookmark-chrome-state-unification`
### 3.8 目标
统一顶部书签按钮、底部书签按钮、高亮按钮、添加高亮按钮的可用状态计算,避免不同协调器各自改一部分 UI。
### 3.9 主要问题代码
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift`
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift`
### 3.10 修改方案
新增统一 UI 状态模型:
```swift
struct RDEPUBReaderUIState {
let canToggleBookmark: Bool
let hasBookmarkAtCurrentLocation: Bool
let canShowBookmarks: Bool
let canAddHighlight: Bool
let canShowHighlights: Bool
}
```
建议新增文件:
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift`
具体修改:
1. `RDEPUBReaderChromeCoordinator`
- 新增 `makeUIState()``applyUIState(_:)`
- `updateReaderChrome()` 内不再直接散写多个 `setXxxEnabled`
2. `RDEPUBReaderAnnotationCoordinator`
- `updateBookmarkChrome()` 不再直接写 view改为触发 `context.updateReaderChrome()`
3. `RDEPUBReaderTopToolView` / `RDEPUBReaderBottomToolView`
- 保持 dumb view只接收状态不参与规则判断
### 3.11 实施步骤
1. 新增 `RDEPUBReaderUIState.swift`
2. 改造 `updateReaderChrome()`
3. 不要简单删除 `AnnotationCoordinator` 内的按钮状态刷新
4. 区分两类触发时机:
- `ChromeCoordinator` 负责整页/整轮 chrome 同步
- `AnnotationCoordinator` 负责用户操作后的即时反馈触发
5. 将二者统一收口到同一个状态计算入口,例如 `context.updateReaderChrome()`
6. 调整书签新增/删除/跳转后的刷新入口为统一 `updateReaderChrome()`
### 3.12 风险点
- 如果简单删除 `AnnotationCoordinator` 内的调用,可能丢失“用户操作后立即反馈”的刷新时机。
- 如果某些按钮当前依赖副作用显示,需要顺便梳理显示时机。
### 3.13 验收标准
- 添加第一个书签后,底部书签列表按钮立即可用。
- 删除最后一个书签后,底部书签列表按钮立即禁用。
- 当前位置已加书签时,顶部书签按钮始终是选中态。
---
## 任务 3`external-link-policy-hardening`
### 3.14 目标
把“外链点击后直接打开系统应用”改成受控策略,避免 SDK 默认行为过于激进。
### 3.15 主要问题代码
- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
- `Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift`
### 3.16 修改方案
`RDEPUBReaderConfiguration` 新增:
- `allowedExternalURLSchemes: Set<String>`
- `requiresExternalLinkConfirmation: Bool`
- 可选:`allowedExternalURLHosts: Set<String>?`
默认值建议:
- scheme 仅允许 `https`
- 需要确认框
具体修改:
1. `RDEPUBWebView+JavaScriptBridge.swift`
- 继续识别外链,但不再表示“允许直接打开”。
- 只负责把外链事件上抛。
2. `RDEPUBReaderController+ContentDelegates.swift`
- 新增私有方法:
- `shouldAllowExternalURL(_:)`
- `presentExternalLinkConfirmation(for:)`
- `openExternalURLIfAllowed(_:)`
- `didActivateExternalLink` 改为:
- 先 delegate 回调
- 再走配置校验
- 再弹确认框
- 最后调用 `UIApplication.shared.open`
3. 可选扩展 `RDEPUBReaderDelegate`
- 增加可选拦截钩子:
- `epubReader(_:shouldOpenExternalURL:) -> Bool`
### 3.17 实施步骤
1. 改配置模型与默认值
2. 改控制器外链处理逻辑
3. 增加确认对话框
4. 增加测试用 mock URL 覆盖 `https`、`mailto`、未知 scheme`
### 3.18 风险点
- 某些 Demo 书可能依赖 `mailto``tel`,要明确它们现在默认不再直接打开。
### 3.19 验收标准
- `https` 外链在确认后打开。
- 默认配置下 `mailto``tel` 不直接打开。
- 未知 scheme 一律拒绝。
---
## 任务 4`epub-archive-path-validation`
### 3.20 目标
为 EPUB 解压路径增加显式安全校验,防止恶意压缩包通过相对路径写出解压根目录。
### 3.21 主要问题代码
- `Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`
### 3.22 修改方案
新增私有方法:
- `validatedExtractionDestination(for:extractionRoot:) -> URL?`
校验规则:
1. 拒绝绝对路径。
2. 拒绝包含 `..` 的路径段。
3. `standardizedFileURL.path` 必须位于 `extractionRoot.standardizedFileURL.path` 下。
具体修改:
1. `extractArchiveIfNeeded(epubURL:)`
- 在循环内先调用 `validatedExtractionDestination`
- 无效 entry 可:
- 直接抛错终止解析,或
- 记日志并跳过
- 建议优先抛错,行为更清晰
2. 增加自定义错误:
- 若当前 `RDEPUBParserError` 里没有合适 case可新增 `invalidArchiveEntryPath(String)`
### 3.23 实施步骤
1. 增加路径校验方法
2. 替换原始 `destinationURL` 生成逻辑
3. 增加针对恶意 entry path 的单元测试
### 3.24 风险点
- 少数历史 EPUB 可能带奇怪路径分隔符,需要兼容性验证。
### 3.25 验收标准
- 正常 EPUB 仍可正常解压。
- 带 `../` 的恶意 entry 会被拒绝。
---
## 任务 5`webview-debug-sanitization`
### 3.26 目标
减少默认调试日志泄露阅读内容的风险,并关闭不必要的 inspectable 默认值。
### 3.27 主要问题代码
- `Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift`
### 3.28 修改方案
1. `RDEPUBWebViewDebug`
- `logMessage` 改为只打印:
- message name
- 字段名列表
- 文本长度
- URL 摘要
- 不再打印完整 message body
2. 新增更细粒度配置
- `RDEPUBWebViewDebugEnabled`
- `RDEPUBVerboseWebViewDebugEnabled`
3. `RDEPUBPaginator` 与正常阅读 WebView
- `isInspectable` 改为受配置控制
- 默认关闭
4. `RDEPUBReaderConfiguration`
- 增加:
- `allowsInspectableWebViews`
- `enablesVerboseWebViewLogging`
### 3.29 实施步骤
1. 改日志输出格式
2. 改 inspectable 的默认逻辑
3. 为 verbose 模式保留调试后门
### 3.30 风险点
- 调试某些 JS 问题时信息会变少,因此要保留显式 verbose 开关。
### 3.31 验收标准
- 默认 DEBUG 包不输出完整选中文本。
- 默认分页 WebView 不可 inspect。
- 打开 verbose 开关后仍能深度调试。
---
## 任务 6`persistence-defaults-hardening`
### 3.32 目标
降低默认 `UserDefaults` 持久化的误用风险和数据膨胀风险,同时不破坏当前 API 可用性。
### 3.33 主要问题代码
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
### 3.34 修改方案
短期目标:
1. 明确 `RDEPUBUserDefaultsPersistence` 是轻量实现。
2. 高亮持久化优先依赖:
- `location`
- `rangeInfo`
- `style`
- `note`
3. `text` 字段作为冗余缓存,而不是唯一恢复依据。
中期可选目标:
新增文件持久化实现:
- `Sources/RDReaderView/EPUBUI/RDEPUBFilePersistence.swift`
### 3.35 具体修改
1. `RDEPUBReaderPersistence.swift`
- 为默认实现增加 DEBUG 警告,提示 bookmarks/settings 默认实现是 no-op
2. `RDEPUBUserDefaultsPersistence`
- 增加大小保护,例如当高亮集合序列化后超阈值时打印告警
- 可选增加版本字段,便于后续迁移
3. `RDEPUBReaderController`
- 初始化默认 persistence 时,在注释与命名上明确这是默认轻量实现
### 3.36 实施步骤
1. 增加 warning/assert 机制
2. 优化高亮持久化字段策略
3. 视时间增加 `RDEPUBFilePersistence`
### 3.37 风险点
- 如果外部代码依赖 `text` 永远存在,需要做好兼容。
### 3.38 验收标准
- 未实现书签持久化时DEBUG 下能看到明确提示。
- 高亮即使 `text` 丢失,也可基于范围信息恢复定位。
---
## 任务 7`resource-scheme-streaming-and-limits`
### 3.39 目标
降低 `WKURLSchemeHandler` 对大资源的内存冲击,避免每次都整块 `Data(contentsOf:)` 读入。
### 3.40 主要问题代码
- `Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
### 3.41 修改方案
分两步做:
第一步,先加大小阈值和告警:
- 读取文件属性获取大小
- 超过阈值时走分块发送
第二步,再补流式发送:
- 使用 `FileHandle``InputStream`
- 按固定 chunk size 反复 `didReceive(data)`
建议新增私有方法:
- `resourceMetadata(for:)`
- `respondWithStreaming(fileURL:requestURL:taskID:urlSchemeTask:)`
- `respondWithInMemoryData(...)`
### 3.42 实施步骤
1. 先加文件大小检测
2. 小文件保留内存直读
3. 大文件切换到分块发送
4. 增加取消任务时的中断判断
### 3.43 风险点
- `WKURLSchemeHandler` 分块发送要注意 stop 之后不继续回调。
### 3.44 验收标准
- 大图片/字体加载时内存峰值下降。
- 取消任务不会继续发送数据。
---
## 任务 8`pagination-webview-hardening`
### 3.45 目标
加强离屏分页 WebView 的边界控制,减少外部资源波动和调试副作用。
### 3.46 主要问题代码
- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
### 3.47 修改方案
1. 为分页 WebView 增加严格导航限制
- 仅允许 `file://`
- 仅允许 `ss-reader://`
- 拒绝 `http/https`
2. 将 `isInspectable` 受配置控制
3. 增加测量稳定性日志
- 记录三轮测量结果
- 当三轮差异过大时告警
建议新增私有方法:
- `shouldAllowPaginatorNavigation(url:)`
- `recordMeasurement(pass:value:)`
### 3.48 实施步骤
1. 扩展 `WKNavigationDelegate`
2. 加入导航 allowlist
3. 记录多轮测量差异
### 3.49 风险点
- 某些 EPUB 样式若依赖外链资源,分页结果可能与当前行为不同,但这本身就是更安全的策略。
### 3.50 验收标准
- 分页 WebView 不发起外部导航。
- 三轮测量波动可观测。
---
## 任务 9`cache-hardening`
### 3.51 目标
提升章节摘要磁盘缓存的健壮性、可观测性和可清理能力。
### 3.52 主要问题代码
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift`
### 3.53 修改方案
1. 原子写入
- 先写 `.tmp`
- 再 replace 到正式文件
2. 读失败分类
- 文件不存在
- 读取失败
- 解码失败
3. 精细化清理
- `removeAll()`
- `removeAll(forBookID:)`
- `removeAll(forRenderSignature:)`
4. 增加统计接口
- 缓存文件数
- 总大小
### 3.54 实施步骤
1. 改写入策略
2. 增加读失败日志
3. 增加清理接口
4. 补测试覆盖损坏文件、半写文件
### 3.55 风险点
- 清理策略接口若公开,需要评估是否作为 public API。
### 3.56 验收标准
- 缓存文件损坏不会影响整本书重新打开。
- 能按需清理缓存,而不是只能全删。
---
## 任务 10`sdk-contract-and-test-hardening`
### 3.57 目标
把前面所有修复固化为更清晰的 SDK 契约和更稳定的测试观测点。
### 3.58 主要问题代码
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
- `Sources/RDReaderView/EPUBUI/RDURLReaderController.swift`
- `ReadViewDemo/ReadViewDemoUITests/`
### 3.59 修改方案
1. 强化 persistence 契约
- 继续保留默认实现,但在 DEBUG 下对 no-op 行为给出明确信号
2. Demo 状态能力修正
- 当前仓库并非完全没有结构化状态。
- `ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift` 已经提供了状态解析器和 `waitForDemoReaderState(...)` 结构化等待能力。
- 真正需要推进的不是再造一个 `DemoReaderSnapshot`,而是把仍然大量存在的 `waitForReaderState(containing:)` 迁移到结构化断言。
建议方向:
- 继续保留隐藏 label 输出
- 保持 `key=value` 格式
- 统一测试侧只通过 `DemoReaderState` 解析和断言
- 逐步淘汰 `containing:` 子串匹配,避免 `highlights=1` 命中 `highlights=10` 之类误判
3. 补测试
- 单元测试:
- `RDEPUBParserArchiveSafetyTests`
- `RDEPUBExternalLinkPolicyTests`
- `RDEPUBChapterSummaryDiskCacheTests`
- UI 测试:
- 选区状态流
- 书签启用/禁用
- 外链确认框
### 3.60 实施步骤
1. 改 Demo 状态串生成逻辑
2. 优先将 `waitForReaderState(containing:)` 迁移为 `waitForDemoReaderState(...)`
3. 调整 UI 测试断言
4. 增加单元测试 target 或现有测试目录内的新文件
### 3.61 风险点
- 现有 UI 测试如果强依赖旧状态串,需要同步迁移。
### 3.62 验收标准
- UI 测试失败时能明确知道是选区状态、书签状态还是导航状态失配。
- persistence 的默认 no-op 行为在开发期不再“静默”。
---
## 4. 补充清理项
以下问题已在代码中可见,但未纳入前 10 个主任务,建议作为补充任务或穿插清理项处理。
### 4.1 `pageBreakBefore/pageBreakAfter` 相关死代码审计
涉及代码:
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift`
- `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift`
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift`
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift`
建议动作:
- 审计 `pageBreakBefore/pageBreakAfter` 从注入到消费的完整链路
- 判断是否有“已定义、已注入、但实际无有效触发”的死路径
- 若确认无效,删除或补测试锁定其真实行为
### 4.2 `PAGINATION-DEBUG` 输出清理
涉及代码:
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift`
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift`
建议动作:
- 将裸 `print` 改为受控调试开关
- 默认关闭
- 避免分页细节和正文片段直接进入控制台
### 4.3 `ChapterRuntime` 旧代码与遗留路径清理
建议动作:
- 梳理 `ChapterRuntime` 下是否存在已不再被主流程使用的辅助类型或过渡实现
- 对“仍被引用但语义已经过时”的代码先补注释标记
- 对“完全无调用”的代码建立删除候选清单
---
## 5. 推荐里程碑切分
### 里程碑 A核心交互稳定
包含任务:
- `selection-state-refactor`
- `bookmark-chrome-state-unification`
完成标准:
- 选区、高亮、书签相关 UI 测试恢复稳定
### 里程碑 B默认安全行为收口
包含任务:
- `external-link-policy-hardening`
- `epub-archive-path-validation`
- `webview-debug-sanitization`
- `persistence-defaults-hardening`
完成标准:
- 默认配置下不再直接打开高风险外链
- 解压路径具备显式校验
- 默认调试日志不泄露完整阅读内容
### 里程碑 C底层稳定性优化
包含任务:
- `resource-scheme-streaming-and-limits`
- `pagination-webview-hardening`
- `cache-hardening`
完成标准:
- 大资源加载和分页更稳定
- 缓存更健壮、可清理
### 里程碑 D契约与测试固化
包含任务:
- `sdk-contract-and-test-hardening`
完成标准:
- SDK 默认行为更清晰
- 回归测试对关键状态具备稳定观测能力
---
## 6. 建议的开发节奏
若按 2 周一个小迭代,可参考:
1. 第 1 周:`bookmark-chrome-state-unification` + `selection-state-refactor`
2. 第 2 周:`external-link-policy-hardening` + `epub-archive-path-validation` + `webview-debug-sanitization`
3. 第 3 周:`persistence-defaults-hardening` + `resource-scheme-streaming-and-limits` + `pagination-webview-hardening`
4. 第 4 周:`cache-hardening` + `sdk-contract-and-test-hardening` 与回归收口
如果只想先做最值的部分,建议最小交付集合为:
1. `bookmark-chrome-state-unification`
2. `selection-state-refactor`
3. `external-link-policy-hardening`
4. `epub-archive-path-validation`
这四项完成后,阅读器的“主观可用性”和“默认安全性”会先提升一个台阶。

View File

@ -5,7 +5,7 @@ workspace 'ReadViewSDK.xcworkspace'
project 'ReadViewSDK.xcodeproj'
target 'ReadViewSDK' do
pod 'RDReaderView', :path => '..'
pod 'RDEpubReaderView', :path => '..'
end
post_install do |installer|

19
RDEpubReaderView.podspec Normal file
View File

@ -0,0 +1,19 @@
Pod::Spec.new do |s|
s.name = "RDEpubReaderView"
s.module_name = "RDEpubReaderView"
s.version = "0.0.1"
s.summary = "A reader view for EPUB and plain-text books"
s.platform = :ios, "15.0"
s.swift_versions = ["5.10"]
s.homepage = "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git"
s.author = { "shenlei" => "shenlei@touchread.com" }
s.source = { :git => "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git", :tag => "#{s.version}" }
s.license = "MIT"
s.source_files = "Sources/RDEpubReaderView/**/*.{swift}"
s.resource_bundles = {
"RDEpubReaderViewAssets" => ["Sources/RDEpubReaderView/EPUBCore/Resources/**/*"]
}
s.dependency "ZIPFoundation", "~> 0.9"
s.dependency "DTCoreText", "~> 1.6"
s.requires_arc = true
end

View File

@ -1,23 +0,0 @@
Pod::Spec.new do |s|
s.name = "RDReaderView"
s.version = "0.0.1"
s.summary = "A reader view for novel"
s.platform = :ios, "15.0"
s.swift_versions = ["5.10"]
s.homepage = "https://github.com/namesubai/RDReaderView.git"
s.author = { "subai" => "804663401@qq.com" }
s.source = { :git => "https://github.com/namesubai/RDReaderView.git", :tag => "#{s.version}"}
s.license = "MIT"
s.source_files = 'Sources/RDReaderView/**/*.{swift}'
s.resource_bundles = {
'RDReaderViewAssets' => ['Sources/RDReaderView/EPUBCore/Resources/**/*']
}
s.dependency 'ZIPFoundation', '~> 0.9'
s.dependency 'DTCoreText'
s.dependency 'SnapKit'
s.dependency 'SSAlertSwift'
s.requires_arc = true
end

View File

@ -3,14 +3,14 @@ platform :ios, '15.6'
target 'ReadViewDemo' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
pod 'RDReaderView', :path => '..'
pod 'RDEpubReaderView', :path => '..'
end
post_install do |installer|
apply_settings = lambda do |config|
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.6'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end
# Pods project (Pods.xcodeproj)

View File

@ -16,38 +16,30 @@ PODS:
- DTFoundation/Core
- DTFoundation/UIKit (1.7.19):
- DTFoundation/Core
- RDReaderView (0.0.1):
- DTCoreText
- SnapKit
- SSAlertSwift
- RDEpubReaderView (0.0.1):
- DTCoreText (~> 1.6)
- ZIPFoundation (~> 0.9)
- SnapKit (5.7.1)
- SSAlertSwift (0.0.15)
- ZIPFoundation (0.9.20)
DEPENDENCIES:
- RDReaderView (from `..`)
- RDEpubReaderView (from `..`)
SPEC REPOS:
trunk:
- DTCoreText
- DTFoundation
- SnapKit
- SSAlertSwift
- ZIPFoundation
EXTERNAL SOURCES:
RDReaderView:
RDEpubReaderView:
:path: ".."
SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
RDEpubReaderView: 5ff99b803f5a46e5ff4ebef3024dd217284c69ed
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 775f5c8c488024e24d494aad6331e9fef8f9e2e5
PODFILE CHECKSUM: adda84af3e8577b2a99d5b2cecf381511352f0bd
COCOAPODS: 1.16.2

View File

@ -0,0 +1,37 @@
{
"name": "RDEpubReaderView",
"module_name": "RDEpubReaderView",
"version": "0.0.1",
"summary": "A reader view for EPUB and plain-text books",
"platforms": {
"ios": "15.0"
},
"swift_versions": [
"5.10"
],
"homepage": "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git",
"authors": {
"shenlei": "shenlei@touchread.com"
},
"source": {
"git": "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git",
"tag": "0.0.1"
},
"license": "MIT",
"source_files": "Sources/RDEpubReaderView/**/*.{swift}",
"resource_bundles": {
"RDEpubReaderViewAssets": [
"Sources/RDEpubReaderView/EPUBCore/Resources/**/*"
]
},
"dependencies": {
"ZIPFoundation": [
"~> 0.9"
],
"DTCoreText": [
"~> 1.6"
]
},
"requires_arc": true,
"swift_version": "5.10"
}

View File

@ -1,36 +0,0 @@
{
"name": "RDReaderView",
"version": "0.0.1",
"summary": "A reader view for novel",
"platforms": {
"ios": "15.0"
},
"swift_versions": [
"5.10"
],
"homepage": "https://github.com/namesubai/RDReaderView.git",
"authors": {
"subai": "804663401@qq.com"
},
"source": {
"git": "https://github.com/namesubai/RDReaderView.git",
"tag": "0.0.1"
},
"license": "MIT",
"source_files": "Sources/RDReaderView/**/*.{swift}",
"resource_bundles": {
"RDReaderViewAssets": [
"Sources/RDReaderView/EPUBCore/Resources/**/*"
]
},
"dependencies": {
"ZIPFoundation": [
"~> 0.9"
],
"DTCoreText": [],
"SnapKit": [],
"SSAlertSwift": []
},
"requires_arc": true,
"swift_version": "5.10"
}

View File

@ -16,38 +16,30 @@ PODS:
- DTFoundation/Core
- DTFoundation/UIKit (1.7.19):
- DTFoundation/Core
- RDReaderView (0.0.1):
- DTCoreText
- SnapKit
- SSAlertSwift
- RDEpubReaderView (0.0.1):
- DTCoreText (~> 1.6)
- ZIPFoundation (~> 0.9)
- SnapKit (5.7.1)
- SSAlertSwift (0.0.15)
- ZIPFoundation (0.9.20)
DEPENDENCIES:
- RDReaderView (from `..`)
- RDEpubReaderView (from `..`)
SPEC REPOS:
trunk:
- DTCoreText
- DTFoundation
- SnapKit
- SSAlertSwift
- ZIPFoundation
EXTERNAL SOURCES:
RDReaderView:
RDEpubReaderView:
:path: ".."
SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
RDEpubReaderView: 5ff99b803f5a46e5ff4ebef3024dd217284c69ed
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 775f5c8c488024e24d494aad6331e9fef8f9e2e5
PODFILE CHECKSUM: adda84af3e8577b2a99d5b2cecf381511352f0bd
COCOAPODS: 1.16.2

File diff suppressed because it is too large Load Diff

View File

@ -1,110 +0,0 @@
# SSAlert
实际开发中经常遇到各种自定义的弹窗的需求,其中编写弹窗动画和遮罩就很繁琐,而且一个一个去写重复的代码,代码会变得更加冗余。
SSAlert封装了动画部分而且支持自定义动画。可以快速构建弹窗简单易用基本可以满足大部分弹窗需求。自带常用自定义弹窗类型系统的UIAlertView,UIActionSheet。
# 特性
- 支持OC、Swift版本
- 支持自定义弹窗内容可以根据弹窗内容frame布局和自动布局确定弹窗大小
- 支持动画展示后刷新弹窗的大小
- 支带透明、黑色、无三种背景遮罩
- 自带从上、下、左、右、中弹出动画,动画支持弹簧效果,而且可以设置位置偏移量
- 支持自定义动画,动画隐藏回调
- 支持模态视图弹窗,支持拖拽隐藏
- 自带类似系统的弹窗UIAlertView,UIActionSheet
# 效果图
<img src="https://github.com/namesubai/SSAlert/blob/main/demo.gif" width = 20% height = 20% />
# 使用
## 1.导入代码
### Objective-C项目
1. pod 'SSAlert'
2. #import <SSAlert/SSAlert.h>
### Swift项目
1. pod 'SSAlertSwift'
2. import SSAlertSwift
## 2.SSAlertView用法
### SSAlertView的初始化
SSAlertView的初始化有两种一种是普通弹窗一种是模态视图弹窗两种的区别在于前者是弹出一个View后者是弹窗一个UIViewController。当需要点击弹窗上按钮不关闭弹窗跳转界面时候可以使用模态视图弹窗。
普通弹窗初始化:
```
let customView = UIView()
customView.frame = CGRect(x:0,y:0,width:200,height:200)
let alertView = SSAlertView(customView: customView, onView: navigationController!.view)
```
模态视图弹窗初始化:
```
let customView = UIView()
customView.frame = CGRect(x:0,y:0,width:200,height:200)
let alertView = SSAlertView(customView: customView, fromViewController: self)
```
### SSAlertView的动画设置
遵循 **SSAlertAnimation** 协议的自定义动画都可以设置 **SSAlertView****animation** 属性
**SSAlertView**自带的动画效果:**SSAlertDefaultAnmation**,提供上、下、左、右、中弹出动画等
```
let animation = SSAlertDefaultAnmation(state: .fromCenter)
alertView.animation = animation
```
### SSAlertView的展示和隐藏
```
alertView.show()
alertView.hide()
```
### SSAlertView的大小刷新
```
customView.frame = CGRect(x:0,y:0,width:400,height:400)
alertView.refreshFrame()
```
## 3.自带类型系统UIAlert,UIActionSheet的用法
```
///
let alertView = SSAlertView.alertView(style: .alert, title: "自定义Alert弹窗", message: "自带自定义Alert弹窗类似系统的UIAlertView", cancelButton: "Cancel", otherButtons: ["OK"], onView: navigationController!.view){ index in
print(index)
}
alertView.show()
///
let alertView = SSAlertView.modalAlertView(style: .actionSheet, title: "自定义ActionSheet弹窗", message: "自带自定义ActionSheet弹窗类似系统的ActionSheet", cancelButton: "Cancel", otherButtons: ["action1", "action2", "action3", "action4"], fromViewController: self) { index in
print(index)
}
alertView.show()
```
## 4.自定义SSAlertCommonView
```
let action1 = SSAlertAction(style: .actionSheet, title: "自定义Action1") {
print("自定义Action1")
}
action1.titleColor = .red
action1.backgroundColor = .yellow
action1.titleFont = UIFont.systemFont(ofSize: 18)
action1.height = 80
let action2 = SSAlertAction(style: .actionSheet, title: "自定义Action2") {
print("自定义Action2")
}
action2.titleColor = .yellow
action2.backgroundColor = .red
action2.titleFont = UIFont.systemFont(ofSize: 22)
action2.height = 55
let commonView = SSAlertCommonView(title: "自定义SSAlertCommonView", message: "文本文本文本文本文本文本", style: .actionSheet, actions: [action1, action2])
commonView.backgroundColor = .white
let alertView = SSAlertView(customView: commonView, onView: navigationController!.view, animation: SSAlertDefaultAnmation(state: .fromBottom))
alertView.show()
```
更多用法请下载代码,观看[demo](https://github.com/namesubai/SSAlert),
觉得好用手动点个Star❤

View File

@ -1,24 +0,0 @@
//
// SSAlertAnimation.swift
// SSAlert
//
// Created by yangsq on 2021/8/13.
//
import Foundation
public typealias PanProgress = CGFloat
public typealias PanCancelProgress = CGFloat
public typealias DimissIsCancel = Bool
public protocol SSAlertAnimation {
///
func animationDuration() -> TimeInterval
///
func showAnimationOfAnimationView(animationView: SSAlertView, viewSize: CGSize, animated: Bool, completion:(((Bool) -> Void))?)
///
func hideAnimationOfAnimationView(animationView: SSAlertView, viewSize: CGSize, animated: Bool, completion:(((Bool) -> Bool))?)
///
func refreshAnimationOfAnimationView(animationView: SSAlertView, viewSize: CGSize, animated: Bool, completion:(((Bool) -> Void))?)
///
func panToDimissTransilatePoint(point: CGPoint, panViewFrame: CGRect) -> (PanProgress, PanCancelProgress)
}

View File

@ -1,46 +0,0 @@
//
// SSAlertAnimationController.swift
// SSAlert
//
// Created by yangsq on 2021/8/13.
//
import Foundation
class SSAlertAnimationController: UIViewController {
var isDimiss: Bool = false
var isHideStatusBar = false {
didSet {
setNeedsStatusBarAppearanceUpdate()
}
}
var isShowNavWhenViewWillDisappear = true
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
}
override var prefersStatusBarHidden: Bool {
return isHideStatusBar
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if !isDimiss, isShowNavWhenViewWillDisappear {
navigationController?.setNavigationBarHidden(false, animated: animated)
}
}
override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
super.dismiss(animated: flag, completion: completion)
isDimiss = true
}
}

View File

@ -1,335 +0,0 @@
//
// SSAlertCommonView.swift
// SSAlert
//
// Created by yangsq on 2021/8/13.
//
import Foundation
public extension SSAlertAction {
enum Style {
case alert
case actionSheet
}
}
public class SSAlertAction: NSObject {
public var title: String
public var titleFont = UIFont.systemFont(ofSize: 16)
public var titleColor: UIColor? = nil
public var height: CGFloat = 55
public var backgroundColor: UIColor? = nil
public private(set) var style: Style
private var addAction:(() -> Void)? = nil
public init(style: Style, title: String, addAction: (() -> Void)? = nil) {
self.style = style
self.title = title
self.addAction = addAction
if style == .alert {
height = 50
}
super.init()
}
func triggerAction() {
if let addAction = addAction {
addAction()
}
}
}
extension SSAlertCommonView {
public static var alertMaxWidth = UIScreen.main.bounds.width * 0.7
public static var actionSheetMaxWidth = UIScreen.main.bounds.width
public static var buttonSpace: CGFloat = 0.5
public static var safeAreaBottomMargin: CGFloat {
if #available(iOS 11.0, *) {
return UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0
}
return 0
}
}
public class SSAlertCommonView: UIView {
public lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 16)
label.numberOfLines = 0
label.textAlignment = .center
return label
}()
public lazy var messageLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 14)
label.numberOfLines = 0
label.textAlignment = .center
return label
}()
public lazy var containerView: UIView = {
let view = UIView()
return view
}()
public lazy var buttonView: UIView = {
let view = UIView()
return view
}()
public var lineColor: UIColor? = nil {
didSet {
lineArray.forEach { lineView in
lineView.backgroundColor = lineColor
}
}
}
public var lineEdgeInsets: UIEdgeInsets = .zero {
didSet {
refreshFrame()
}
}
public private(set) var style: SSAlertAction.Style
public private(set) var actions: [SSAlertAction]
public private(set) var title: String?
public private(set) var message: String?
private var buttonArray: [UIButton] = []
private var lineArray: [UIView] = []
private var hasText = false
public init(title: String? = nil, message: String? = nil, style: SSAlertAction.Style, actions:[SSAlertAction] = []) {
self.title = title
self.message = message
self.style = style
self.actions = actions
super.init(frame: .zero)
makeUI()
}
private func makeUI() {
addSubview(containerView)
if let title = title, !title.isEmpty {
titleLabel.text = title
containerView.addSubview(titleLabel)
hasText = true
}
if let message = message, !message.isEmpty {
messageLabel.text = message
containerView.addSubview(messageLabel)
hasText = true
}
if actions.count > 0 {
addSubview(buttonView)
actions.forEach { action in
let button = UIButton(type: .system)
button.setTitle(action.title, for: .normal)
button.backgroundColor = backgroundColor
if let backgroundColor = action.backgroundColor {
button.backgroundColor = backgroundColor
}
button.titleLabel?.font = action.titleFont
button.setTitleColor(action.titleColor, for: .normal)
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
buttonView.addSubview(button)
buttonArray.append(button)
var isAddLineView = false
if style == .actionSheet {
if hasText {
isAddLineView = true
} else {
if actions[actions.count - 1] != action {
isAddLineView = true
}
}
} else {
if actions.count > 2 {
if hasText {
isAddLineView = true
} else {
if actions[actions.count - 1] != action {
isAddLineView = true
}
}
} else {
if actions.count == 1 {
if hasText {
isAddLineView = true
}
} else {
if hasText {
isAddLineView = true
} else {
if actions[actions.count - 1] != action {
isAddLineView = true
}
}
}
}
}
if isAddLineView {
let linView = UIView()
linView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
buttonView.addSubview(linView)
lineArray.append(linView)
}
}
}
refreshFrame()
}
private func refreshFrame() {
let space: CGFloat = 15
let viewMaxWidth = style == .alert ? SSAlertCommonView.alertMaxWidth : SSAlertCommonView.actionSheetMaxWidth
var height: CGFloat = 0
let width = viewMaxWidth
let containerWidth = viewMaxWidth - space * 2
var originY: CGFloat = 0
var cotainerOriginY: CGFloat = 0
if titleLabel.superview != nil {
let titleSize = titleLabel.sizeThatFits(CGSize(width: containerWidth, height: CGFloat(MAXFLOAT)))
titleLabel.ss_origin = CGPoint(x: 0, y: cotainerOriginY)
titleLabel.ss_size = CGSize(width: containerWidth, height: titleSize.height)
height += (titleSize.height + space)
cotainerOriginY += (space + titleSize.height)
originY = space
}
if messageLabel.superview != nil {
let messageSize = messageLabel.sizeThatFits(CGSize(width: containerWidth, height: CGFloat(MAXFLOAT)))
messageLabel.ss_origin = CGPoint(x: 0, y: cotainerOriginY)
messageLabel.ss_size = CGSize(width: containerWidth, height: messageSize.height)
height += (messageSize.height + space)
cotainerOriginY += (space + messageSize.height)
originY = space
}
containerView.ss_origin = CGPoint(x: space, y: originY)
containerView.ss_size = CGSize(width: containerWidth, height: height)
originY += height
if !buttonArray.isEmpty {
let buttonSpace: CGFloat = SSAlertCommonView.buttonSpace
var buttonTotalHeight: CGFloat = 0
var buttonTopY: CGFloat = 0
if style == .actionSheet {
for index in 0..<buttonArray.count {
let action = actions[index]
let button = buttonArray[index]
if lineArray.count == buttonArray.count {
let lineView = lineArray[index]
lineView.frame = CGRect(x: lineEdgeInsets.left, y: buttonTopY, width: width - lineEdgeInsets.left - lineEdgeInsets.right, height: buttonSpace)
buttonTopY += buttonSpace
}
if lineArray.count == buttonArray.count - 1 {
if index < lineArray.count {
let lineView = lineArray[index]
lineView.frame = CGRect(x: lineEdgeInsets.left, y: buttonTopY + action.height, width: width - lineEdgeInsets.left - lineEdgeInsets.right, height: buttonSpace)
buttonTopY += buttonSpace
}
}
if index == buttonArray.count - 1 {
button.ss_size = CGSize(width: width, height: action.height + SSAlertCommonView.safeAreaBottomMargin)
button.titleEdgeInsets = UIEdgeInsets(top: -SSAlertCommonView.safeAreaBottomMargin, left: 0, bottom: 0, right: 0)
} else {
button.ss_size = CGSize(width: width, height: action.height)
}
button.ss_origin = CGPoint(x: 0, y: buttonTopY)
buttonTopY += button.ss_h
}
buttonTotalHeight = buttonTopY
}
if style == .alert {
if buttonArray.count > 2 {
buttonArray.forEach { button in
let index = buttonArray.firstIndex(of: button)
let action = actions[index!]
if lineArray.count == buttonArray.count {
let lineView = lineArray[index!]
lineView.frame = CGRect(x: lineEdgeInsets.left, y: buttonTopY, width: width - lineEdgeInsets.left - lineEdgeInsets.right, height: buttonSpace)
buttonTopY += buttonSpace
}
if lineArray.count == buttonArray.count - 1 {
if index! < lineArray.count {
let lineView = lineArray[index!]
lineView.frame = CGRect(x: lineEdgeInsets.left, y: buttonTopY + action.height, width: width - lineEdgeInsets.left - lineEdgeInsets.right, height: buttonSpace)
buttonTopY += buttonSpace
}
}
button.ss_origin = CGPoint(x: 0, y: buttonTopY)
button.ss_size = CGSize(width: width, height: action.height)
buttonTopY += action.height
}
buttonTotalHeight = buttonTopY
} else {
if buttonArray.count == 1 {
let button = buttonArray.first!
let action = actions.first!
if lineArray.count > 0 {
let lineView = lineArray.first!
lineView.frame = CGRect(x: lineEdgeInsets.left, y: buttonTopY, width: width - lineEdgeInsets.left - lineEdgeInsets.right, height: buttonSpace)
buttonTopY += buttonSpace
}
button.ss_origin = CGPoint(x: 0, y: buttonTopY)
button.ss_size = CGSize(width: width, height: action.height)
buttonTotalHeight = buttonTopY
} else {
let button1 = buttonArray.first!
let button2 = buttonArray.last!
let action1 = actions.first!
let action2 = actions.last!
let maxButtonHeight = max(action1.height, action2.height)
if hasText {
let lineView1 = lineArray.first!
lineView1.frame = CGRect(x: 0, y: buttonTopY, width: width, height: buttonSpace)
buttonTopY += buttonSpace
}
button1.ss_origin = CGPoint(x: 0, y: buttonTopY)
button1.ss_size = CGSize(width: width / 2 - buttonSpace / 2, height: maxButtonHeight)
button2.ss_origin = CGPoint(x: button1.frame.maxX + buttonSpace, y: buttonTopY)
button2.ss_size = CGSize(width: width / 2 - buttonSpace / 2, height: maxButtonHeight)
let lineView2 = lineArray.last!
lineView2.frame = CGRect(x: button1.frame.maxX, y: buttonSpace, width: buttonSpace, height: button1.ss_h)
buttonTopY += maxButtonHeight
}
buttonTotalHeight = buttonTopY
}
}
buttonView.ss_origin = CGPoint(x: 0, y: originY)
buttonView.ss_size = CGSize(width: width, height: buttonTotalHeight)
height += buttonTotalHeight + (originY > 0 ? space : 0)
}
self.ss_size = CGSize(width: width, height: height)
}
@objc private func buttonAction(button: UIButton) {
let index = buttonArray.firstIndex(of: button)
let action = actions[index!]
action.triggerAction()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -1,223 +0,0 @@
//
// SSAlertDefaultAnmation.swift
// SSAlert
//
// Created by yangsq on 2021/8/13.
//
import Foundation
extension SSAlertDefaultAnmation {
public enum State {
case fromTop, fromLeft, fromBottom, fromRight, fromCenter
}
}
open class SSAlertDefaultAnmation: NSObject {
public var state: State
///
public var centerOffset: CGPoint = .zero
///
public var duration: TimeInterval = 0.3
///
public var isSpringShowAnimation: Bool = true
///
public var panDimissProgress: CGFloat = 0.4
public var usingSpringWithDamping: CGFloat = 0.8
public var initialSpringVelocity: CGFloat = 0.5
private weak var animationView: SSAlertView? = nil
public init(state: State) {
self.state = state
super.init()
}
private func animation(animated: Bool, isSpringAnimation: Bool = true, animations: @escaping (() -> Void), completion: ((Bool) -> Void)? = nil) {
if animated {
if isSpringAnimation {
UIView.animate(withDuration: animationDuration(),
delay: 0,
usingSpringWithDamping: usingSpringWithDamping,
initialSpringVelocity: initialSpringVelocity,
options: [],
animations: animations,
completion: completion)
} else {
UIView.animate(withDuration: animationDuration(),
animations: animations,
completion: completion)
}
} else {
animations()
if let completion = completion {
completion(true)
}
}
}
public func setCenterOffset(center offset: CGPoint, duration: TimeInterval = 0.3, animated: Bool = true, completion:((Bool) -> Void)?) {
self.duration = duration
self.animation(animated: animated, animations: {
self.animationView?.transform = CGAffineTransform.init(translationX: offset.x, y: offset.y)
}, completion: completion)
}
}
extension SSAlertDefaultAnmation: SSAlertAnimation {
public func animationDuration() -> TimeInterval {
return duration
}
public func showAnimationOfAnimationView(animationView: SSAlertView, viewSize: CGSize, animated: Bool, completion: (((Bool) -> Void))?) {
guard let animationSuperView = animationView.superview else {
return
}
self.animationView = animationView
animationView.alpha = 1
animationView.ss_size = viewSize
switch state {
case .fromBottom:
animationView.ss_centerX = animationSuperView.ss_w / 2 + centerOffset.x
animationView.ss_y = animationSuperView.ss_h
animation(animated: animated, isSpringAnimation: isSpringShowAnimation, animations: { [weak self] in guard let self = self else { return }
animationView.transform = CGAffineTransform.init(translationX: 0, y: -animationView.ss_h + self.centerOffset.y)
animationView.backgroundMask?.alpha = 1;
}, completion: completion)
case .fromTop:
animationView.ss_centerX = animationSuperView.ss_w / 2 + centerOffset.x
animationView.ss_y = -animationView.ss_h
animation(animated: animated, isSpringAnimation: isSpringShowAnimation, animations: { [weak self] in guard let self = self else { return }
animationView.transform = CGAffineTransform.init(translationX: 0, y: animationView.ss_h + self.centerOffset.y)
animationView.backgroundMask?.alpha = 1;
}, completion: completion)
case .fromLeft:
animationView.ss_centerY = animationSuperView.ss_h / 2 + centerOffset.y
animationView.ss_x = -animationView.ss_w
animation(animated: animated, isSpringAnimation: isSpringShowAnimation, animations: {
animationView.transform = CGAffineTransform.init(translationX: animationView.ss_w + self.centerOffset.x, y: 0)
animationView.backgroundMask?.alpha = 1;
}, completion: completion)
case .fromRight:
animationView.ss_centerY = animationSuperView.ss_h / 2 + centerOffset.y
animationView.ss_x = animationSuperView.ss_w
animation(animated: animated, isSpringAnimation: isSpringShowAnimation, animations: {
animationView.transform = CGAffineTransform.init(translationX: -animationView.ss_w + self.centerOffset.x, y: 0)
animationView.backgroundMask?.alpha = 1;
}, completion: completion)
case .fromCenter:
animationView.ss_centerX = animationSuperView.ss_w / 2 + centerOffset.x
animationView.ss_centerY = animationSuperView.ss_h / 2 + centerOffset.y
animationView.transform = CGAffineTransform.init(scaleX: 1.5, y: 1.5)
animationView.alpha = 0
animation(animated: animated, isSpringAnimation: isSpringShowAnimation, animations: {
animationView.alpha = 1
animationView.transform = CGAffineTransform.identity
animationView.backgroundMask?.alpha = 1;
}, completion: completion)
}
}
public func hideAnimationOfAnimationView(animationView: SSAlertView, viewSize: CGSize, animated: Bool, completion: (((Bool) -> Bool))?) {
self.animationView = animationView
animation(animated: animated, isSpringAnimation: false, animations: {
if self.state == .fromCenter {
animationView.alpha = 0
} else {
animationView.transform = CGAffineTransform.identity
}
animationView.backgroundMask?.alpha = 0
}, completion: {
finished in
var isCancel = false
if let completion = completion {
isCancel = completion(finished)
}
if !isCancel {
animationView.removeFromSuperview()
animationView.backgroundMask?.removeFromSuperview()
self.animationView = nil
}
})
}
public func refreshAnimationOfAnimationView(animationView: SSAlertView, viewSize: CGSize, animated: Bool, completion: (((Bool) -> Void))?) {
guard let animationSuperView = animationView.superview else {
return
}
self.animationView = animationView
animationView.alpha = 1
animationView.ss_size = viewSize
switch state {
case .fromBottom:
animationView.ss_centerX = animationSuperView.ss_w / 2.0 + centerOffset.x
animation(animated: animated, animations: {
animationView.ss_y = animationSuperView.ss_h - viewSize.height + self.centerOffset.y
}, completion: completion)
case .fromTop:
animationView.ss_centerX = animationSuperView.ss_w / 2.0 + centerOffset.x
animation(animated: animated, animations: {
animationView.ss_y = self.centerOffset.y
}, completion: completion)
case .fromLeft:
animationView.ss_centerY = animationSuperView.ss_h / 2.0 + centerOffset.y
animation(animated: animated, animations: {
animationView.ss_x = self.centerOffset.x
}, completion: completion)
case .fromRight:
animationView.ss_centerY = animationSuperView.ss_h / 2.0 + centerOffset.y
animation(animated: animated, animations: {
animationView.ss_x = animationSuperView.ss_w - viewSize.width + self.centerOffset.x
}, completion: completion)
case .fromCenter:
animationView.alpha = 0
animationView.ss_centerX = animationSuperView.ss_w / 2 + centerOffset.x
animationView.ss_centerY = animationSuperView.ss_h / 2 + centerOffset.y
animationView.transform = CGAffineTransform.init(scaleX: 1.5, y: 1.5)
animation(animated: animated, animations: {
animationView.alpha = 1
animationView.transform = CGAffineTransform.identity
}, completion: completion)
}
}
public func panToDimissTransilatePoint(point: CGPoint, panViewFrame: CGRect) -> (PanProgress, PanCancelProgress) {
var progress: CGFloat = 0
switch state {
case .fromTop:
if point.y <= 0 {
progress = abs(point.y / panViewFrame.height)
}
case .fromBottom:
if point.y >= 0 {
progress = abs(point.y / panViewFrame.height)
}
case .fromLeft:
if point.x <= 0 {
progress = abs(point.x / panViewFrame.width)
}
case .fromRight:
if point.x >= 0 {
progress = abs(point.x / panViewFrame.width)
}
case .fromCenter:
progress = abs(point.y / panViewFrame.height)
}
return (progress, panDimissProgress)
}
}

View File

@ -1,222 +0,0 @@
//
// SSAlertPresentAnimation.swift
// SSAlert
//
// Created by yangsq on 2021/8/13.
//
import Foundation
typealias PanDimissAnimation = (CGPoint, CGRect) -> (PanProgress, PanCancelProgress)
typealias DimissAnimation = (DimissIsCancel) -> Void
class SSCustomInteractiveAnimation: UIPercentDrivenInteractiveTransition {
private(set) var panGestureRecognizer: UIPanGestureRecognizer
private var panDimissAnimation: PanDimissAnimation?
private var dimissAnimation: DimissAnimation?
init(panGestureRecognizer: UIPanGestureRecognizer, panDimissAnimation: PanDimissAnimation?, dimissAnimation: DimissAnimation?) {
self.panGestureRecognizer = panGestureRecognizer
self.panDimissAnimation = panDimissAnimation
self.dimissAnimation = dimissAnimation
super.init()
self.completionSpeed = 0.2
panGestureRecognizer.addTarget(self, action: #selector(panAction(pan:)))
}
@objc private func panAction(pan: UIPanGestureRecognizer) {
guard let view = pan.view else {
return
}
let poin = pan.translation(in: view)
var progress: CGFloat = 0
var cancelProgress: CGFloat = 0.4
if let panDimissAnimation = self.panDimissAnimation {
let (tProgress, tCancelProgress) = panDimissAnimation(poin, pan.view!.frame)
progress = tProgress
cancelProgress = tCancelProgress
}
switch pan.state {
case .began:
pan.setTranslation(CGPoint(x: 0, y: 0), in: view)
case .changed:
update(progress)
case .cancelled:
cancel()
case .ended:
if progress > cancelProgress {
finish()
dimissAnimation?(false)
} else {
cancel()
dimissAnimation?(true)
}
default:
break
}
}
override func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
super.startInteractiveTransition(transitionContext)
}
deinit {
panGestureRecognizer.removeTarget(self, action: #selector(panAction(pan:)))
}
}
class SSAlertPresentAnimation: NSObject {
typealias AnimationCompletion = () -> Void
var duration: TimeInterval = 0.35
private var showAnimation: AnimationCompletion? = nil
private var hideAnimation: AnimationCompletion? = nil
private var endCompletion: AnimationCompletion? = nil
private var panDimissAnimation: PanDimissAnimation? = nil
private var dimissAnimation: DimissAnimation? = nil
private var transitionContext: UIViewControllerContextTransitioning?
private weak var nav: UINavigationController?
private weak var animationView: UIView?
private var panGestureRecognizer: UIPanGestureRecognizer?
init(navigationViewController: UINavigationController, animationView: UIView, canPanDimiss: Bool = true) {
self.nav = navigationViewController
self.animationView = animationView
super.init()
if canPanDimiss {
let pan = UIPanGestureRecognizer(target: self, action: #selector(panAction(pan:)))
pan.maximumNumberOfTouches = 1
animationView.addGestureRecognizer(pan)
}
}
@objc func panAction(pan: UIPanGestureRecognizer) {
if pan.state == .began {
panGestureRecognizer = pan
nav?.viewControllers[0].dismiss(animated: true, completion: {
[weak self] in guard let self = self else { return }
self.panGestureRecognizer = nil
});
}
}
func animationCompletion(showAnimation: AnimationCompletion? = nil,
hideAnimation: AnimationCompletion? = nil,
endCompletion: AnimationCompletion? = nil,
panDimissAnimation: PanDimissAnimation? = nil,
dimissAnimation: DimissAnimation? = nil) {
if showAnimation != nil {
self.showAnimation = showAnimation
}
if hideAnimation != nil {
self.hideAnimation = hideAnimation
}
if endCompletion != nil {
self.endCompletion = endCompletion
}
if panDimissAnimation != nil {
self.panDimissAnimation = panDimissAnimation
}
if panDimissAnimation != nil {
self.panDimissAnimation = panDimissAnimation
}
if dimissAnimation != nil {
self.dimissAnimation = dimissAnimation
}
}
@discardableResult
func setCompleteTransitionIsHide(isHide: Bool) -> Bool {
if let transitionContext = transitionContext {
let isCancelled = transitionContext.transitionWasCancelled
if !isCancelled {
if isHide {
transitionContext.containerView.subviews.forEach { subView in
subView.removeFromSuperview()
}
}
}
transitionContext.completeTransition(!isCancelled)
return isCancelled
}
return false
}
}
extension SSAlertPresentAnimation: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return self
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return self
}
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
if self.panGestureRecognizer != nil {
return SSCustomInteractiveAnimation(panGestureRecognizer: self.panGestureRecognizer!, panDimissAnimation: self.panDimissAnimation, dimissAnimation: dimissAnimation)
}
return nil
}
func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
if self.panGestureRecognizer != nil {
return SSCustomInteractiveAnimation(panGestureRecognizer: self.panGestureRecognizer!, panDimissAnimation: self.panDimissAnimation, dimissAnimation: dimissAnimation)
}
return nil
}
}
extension SSAlertPresentAnimation: UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return self.duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
self.transitionContext = transitionContext
let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)
let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)
let containerView = transitionContext.containerView
if let fromVC = fromVC, let toVC = toVC {
var fromView = fromVC.view
var toView = toVC.view
if transitionContext.responds(to: #selector(UIViewControllerContextTransitioning.view(forKey:))) {
fromView = transitionContext.view(forKey: UITransitionContextViewKey.from)
toView = transitionContext.view(forKey: UITransitionContextViewKey.to)
}
let isPresenting = toVC.presentingViewController == fromVC
if isPresenting {
if let toView = toView {
containerView.addSubview(toView)
}
if let showAnimation = self.showAnimation {
showAnimation()
}
} else {
if let toView = toView, let fromView = fromView {
containerView.insertSubview(toView, belowSubview: fromView)
}
if let hideAnimation = self.hideAnimation {
hideAnimation()
}
}
}
}
func animationEnded(_ transitionCompleted: Bool) {
if let endCompletion = self.endCompletion, transitionCompleted, let transitionContext = transitionContext, transitionContext.isInteractive {
endCompletion()
}
}
}

View File

@ -1,312 +0,0 @@
//
// SSAlertView.swift
// SSAlert
//
// Created by yangsq on 2021/8/13.
//
import UIKit
extension SSAlertView {
public enum BackgroundMaskType {
///
case none
///
case clear
///
case black
}
}
fileprivate extension SSAlertView {
typealias HideCompletion = () -> Void
class HideCompletionData: NSObject {
var completion: HideCompletion?
}
}
open class SSAlertView: UIView {
/// customView
public var customEdgeInsets: UIEdgeInsets = .zero {
didSet {
setNeedsDisplay()
layoutIfNeeded()
}
}
///
public var animation: SSAlertAnimation
///
public private(set) var backgroundMask: UIView?
///
public var canTouchMaskHide: Bool = true
/// nil,
public var isTouchMaskHideAnimated: Bool? = nil
///
public private(set) var navigationController: UINavigationController?
public var isHideStatusBar = false {
didSet {
self.toViewContrller?.isHideStatusBar = isHideStatusBar
}
}
public var isShowNavWhenViewWillDisappear = true {
didSet {
self.toViewContrller?.isShowNavWhenViewWillDisappear = isShowNavWhenViewWillDisappear
}
}
private var onView: UIView
public private(set) var maskType: BackgroundMaskType
public private(set) var customView: UIView
private weak var fromViewController: UIViewController?
private weak var toViewContrller: SSAlertAnimationController?
private var presentAnimation: SSAlertPresentAnimation?
private var canPanDimiss: Bool = false
private var hideCompletions = [HideCompletionData]()
///
public init(customView: UIView,
onView: UIView,
animation: SSAlertAnimation = SSAlertDefaultAnmation(state: .fromCenter),
maskType: BackgroundMaskType = .black) {
self.animation = animation
self.onView = onView
self.maskType = maskType
self.customView = customView
super.init(frame: .zero)
makeUI()
}
/// ),canPanDimiss:
public convenience init(customView: UIView,
fromViewController: UIViewController,
animation: SSAlertAnimation = SSAlertDefaultAnmation(state: .fromCenter),
navigationControllerClass: UINavigationController.Type = UINavigationController.self,
maskType: BackgroundMaskType = .black,
canPanDimiss: Bool = true) {
let animaionVC = SSAlertAnimationController()
self.init(customView: customView, onView: animaionVC.view, animation: animation, maskType: maskType)
animaionVC.isHideStatusBar = self.isHideStatusBar
if let superViewController = superViewController(view: customView), superViewController != animaionVC {
animaionVC.addChild(superViewController)
superViewController.didMove(toParent: animaionVC)
}
self.canPanDimiss = canPanDimiss
self.fromViewController = fromViewController
let nav = navigationControllerClass.init(rootViewController: animaionVC)
nav.modalPresentationStyle = .custom
self.navigationController = nav
self.canPanDimiss = canPanDimiss
let animation = SSAlertPresentAnimation(navigationViewController: nav, animationView: customView, canPanDimiss: canPanDimiss)
self.navigationController!.transitioningDelegate = animation
self.navigationController!.modalPresentationCapturesStatusBarAppearance = true
self.presentAnimation = animation
self.toViewContrller = animaionVC
}
open override func layoutSubviews() {
super.layoutSubviews()
backgroundMask?.frame = CGRect(x: 0, y: 0, width: onView.ss_w, height: onView.ss_h)
customView.ss_x = customEdgeInsets.left
customView.ss_y = customEdgeInsets.top
customView.ss_w = frame.width - customEdgeInsets.left - customEdgeInsets.right
customView.ss_h = frame.height - customEdgeInsets.top - customEdgeInsets.bottom
}
private func makeUI() {
if maskType != .none {
let backgroundMask = UIButton()
backgroundMask.alpha = 0
onView.addSubview(backgroundMask)
backgroundMask.addTarget(self, action: #selector(backgroundMaskAction), for: .touchUpInside)
self.backgroundMask = backgroundMask
if maskType == .black {
backgroundMask.backgroundColor = UIColor.black.withAlphaComponent(0.3)
}
}
addSubview(customView)
onView.addSubview(self)
}
///
public func show(animated: Bool = true, completion: (() -> Void)? = nil) {
if navigationController != nil {
presentView(animated: animated, completion: completion)
} else {
showView(animated: animated, completion: completion)
}
}
///
public func hide(animated: Bool = true, completion: (() -> Void)? = nil) {
if navigationController != nil {
dimissView(animated: animated, completion: completion)
} else {
hideView(animated: animated, completion: completion)
}
}
///
/// - Parameter animated:
private func showView(animated: Bool = true, completion: (() -> Void)? = nil) {
isTouchMaskHideAnimated = animated
var customSize = customView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
if customView.ss_size != .zero {
customSize = customView.ss_size
}
let size = CGSize(width: customSize.width + customEdgeInsets.left + customEdgeInsets.right, height: customSize.height + customEdgeInsets.top + customEdgeInsets.bottom)
animation.showAnimationOfAnimationView(animationView: self, viewSize: size, animated: animated, completion: { _ in completion?() })
}
///
/// - Parameter animated:
private func hideView(animated: Bool = true, completion: (() -> Void)? = nil) {
var customSize = customView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
if customView.ss_size != .zero {
customSize = customView.ss_size
}
let size = CGSize(width: customSize.width + customEdgeInsets.left + customEdgeInsets.right, height: customSize.height + customEdgeInsets.top + customEdgeInsets.bottom)
animation.hideAnimationOfAnimationView(animationView: self, viewSize: size, animated: animated) { [weak self] _ in guard let self = self else { return false }
completion?()
self.hideCompletions.forEach { hideC in
hideC.completion?()
hideC.completion = nil
self.hideCompletions.removeAll(where: {$0 == hideC})
}
return false
}
}
///
/// - Parameter animated:
private func presentView(animated: Bool = true, completion: (() -> Void)? = nil) {
isTouchMaskHideAnimated = animated
var customSize = customView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
if customView.ss_size != .zero {
customSize = customView.ss_size
}
let size = CGSize(width: customSize.width + customEdgeInsets.left + customEdgeInsets.right, height: customSize.height + customEdgeInsets.top + customEdgeInsets.bottom)
presentAnimation?.animationCompletion(showAnimation: {
[weak self] in guard let self = self else { return }
self.animation.showAnimationOfAnimationView(animationView: self, viewSize: size, animated: animated) { _ in
self.presentAnimation?.setCompleteTransitionIsHide(isHide: false)
completion?()
}
})
self.presentAnimation?.duration = animation.animationDuration()
/// apppresnentmodalPresentationStyleCurrentContextpresentingVCtabbarVCtabbarVCdimisspresentedVCdimissdimisspresentigngVCdimiss,
/// definesPresentationContext = true,modalPresentationStyle = UIModalPresentationOverCurrentContextpresentigngVC self.navigationController,
/// UIModalPresentationOverCurrentContext UIModalPresentationCurrentContextstact
self.navigationController?.definesPresentationContext = true
self.navigationController?.modalPresentationStyle = .overCurrentContext
self.fromViewController?.present(self.navigationController!, animated: true, completion: nil)
if canPanDimiss {
dimissViewDragToHideAnimation(animated: animated)
}
}
///
/// - Parameter animated:
private func dimissView(animated: Bool = true, completion: (() -> Void)? = nil) {
var customSize = customView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
if customView.ss_size != .zero {
customSize = customView.ss_size
}
let size = CGSize(width: customSize.width + customEdgeInsets.left + customEdgeInsets.right, height: customSize.height + customEdgeInsets.top + customEdgeInsets.bottom)
presentAnimation?.animationCompletion(hideAnimation: {
[weak self] in guard let self = self else { return }
self.animation.hideAnimationOfAnimationView(animationView: self, viewSize: size, animated: animated) { _ in
return self.presentAnimation!.setCompleteTransitionIsHide(isHide: true)
}
})
self.presentAnimation?.duration = animation.animationDuration()
self.toViewContrller?.dismiss(animated: animated, completion: {
[weak self] in guard let self = self else { return }
completion?()
self.hideCompletions.forEach { hideC in
hideC.completion?()
hideC.completion = nil
self.hideCompletions.removeAll(where: {$0 == hideC})
}
})
}
private func dimissViewDragToHideAnimation(animated: Bool) {
var customSize = customView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
if customView.ss_size != .zero {
customSize = customView.ss_size
}
let size = CGSize(width: customSize.width + customEdgeInsets.left + customEdgeInsets.right, height: customSize.height + customEdgeInsets.top + customEdgeInsets.bottom)
presentAnimation?.animationCompletion(hideAnimation: {
[weak self] in guard let self = self else { return }
self.animation.hideAnimationOfAnimationView(animationView: self, viewSize: size, animated: animated) { _ in
return self.presentAnimation!.setCompleteTransitionIsHide(isHide: true)
}
},endCompletion: {
[weak self] in guard let self = self else { return }
self.hideCompletions.forEach { hideC in
hideC.completion?()
hideC.completion = nil
self.hideCompletions.removeAll(where: {$0 == hideC})
}
}, panDimissAnimation: { [weak self] point, frame in
guard let self = self else { return (0, 0.4) }
return self.animation.panToDimissTransilatePoint(point: point, panViewFrame: frame)
}, dimissAnimation: { [weak self] isCancel in guard let self = self else { return }
self.toViewContrller?.isDimiss = !isCancel
})
self.presentAnimation?.duration = animation.animationDuration()
}
public func refreshFrame(animated: Bool = true) {
var customSize = customView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
if customView.ss_size != .zero {
customSize = customView.ss_size
}
let size = CGSize(width: customSize.width + customEdgeInsets.left + customEdgeInsets.right, height: customSize.height + customEdgeInsets.top + customEdgeInsets.bottom)
animation.refreshAnimationOfAnimationView(animationView: self, viewSize: size, animated: animated, completion: nil)
}
@objc private func backgroundMaskAction() {
if canTouchMaskHide {
if fromViewController != nil {
dimissView(animated: isTouchMaskHideAnimated ?? false)
} else {
hideView(animated: isTouchMaskHideAnimated ?? false)
}
}
}
private func superViewController(view: UIView) -> UIViewController? {
var next = view.next
while next != nil {
if next is UIViewController {
return next as? UIViewController
} else {
next = next!.next
}
}
return nil
}
public func observeHideCompletion(completion: (() -> Void)?) {
let completionData = HideCompletionData()
completionData.completion = completion
self.hideCompletions.append(completionData)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -1,127 +0,0 @@
//
// SSAlertViewExtention.swift
// SSAlert
//
// Created by yangsq on 2021/8/13.
//
import Foundation
extension SSAlertView {
@discardableResult
public class func alertView(style: SSAlertAction.Style, title: String? = nil, message: String? = nil, cancelButton: String? = nil, otherButtons: [String] = [], onView: UIView, onTrigger:((Int) -> Void)? = nil) -> SSAlertView {
var actionArray = [SSAlertAction]()
var cancelAction: SSAlertAction? = nil
var alertView: SSAlertView?
if style == .alert {
if let cancelButton = cancelButton, !cancelButton.isEmpty {
cancelAction = SSAlertAction(style: style, title: cancelButton, addAction: {
if let onTrigger = onTrigger {
onTrigger(0)
}
alertView?.hide()
})
actionArray.append(cancelAction!)
}
otherButtons.forEach { title in
let action = SSAlertAction(style: style, title: title) {
if let onTrigger = onTrigger {
let index = otherButtons.firstIndex(of: title)
onTrigger(cancelAction != nil ? (index! + 1) : index!)
}
}
actionArray.append(action)
}
}
if style == .actionSheet {
otherButtons.forEach { title in
let action = SSAlertAction(style: style, title: title) {
if let onTrigger = onTrigger {
let index = otherButtons.firstIndex(of: title)
onTrigger(cancelAction != nil ? (index! + 1) : index!)
}
}
actionArray.append(action)
}
if let cancelButton = cancelButton, !cancelButton.isEmpty {
cancelAction = SSAlertAction(style: style, title: cancelButton, addAction: {
if let onTrigger = onTrigger {
onTrigger(0)
}
alertView?.hide()
})
actionArray.append(cancelAction!)
}
}
let customView = SSAlertCommonView(title: title, message: message, style: style, actions: actionArray)
customView.backgroundColor = .white
alertView = SSAlertView(customView: customView, onView: onView, animation: SSAlertDefaultAnmation(state: style == .actionSheet ? .fromBottom : .fromCenter), maskType: .black)
alertView!.canTouchMaskHide = false
if style == .alert {
alertView!.layer.cornerRadius = 10
alertView!.layer.masksToBounds = true
}
return alertView!
}
@discardableResult
public class func modalAlertView(style: SSAlertAction.Style, title: String? = nil, message: String? = nil, cancelButton: String? = nil, otherButtons: [String] = [], fromViewController: UIViewController, navigationViewControllerClass: UINavigationController.Type = UINavigationController.self, onTrigger:((Int) -> Void)? = nil) -> SSAlertView {
var actionArray = [SSAlertAction]()
var cancelAction: SSAlertAction? = nil
var alertView: SSAlertView?
if style == .alert {
if let cancelButton = cancelButton, !cancelButton.isEmpty {
cancelAction = SSAlertAction(style: style, title: cancelButton, addAction: {
if let onTrigger = onTrigger {
onTrigger(0)
}
alertView?.hide()
})
actionArray.append(cancelAction!)
}
otherButtons.forEach { title in
let action = SSAlertAction(style: style, title: title) {
if let onTrigger = onTrigger {
let index = otherButtons.firstIndex(of: title)
onTrigger(cancelAction != nil ? (index! + 1) : index!)
}
}
actionArray.append(action)
}
}
if style == .actionSheet {
otherButtons.forEach { title in
let action = SSAlertAction(style: style, title: title) {
if let onTrigger = onTrigger {
let index = otherButtons.firstIndex(of: title)
onTrigger(cancelAction != nil ? (index! + 1) : index!)
}
}
actionArray.append(action)
}
if let cancelButton = cancelButton, !cancelButton.isEmpty {
cancelAction = SSAlertAction(style: style, title: cancelButton, addAction: {
if let onTrigger = onTrigger {
onTrigger(0)
}
alertView?.hide()
})
actionArray.append(cancelAction!)
}
}
let customView = SSAlertCommonView(title: title, message: message, style: style, actions: actionArray)
customView.backgroundColor = .white
alertView = SSAlertView(customView: customView,fromViewController: fromViewController, animation: SSAlertDefaultAnmation(state: style == .actionSheet ? .fromBottom : .fromCenter), navigationControllerClass: navigationViewControllerClass, maskType: .black)
alertView!.canTouchMaskHide = false
if style == .alert {
alertView!.layer.cornerRadius = 10
alertView!.layer.masksToBounds = true
}
return alertView!
}
}

View File

@ -1,132 +0,0 @@
//
// UIViewFrameExtension.swift
// Pods-SSAlertSwiftDemo
//
// Created by yangsq on 2021/8/13.
//
import UIKit
public extension UIView {
var ss_size: CGSize {
get {
frame.size
}
set {
var frame = frame
frame.size = newValue
self.frame = frame
}
}
var ss_origin: CGPoint {
get {
frame.origin
}
set {
var frame = frame
frame.origin = newValue
self.frame = frame
}
}
var ss_center: CGPoint {
get {
center
}
set {
center = newValue
}
}
var ss_x: CGFloat {
get {
frame.origin.x
}
set {
var frame = frame
frame.origin.x = newValue
self.frame = frame
}
}
var ss_maxX: CGFloat {
get {
frame.maxX
}
}
var ss_y: CGFloat {
get {
frame.origin.y
}
set {
var frame = frame
frame.origin.y = newValue
self.frame = frame
}
}
var ss_maxY: CGFloat {
get {
frame.maxY
}
set {
var frame = frame
frame.origin.y = newValue
self.frame = frame
}
}
var ss_w: CGFloat {
get {
frame.width
}
set {
var frame = frame
frame.size.width = newValue
self.frame = frame
}
}
var ss_h: CGFloat {
get {
frame.height
}
set {
var frame = frame
frame.size.height = newValue
self.frame = frame
}
}
var ss_centerX: CGFloat {
get {
center.x
}
set {
var center = center
center.x = newValue
self.center = center
}
}
var ss_centerY: CGFloat {
get {
center.y
}
set {
var center = center
center.y = newValue
self.center = center
}
}
func ss_moveToCenter() {
if let superV = superview {
ss_center = CGPoint(x: superV.frame.width / 2, y: superV.frame.height / 2)
}
}
}

View File

@ -1,19 +0,0 @@
Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -1,155 +0,0 @@
<img src="https://snapkit.github.io/SnapKit/images/banner.jpg" alt="" />
SnapKit is a DSL to make Auto Layout easy on both iOS and OS X.
[![Build Status](https://travis-ci.org/SnapKit/SnapKit.svg)](https://travis-ci.org/SnapKit/SnapKit)
[![Platform](https://img.shields.io/cocoapods/p/SnapKit.svg?style=flat)](https://github.com/SnapKit/SnapKit)
[![Cocoapods Compatible](https://img.shields.io/cocoapods/v/SnapKit.svg)](https://cocoapods.org/pods/SnapKit)
[![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage)
#### ⚠️ **To use with Swift 4.x please ensure you are using >= 4.0.0** ⚠️
#### ⚠️ **To use with Swift 5.x please ensure you are using >= 5.0.0** ⚠️
## Contents
- [Requirements](#requirements)
- [Migration Guides](#migration-guides)
- [Communication](#communication)
- [Installation](#installation)
- [Usage](#usage)
- [Credits](#credits)
- [License](#license)
## Requirements
- iOS 12.0+ / Mac OS X 10.13+ / tvOS 10.0+
- Xcode 10.0+
- Swift 4.0+
## Migration Guides
- [SnapKit 3.0 Migration Guide](Documentation/SnapKit%203.0%20Migration%20Guide.md)
## Communication
- If you **need help**, use [Stack Overflow](http://stackoverflow.com/questions/tagged/snapkit). (Tag 'snapkit')
- If you'd like to **ask a general question**, use [Stack Overflow](http://stackoverflow.com/questions/tagged/snapkit).
- If you **found a bug**, open an issue.
- If you **have a feature request**, open an issue.
- If you **want to contribute**, submit a pull request.
## Installation
### CocoaPods
[CocoaPods](http://cocoapods.org) is a dependency manager for Cocoa projects. You can install it with the following command:
```bash
$ gem install cocoapods
```
> CocoaPods 1.1.0+ is required to build SnapKit 4.0.0+.
To integrate SnapKit into your Xcode project using CocoaPods, specify it in your `Podfile`:
```ruby
source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '10.0'
use_frameworks!
target '<Your Target Name>' do
pod 'SnapKit', '~> 5.7.0'
end
```
Then, run the following command:
```bash
$ pod install
```
### Carthage
[Carthage](https://github.com/Carthage/Carthage) is a decentralized dependency manager that builds your dependencies and provides you with binary frameworks.
You can install Carthage with [Homebrew](http://brew.sh/) using the following command:
```bash
$ brew update
$ brew install carthage
```
To integrate SnapKit into your Xcode project using Carthage, specify it in your `Cartfile`:
```ogdl
github "SnapKit/SnapKit" ~> 5.0.0
```
Run `carthage update` to build the framework and drag the built `SnapKit.framework` into your Xcode project.
### Swift Package Manager
[Swift Package Manager](https://swift.org/package-manager/) is a tool for managing the distribution of Swift code. Its integrated with the Swift build system to automate the process of downloading, compiling, and linking dependencies.
> Xcode 11+ is required to build SnapKit using Swift Package Manager.
To integrate SnapKit into your Xcode project using Swift Package Manager, add it to the dependencies value of your `Package.swift`:
```swift
dependencies: [
.package(url: "https://github.com/SnapKit/SnapKit.git", .upToNextMajor(from: "5.0.1"))
]
```
### Manually
If you prefer not to use either of the aforementioned dependency managers, you can integrate SnapKit into your project manually.
---
## Usage
### Quick Start
```swift
import SnapKit
class MyViewController: UIViewController {
lazy var box = UIView()
override func viewDidLoad() {
super.viewDidLoad()
self.view.addSubview(box)
box.backgroundColor = .green
box.snp.makeConstraints { (make) -> Void in
make.width.height.equalTo(50)
make.center.equalTo(self.view)
}
}
}
```
### Playground
You can try SnapKit in Playground.
**Note:**
> To try SnapKit in playground, open `SnapKit.xcworkspace` and build SnapKit.framework for any simulator first.
### Resources
- [Documentation](https://snapkit.github.io/SnapKit/docs/)
- [F.A.Q.](https://snapkit.github.io/SnapKit/faq/)
## Credits
- Robert Payne ([@robertjpayne](https://twitter.com/robertjpayne))
- Many other contributors
## License
SnapKit is released under the MIT license. See LICENSE for details.

View File

@ -1,341 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public final class Constraint {
internal let sourceLocation: (String, UInt)
internal let label: String?
private let from: ConstraintItem
private let to: ConstraintItem
private let relation: ConstraintRelation
private let multiplier: ConstraintMultiplierTarget
private var constant: ConstraintConstantTarget {
didSet {
self.updateConstantAndPriorityIfNeeded()
}
}
private var priority: ConstraintPriorityTarget {
didSet {
self.updateConstantAndPriorityIfNeeded()
}
}
public var layoutConstraints: [LayoutConstraint]
public var isActive: Bool {
set {
if newValue {
activate()
}
else {
deactivate()
}
}
get {
for layoutConstraint in self.layoutConstraints {
if layoutConstraint.isActive {
return true
}
}
return false
}
}
// MARK: Initialization
internal init(from: ConstraintItem,
to: ConstraintItem,
relation: ConstraintRelation,
sourceLocation: (String, UInt),
label: String?,
multiplier: ConstraintMultiplierTarget,
constant: ConstraintConstantTarget,
priority: ConstraintPriorityTarget) {
self.from = from
self.to = to
self.relation = relation
self.sourceLocation = sourceLocation
self.label = label
self.multiplier = multiplier
self.constant = constant
self.priority = priority
self.layoutConstraints = []
// get attributes
let layoutFromAttributes = self.from.attributes.layoutAttributes
let layoutToAttributes = self.to.attributes.layoutAttributes
// get layout from
let layoutFrom = self.from.layoutConstraintItem!
// get relation
let layoutRelation = self.relation.layoutRelation
for layoutFromAttribute in layoutFromAttributes {
// get layout to attribute
let layoutToAttribute: LayoutAttribute
#if canImport(UIKit)
if layoutToAttributes.count > 0 {
if self.from.attributes == .edges && self.to.attributes == .margins {
switch layoutFromAttribute {
case .left:
layoutToAttribute = .leftMargin
case .right:
layoutToAttribute = .rightMargin
case .top:
layoutToAttribute = .topMargin
case .bottom:
layoutToAttribute = .bottomMargin
default:
fatalError()
}
} else if self.from.attributes == .margins && self.to.attributes == .edges {
switch layoutFromAttribute {
case .leftMargin:
layoutToAttribute = .left
case .rightMargin:
layoutToAttribute = .right
case .topMargin:
layoutToAttribute = .top
case .bottomMargin:
layoutToAttribute = .bottom
default:
fatalError()
}
} else if self.from.attributes == .directionalEdges && self.to.attributes == .directionalMargins {
switch layoutFromAttribute {
case .leading:
layoutToAttribute = .leadingMargin
case .trailing:
layoutToAttribute = .trailingMargin
case .top:
layoutToAttribute = .topMargin
case .bottom:
layoutToAttribute = .bottomMargin
default:
fatalError()
}
} else if self.from.attributes == .directionalMargins && self.to.attributes == .directionalEdges {
switch layoutFromAttribute {
case .leadingMargin:
layoutToAttribute = .leading
case .trailingMargin:
layoutToAttribute = .trailing
case .topMargin:
layoutToAttribute = .top
case .bottomMargin:
layoutToAttribute = .bottom
default:
fatalError()
}
} else if self.from.attributes == self.to.attributes {
layoutToAttribute = layoutFromAttribute
} else {
layoutToAttribute = layoutToAttributes[0]
}
} else {
if self.to.target == nil && (layoutFromAttribute == .centerX || layoutFromAttribute == .centerY) {
layoutToAttribute = layoutFromAttribute == .centerX ? .left : .top
} else {
layoutToAttribute = layoutFromAttribute
}
}
#else
if self.from.attributes == self.to.attributes {
layoutToAttribute = layoutFromAttribute
} else if layoutToAttributes.count > 0 {
layoutToAttribute = layoutToAttributes[0]
} else {
layoutToAttribute = layoutFromAttribute
}
#endif
// get layout constant
let layoutConstant: CGFloat = self.constant.constraintConstantTargetValueFor(layoutAttribute: layoutToAttribute)
// get layout to
var layoutTo: AnyObject? = self.to.target
// use superview if possible
if layoutTo == nil && layoutToAttribute != .width && layoutToAttribute != .height {
layoutTo = layoutFrom.superview
}
// create layout constraint
let layoutConstraint = LayoutConstraint(
item: layoutFrom,
attribute: layoutFromAttribute,
relatedBy: layoutRelation,
toItem: layoutTo,
attribute: layoutToAttribute,
multiplier: self.multiplier.constraintMultiplierTargetValue,
constant: layoutConstant
)
// set label
layoutConstraint.label = self.label
// set priority
layoutConstraint.priority = LayoutPriority(rawValue: self.priority.constraintPriorityTargetValue)
// set constraint
layoutConstraint.constraint = self
// append
self.layoutConstraints.append(layoutConstraint)
}
}
// MARK: Public
@available(*, deprecated, renamed:"activate()")
public func install() {
self.activate()
}
@available(*, deprecated, renamed:"deactivate()")
public func uninstall() {
self.deactivate()
}
public func activate() {
self.activateIfNeeded()
}
public func deactivate() {
self.deactivateIfNeeded()
}
@discardableResult
public func update(offset: ConstraintOffsetTarget) -> Constraint {
self.constant = offset.constraintOffsetTargetValue
return self
}
@discardableResult
public func update(inset: ConstraintInsetTarget) -> Constraint {
self.constant = inset.constraintInsetTargetValue
return self
}
#if canImport(UIKit)
@discardableResult
@available(iOS 11.0, tvOS 11.0, *)
public func update(inset: ConstraintDirectionalInsetTarget) -> Constraint {
self.constant = inset.constraintDirectionalInsetTargetValue
return self
}
#endif
@discardableResult
public func update(priority: ConstraintPriorityTarget) -> Constraint {
self.priority = priority.constraintPriorityTargetValue
return self
}
@discardableResult
public func update(priority: ConstraintPriority) -> Constraint {
self.priority = priority.value
return self
}
@available(*, deprecated, renamed:"update(offset:)")
public func updateOffset(amount: ConstraintOffsetTarget) -> Void { self.update(offset: amount) }
@available(*, deprecated, renamed:"update(inset:)")
public func updateInsets(amount: ConstraintInsetTarget) -> Void { self.update(inset: amount) }
@available(*, deprecated, renamed:"update(priority:)")
public func updatePriority(amount: ConstraintPriorityTarget) -> Void { self.update(priority: amount) }
@available(*, deprecated, message:"Use update(priority: ConstraintPriorityTarget) instead.")
public func updatePriorityRequired() -> Void {}
@available(*, deprecated, message:"Use update(priority: ConstraintPriorityTarget) instead.")
public func updatePriorityHigh() -> Void { fatalError("Must be implemented by Concrete subclass.") }
@available(*, deprecated, message:"Use update(priority: ConstraintPriorityTarget) instead.")
public func updatePriorityMedium() -> Void { fatalError("Must be implemented by Concrete subclass.") }
@available(*, deprecated, message:"Use update(priority: ConstraintPriorityTarget) instead.")
public func updatePriorityLow() -> Void { fatalError("Must be implemented by Concrete subclass.") }
// MARK: Internal
internal func updateConstantAndPriorityIfNeeded() {
for layoutConstraint in self.layoutConstraints {
let attribute = (layoutConstraint.secondAttribute == .notAnAttribute) ? layoutConstraint.firstAttribute : layoutConstraint.secondAttribute
layoutConstraint.constant = self.constant.constraintConstantTargetValueFor(layoutAttribute: attribute)
let requiredPriority = ConstraintPriority.required.value
if (layoutConstraint.priority.rawValue < requiredPriority), (self.priority.constraintPriorityTargetValue != requiredPriority) {
layoutConstraint.priority = LayoutPriority(rawValue: self.priority.constraintPriorityTargetValue)
}
}
}
internal func activateIfNeeded(updatingExisting: Bool = false) {
guard let item = self.from.layoutConstraintItem else {
print("WARNING: SnapKit failed to get from item from constraint. Activate will be a no-op.")
return
}
let layoutConstraints = self.layoutConstraints
if updatingExisting {
var existingLayoutConstraints: [LayoutConstraint] = []
for constraint in item.constraints {
existingLayoutConstraints += constraint.layoutConstraints
}
for layoutConstraint in layoutConstraints {
let existingLayoutConstraint = existingLayoutConstraints.first { $0 == layoutConstraint }
guard let updateLayoutConstraint = existingLayoutConstraint else {
fatalError("Updated constraint could not find existing matching constraint to update: \(layoutConstraint)")
}
let updateLayoutAttribute = (updateLayoutConstraint.secondAttribute == .notAnAttribute) ? updateLayoutConstraint.firstAttribute : updateLayoutConstraint.secondAttribute
updateLayoutConstraint.constant = self.constant.constraintConstantTargetValueFor(layoutAttribute: updateLayoutAttribute)
}
} else {
NSLayoutConstraint.activate(layoutConstraints)
item.add(constraints: [self])
}
}
internal func deactivateIfNeeded() {
guard let item = self.from.layoutConstraintItem else {
print("WARNING: SnapKit failed to get from item from constraint. Deactivate will be a no-op.")
return
}
let layoutConstraints = self.layoutConstraints
NSLayoutConstraint.deactivate(layoutConstraints)
item.remove(constraints: [self])
}
}

View File

@ -1,203 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
internal struct ConstraintAttributes : OptionSet, ExpressibleByIntegerLiteral {
typealias IntegerLiteralType = UInt
internal init(rawValue: UInt) {
self.rawValue = rawValue
}
internal init(_ rawValue: UInt) {
self.init(rawValue: rawValue)
}
internal init(nilLiteral: ()) {
self.rawValue = 0
}
internal init(integerLiteral rawValue: IntegerLiteralType) {
self.init(rawValue: rawValue)
}
internal private(set) var rawValue: UInt
internal static var allZeros: ConstraintAttributes { return 0 }
internal static func convertFromNilLiteral() -> ConstraintAttributes { return 0 }
internal var boolValue: Bool { return self.rawValue != 0 }
internal func toRaw() -> UInt { return self.rawValue }
internal static func fromRaw(_ raw: UInt) -> ConstraintAttributes? { return self.init(raw) }
internal static func fromMask(_ raw: UInt) -> ConstraintAttributes { return self.init(raw) }
// normal
internal static let none: ConstraintAttributes = 0
internal static let left: ConstraintAttributes = ConstraintAttributes(UInt(1) << 0)
internal static let top: ConstraintAttributes = ConstraintAttributes(UInt(1) << 1)
internal static let right: ConstraintAttributes = ConstraintAttributes(UInt(1) << 2)
internal static let bottom: ConstraintAttributes = ConstraintAttributes(UInt(1) << 3)
internal static let leading: ConstraintAttributes = ConstraintAttributes(UInt(1) << 4)
internal static let trailing: ConstraintAttributes = ConstraintAttributes(UInt(1) << 5)
internal static let width: ConstraintAttributes = ConstraintAttributes(UInt(1) << 6)
internal static let height: ConstraintAttributes = ConstraintAttributes(UInt(1) << 7)
internal static let centerX: ConstraintAttributes = ConstraintAttributes(UInt(1) << 8)
internal static let centerY: ConstraintAttributes = ConstraintAttributes(UInt(1) << 9)
internal static let lastBaseline: ConstraintAttributes = ConstraintAttributes(UInt(1) << 10)
@available(iOS 8.0, OSX 10.11, *)
internal static let firstBaseline: ConstraintAttributes = ConstraintAttributes(UInt(1) << 11)
@available(iOS 8.0, *)
internal static let leftMargin: ConstraintAttributes = ConstraintAttributes(UInt(1) << 12)
@available(iOS 8.0, *)
internal static let rightMargin: ConstraintAttributes = ConstraintAttributes(UInt(1) << 13)
@available(iOS 8.0, *)
internal static let topMargin: ConstraintAttributes = ConstraintAttributes(UInt(1) << 14)
@available(iOS 8.0, *)
internal static let bottomMargin: ConstraintAttributes = ConstraintAttributes(UInt(1) << 15)
@available(iOS 8.0, *)
internal static let leadingMargin: ConstraintAttributes = ConstraintAttributes(UInt(1) << 16)
@available(iOS 8.0, *)
internal static let trailingMargin: ConstraintAttributes = ConstraintAttributes(UInt(1) << 17)
@available(iOS 8.0, *)
internal static let centerXWithinMargins: ConstraintAttributes = ConstraintAttributes(UInt(1) << 18)
@available(iOS 8.0, *)
internal static let centerYWithinMargins: ConstraintAttributes = ConstraintAttributes(UInt(1) << 19)
// aggregates
internal static let edges: ConstraintAttributes = [.horizontalEdges, .verticalEdges]
internal static let horizontalEdges: ConstraintAttributes = [.left, .right]
internal static let verticalEdges: ConstraintAttributes = [.top, .bottom]
internal static let directionalEdges: ConstraintAttributes = [.directionalHorizontalEdges, .directionalVerticalEdges]
internal static let directionalHorizontalEdges: ConstraintAttributes = [.leading, .trailing]
internal static let directionalVerticalEdges: ConstraintAttributes = [.top, .bottom]
internal static let size: ConstraintAttributes = [.width, .height]
internal static let center: ConstraintAttributes = [.centerX, .centerY]
@available(iOS 8.0, *)
internal static let margins: ConstraintAttributes = [.leftMargin, .topMargin, .rightMargin, .bottomMargin]
@available(iOS 8.0, *)
internal static let directionalMargins: ConstraintAttributes = [.leadingMargin, .topMargin, .trailingMargin, .bottomMargin]
@available(iOS 8.0, *)
internal static let centerWithinMargins: ConstraintAttributes = [.centerXWithinMargins, .centerYWithinMargins]
internal var layoutAttributes:[LayoutAttribute] {
var attrs = [LayoutAttribute]()
if (self.contains(ConstraintAttributes.left)) {
attrs.append(.left)
}
if (self.contains(ConstraintAttributes.top)) {
attrs.append(.top)
}
if (self.contains(ConstraintAttributes.right)) {
attrs.append(.right)
}
if (self.contains(ConstraintAttributes.bottom)) {
attrs.append(.bottom)
}
if (self.contains(ConstraintAttributes.leading)) {
attrs.append(.leading)
}
if (self.contains(ConstraintAttributes.trailing)) {
attrs.append(.trailing)
}
if (self.contains(ConstraintAttributes.width)) {
attrs.append(.width)
}
if (self.contains(ConstraintAttributes.height)) {
attrs.append(.height)
}
if (self.contains(ConstraintAttributes.centerX)) {
attrs.append(.centerX)
}
if (self.contains(ConstraintAttributes.centerY)) {
attrs.append(.centerY)
}
if (self.contains(ConstraintAttributes.lastBaseline)) {
attrs.append(.lastBaseline)
}
#if canImport(UIKit)
if (self.contains(ConstraintAttributes.firstBaseline)) {
attrs.append(.firstBaseline)
}
if (self.contains(ConstraintAttributes.leftMargin)) {
attrs.append(.leftMargin)
}
if (self.contains(ConstraintAttributes.rightMargin)) {
attrs.append(.rightMargin)
}
if (self.contains(ConstraintAttributes.topMargin)) {
attrs.append(.topMargin)
}
if (self.contains(ConstraintAttributes.bottomMargin)) {
attrs.append(.bottomMargin)
}
if (self.contains(ConstraintAttributes.leadingMargin)) {
attrs.append(.leadingMargin)
}
if (self.contains(ConstraintAttributes.trailingMargin)) {
attrs.append(.trailingMargin)
}
if (self.contains(ConstraintAttributes.centerXWithinMargins)) {
attrs.append(.centerXWithinMargins)
}
if (self.contains(ConstraintAttributes.centerYWithinMargins)) {
attrs.append(.centerYWithinMargins)
}
#endif
return attrs
}
}
internal func + (left: ConstraintAttributes, right: ConstraintAttributes) -> ConstraintAttributes {
return left.union(right)
}
internal func +=(left: inout ConstraintAttributes, right: ConstraintAttributes) {
left.formUnion(right)
}
internal func -=(left: inout ConstraintAttributes, right: ConstraintAttributes) {
left.subtract(right)
}
internal func ==(left: ConstraintAttributes, right: ConstraintAttributes) -> Bool {
return left.rawValue == right.rawValue
}

View File

@ -1,37 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
public typealias ConstraintInterfaceLayoutDirection = UIUserInterfaceLayoutDirection
#else
import AppKit
public typealias ConstraintInterfaceLayoutDirection = NSUserInterfaceLayoutDirection
#endif
public struct ConstraintConfig {
public static var interfaceLayoutDirection: ConstraintInterfaceLayoutDirection = .leftToRight
}

View File

@ -1,213 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public protocol ConstraintConstantTarget {
}
extension CGPoint: ConstraintConstantTarget {
}
extension CGSize: ConstraintConstantTarget {
}
extension ConstraintInsets: ConstraintConstantTarget {
}
#if canImport(UIKit)
@available(iOS 11.0, tvOS 11.0, *)
extension ConstraintDirectionalInsets: ConstraintConstantTarget {
}
#endif
extension ConstraintConstantTarget {
internal func constraintConstantTargetValueFor(layoutAttribute: LayoutAttribute) -> CGFloat {
if let value = self as? CGFloat {
return value
}
if let value = self as? Float {
return CGFloat(value)
}
if let value = self as? Double {
return CGFloat(value)
}
if let value = self as? Int {
return CGFloat(value)
}
if let value = self as? UInt {
return CGFloat(value)
}
if let value = self as? CGSize {
if layoutAttribute == .width {
return value.width
} else if layoutAttribute == .height {
return value.height
} else {
return 0.0
}
}
if let value = self as? CGPoint {
#if canImport(UIKit)
switch layoutAttribute {
case .left, .right, .leading, .trailing, .centerX, .leftMargin, .rightMargin, .leadingMargin, .trailingMargin, .centerXWithinMargins:
return value.x
case .top, .bottom, .centerY, .topMargin, .bottomMargin, .centerYWithinMargins, .lastBaseline, .firstBaseline:
return value.y
case .width, .height, .notAnAttribute:
return 0.0
#if swift(>=5.0)
@unknown default:
return 0.0
#endif
}
#else
switch layoutAttribute {
case .left, .right, .leading, .trailing, .centerX:
return value.x
case .top, .bottom, .centerY, .lastBaseline, .firstBaseline:
return value.y
case .width, .height, .notAnAttribute:
return 0.0
#if swift(>=5.0)
@unknown default:
return 0.0
#endif
}
#endif
}
if let value = self as? ConstraintInsets {
#if canImport(UIKit)
switch layoutAttribute {
case .left, .leftMargin:
return value.left
case .top, .topMargin, .firstBaseline:
return value.top
case .right, .rightMargin:
return -value.right
case .bottom, .bottomMargin, .lastBaseline:
return -value.bottom
case .leading, .leadingMargin:
return (ConstraintConfig.interfaceLayoutDirection == .leftToRight) ? value.left : value.right
case .trailing, .trailingMargin:
return (ConstraintConfig.interfaceLayoutDirection == .leftToRight) ? -value.right : -value.left
case .centerX, .centerXWithinMargins:
return (value.left - value.right) / 2
case .centerY, .centerYWithinMargins:
return (value.top - value.bottom) / 2
case .width:
return -(value.left + value.right)
case .height:
return -(value.top + value.bottom)
case .notAnAttribute:
return 0.0
#if swift(>=5.0)
@unknown default:
return 0.0
#endif
}
#else
switch layoutAttribute {
case .left:
return value.left
case .top, .firstBaseline:
return value.top
case .right:
return -value.right
case .bottom, .lastBaseline:
return -value.bottom
case .leading:
return (ConstraintConfig.interfaceLayoutDirection == .leftToRight) ? value.left : value.right
case .trailing:
return (ConstraintConfig.interfaceLayoutDirection == .leftToRight) ? -value.right : -value.left
case .centerX:
return (value.left - value.right) / 2
case .centerY:
return (value.top - value.bottom) / 2
case .width:
return -(value.left + value.right)
case .height:
return -(value.top + value.bottom)
case .notAnAttribute:
return 0.0
#if swift(>=5.0)
@unknown default:
return 0.0
#endif
}
#endif
}
#if canImport(UIKit)
if #available(iOS 11.0, tvOS 11.0, *), let value = self as? ConstraintDirectionalInsets {
switch layoutAttribute {
case .left, .leftMargin:
return (ConstraintConfig.interfaceLayoutDirection == .leftToRight) ? value.leading : value.trailing
case .top, .topMargin, .firstBaseline:
return value.top
case .right, .rightMargin:
return (ConstraintConfig.interfaceLayoutDirection == .leftToRight) ? -value.trailing : -value.leading
case .bottom, .bottomMargin, .lastBaseline:
return -value.bottom
case .leading, .leadingMargin:
return value.leading
case .trailing, .trailingMargin:
return -value.trailing
case .centerX, .centerXWithinMargins:
return (value.leading - value.trailing) / 2
case .centerY, .centerYWithinMargins:
return (value.top - value.bottom) / 2
case .width:
return -(value.leading + value.trailing)
case .height:
return -(value.top + value.bottom)
case .notAnAttribute:
return 0.0
#if swift(>=5.0)
@unknown default:
return 0.0
#else
default:
return 0.0
#endif
}
}
#endif
return 0.0
}
}

View File

@ -1,209 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public protocol ConstraintDSL {
var target: AnyObject? { get }
func setLabel(_ value: String?)
func label() -> String?
}
extension ConstraintDSL {
public func setLabel(_ value: String?) {
objc_setAssociatedObject(self.target as Any, &labelKey, value, .OBJC_ASSOCIATION_COPY_NONATOMIC)
}
public func label() -> String? {
return objc_getAssociatedObject(self.target as Any, &labelKey) as? String
}
}
private var labelKey: UInt8 = 0
public protocol ConstraintBasicAttributesDSL : ConstraintDSL {
}
extension ConstraintBasicAttributesDSL {
// MARK: Basics
public var left: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.left)
}
public var top: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.top)
}
public var right: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.right)
}
public var bottom: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.bottom)
}
public var leading: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.leading)
}
public var trailing: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.trailing)
}
public var width: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.width)
}
public var height: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.height)
}
public var centerX: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.centerX)
}
public var centerY: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.centerY)
}
public var edges: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.edges)
}
public var directionalEdges: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.directionalEdges)
}
public var horizontalEdges: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.horizontalEdges)
}
public var verticalEdges: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.verticalEdges)
}
public var directionalHorizontalEdges: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.directionalHorizontalEdges)
}
public var directionalVerticalEdges: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.directionalVerticalEdges)
}
public var size: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.size)
}
public var center: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.center)
}
}
public protocol ConstraintAttributesDSL : ConstraintBasicAttributesDSL {
}
extension ConstraintAttributesDSL {
// MARK: Baselines
@available(*, deprecated, renamed:"lastBaseline")
public var baseline: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.lastBaseline)
}
@available(iOS 8.0, OSX 10.11, *)
public var lastBaseline: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.lastBaseline)
}
@available(iOS 8.0, OSX 10.11, *)
public var firstBaseline: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.firstBaseline)
}
// MARK: Margins
@available(iOS 8.0, *)
public var leftMargin: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.leftMargin)
}
@available(iOS 8.0, *)
public var topMargin: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.topMargin)
}
@available(iOS 8.0, *)
public var rightMargin: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.rightMargin)
}
@available(iOS 8.0, *)
public var bottomMargin: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.bottomMargin)
}
@available(iOS 8.0, *)
public var leadingMargin: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.leadingMargin)
}
@available(iOS 8.0, *)
public var trailingMargin: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.trailingMargin)
}
@available(iOS 8.0, *)
public var centerXWithinMargins: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.centerXWithinMargins)
}
@available(iOS 8.0, *)
public var centerYWithinMargins: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.centerYWithinMargins)
}
@available(iOS 8.0, *)
public var margins: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.margins)
}
@available(iOS 8.0, *)
public var directionalMargins: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.directionalMargins)
}
@available(iOS 8.0, *)
public var centerWithinMargins: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.centerWithinMargins)
}
}

View File

@ -1,69 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public class ConstraintDescription {
internal let item: LayoutConstraintItem
internal var attributes: ConstraintAttributes
internal var relation: ConstraintRelation? = nil
internal var sourceLocation: (String, UInt)? = nil
internal var label: String? = nil
internal var related: ConstraintItem? = nil
internal var multiplier: ConstraintMultiplierTarget = 1.0
internal var constant: ConstraintConstantTarget = 0.0
internal var priority: ConstraintPriorityTarget = 1000.0
internal lazy var constraint: Constraint? = {
guard let relation = self.relation,
let related = self.related,
let sourceLocation = self.sourceLocation else {
return nil
}
let from = ConstraintItem(target: self.item, attributes: self.attributes)
return Constraint(
from: from,
to: related,
relation: relation,
sourceLocation: sourceLocation,
label: self.label,
multiplier: self.multiplier,
constant: self.constant,
priority: self.priority
)
}()
// MARK: Initialization
internal init(item: LayoutConstraintItem, attributes: ConstraintAttributes) {
self.item = item
self.attributes = attributes
}
}

View File

@ -1,49 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
#if canImport(UIKit)
public protocol ConstraintDirectionalInsetTarget: ConstraintConstantTarget {
}
@available(iOS 11.0, tvOS 11.0, *)
extension ConstraintDirectionalInsets: ConstraintDirectionalInsetTarget {
}
extension ConstraintDirectionalInsetTarget {
@available(iOS 11.0, tvOS 11.0, *)
internal var constraintDirectionalInsetTargetValue: ConstraintDirectionalInsets {
if let amount = self as? ConstraintDirectionalInsets {
return amount
} else {
return ConstraintDirectionalInsets(top: 0, leading: 0, bottom: 0, trailing: 0)
}
}
}
#endif

View File

@ -1,34 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
#if canImport(UIKit)
@available(iOS 11.0, tvOS 11.0, *)
public typealias ConstraintDirectionalInsets = NSDirectionalEdgeInsets
#endif

View File

@ -1,72 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public protocol ConstraintInsetTarget: ConstraintConstantTarget {
}
extension Int: ConstraintInsetTarget {
}
extension UInt: ConstraintInsetTarget {
}
extension Float: ConstraintInsetTarget {
}
extension Double: ConstraintInsetTarget {
}
extension CGFloat: ConstraintInsetTarget {
}
extension ConstraintInsets: ConstraintInsetTarget {
}
extension ConstraintInsetTarget {
internal var constraintInsetTargetValue: ConstraintInsets {
if let amount = self as? ConstraintInsets {
return amount
} else if let amount = self as? Float {
return ConstraintInsets(top: CGFloat(amount), left: CGFloat(amount), bottom: CGFloat(amount), right: CGFloat(amount))
} else if let amount = self as? Double {
return ConstraintInsets(top: CGFloat(amount), left: CGFloat(amount), bottom: CGFloat(amount), right: CGFloat(amount))
} else if let amount = self as? CGFloat {
return ConstraintInsets(top: amount, left: amount, bottom: amount, right: amount)
} else if let amount = self as? Int {
return ConstraintInsets(top: CGFloat(amount), left: CGFloat(amount), bottom: CGFloat(amount), right: CGFloat(amount))
} else if let amount = self as? UInt {
return ConstraintInsets(top: CGFloat(amount), left: CGFloat(amount), bottom: CGFloat(amount), right: CGFloat(amount))
} else {
return ConstraintInsets(top: 0, left: 0, bottom: 0, right: 0)
}
}
}

View File

@ -1,35 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
#if canImport(UIKit)
public typealias ConstraintInsets = UIEdgeInsets
#else
public typealias ConstraintInsets = NSEdgeInsets
#endif

View File

@ -1,61 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public final class ConstraintItem {
internal weak var target: AnyObject?
internal let attributes: ConstraintAttributes
internal init(target: AnyObject?, attributes: ConstraintAttributes) {
self.target = target
self.attributes = attributes
}
internal var layoutConstraintItem: LayoutConstraintItem? {
return self.target as? LayoutConstraintItem
}
}
public func ==(lhs: ConstraintItem, rhs: ConstraintItem) -> Bool {
// pointer equality
guard lhs !== rhs else {
return true
}
// must both have valid targets and identical attributes
guard let target1 = lhs.target,
let target2 = rhs.target,
target1 === target2 && lhs.attributes == rhs.attributes else {
return false
}
return true
}

View File

@ -1,36 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#endif
@available(iOS 9.0, OSX 10.11, *)
public extension ConstraintLayoutGuide {
var snp: ConstraintLayoutGuideDSL {
return ConstraintLayoutGuideDSL(guide: self)
}
}

View File

@ -1,37 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
#if canImport(UIKit)
@available(iOS 9.0, *)
public typealias ConstraintLayoutGuide = UILayoutGuide
#else
@available(OSX 10.11, *)
public typealias ConstraintLayoutGuide = NSLayoutGuide
#endif

View File

@ -1,66 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
@available(iOS 9.0, OSX 10.11, *)
public struct ConstraintLayoutGuideDSL: ConstraintAttributesDSL {
@discardableResult
public func prepareConstraints(_ closure: (_ make: ConstraintMaker) -> Void) -> [Constraint] {
return ConstraintMaker.prepareConstraints(item: self.guide, closure: closure)
}
public func makeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
ConstraintMaker.makeConstraints(item: self.guide, closure: closure)
}
public func remakeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
ConstraintMaker.remakeConstraints(item: self.guide, closure: closure)
}
public func updateConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
ConstraintMaker.updateConstraints(item: self.guide, closure: closure)
}
public func removeConstraints() {
ConstraintMaker.removeConstraints(item: self.guide)
}
public var target: AnyObject? {
return self.guide
}
internal let guide: ConstraintLayoutGuide
internal init(guide: ConstraintLayoutGuide) {
self.guide = guide
}
}

View File

@ -1,36 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
#if canImport(UIKit)
@available(iOS 8.0, *)
public typealias ConstraintLayoutSupport = UILayoutSupport
#else
public class ConstraintLayoutSupport {}
#endif

View File

@ -1,56 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
@available(iOS 8.0, *)
public struct ConstraintLayoutSupportDSL: ConstraintDSL {
public var target: AnyObject? {
return self.support
}
internal let support: ConstraintLayoutSupport
internal init(support: ConstraintLayoutSupport) {
self.support = support
}
public var top: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.top)
}
public var bottom: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.bottom)
}
public var height: ConstraintItem {
return ConstraintItem(target: self.target, attributes: ConstraintAttributes.height)
}
}

View File

@ -1,224 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public class ConstraintMaker {
public var left: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.left)
}
public var top: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.top)
}
public var bottom: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.bottom)
}
public var right: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.right)
}
public var leading: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.leading)
}
public var trailing: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.trailing)
}
public var width: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.width)
}
public var height: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.height)
}
public var centerX: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.centerX)
}
public var centerY: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.centerY)
}
@available(*, deprecated, renamed:"lastBaseline")
public var baseline: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.lastBaseline)
}
public var lastBaseline: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.lastBaseline)
}
@available(iOS 8.0, OSX 10.11, *)
public var firstBaseline: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.firstBaseline)
}
@available(iOS 8.0, *)
public var leftMargin: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.leftMargin)
}
@available(iOS 8.0, *)
public var rightMargin: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.rightMargin)
}
@available(iOS 8.0, *)
public var topMargin: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.topMargin)
}
@available(iOS 8.0, *)
public var bottomMargin: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.bottomMargin)
}
@available(iOS 8.0, *)
public var leadingMargin: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.leadingMargin)
}
@available(iOS 8.0, *)
public var trailingMargin: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.trailingMargin)
}
@available(iOS 8.0, *)
public var centerXWithinMargins: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.centerXWithinMargins)
}
@available(iOS 8.0, *)
public var centerYWithinMargins: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.centerYWithinMargins)
}
public var edges: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.edges)
}
public var horizontalEdges: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.horizontalEdges)
}
public var verticalEdges: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.verticalEdges)
}
public var directionalEdges: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.directionalEdges)
}
public var directionalHorizontalEdges: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.directionalHorizontalEdges)
}
public var directionalVerticalEdges: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.directionalVerticalEdges)
}
public var size: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.size)
}
public var center: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.center)
}
@available(iOS 8.0, *)
public var margins: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.margins)
}
@available(iOS 8.0, *)
public var directionalMargins: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.directionalMargins)
}
@available(iOS 8.0, *)
public var centerWithinMargins: ConstraintMakerExtendable {
return self.makeExtendableWithAttributes(.centerWithinMargins)
}
public let item: LayoutConstraintItem
private var descriptions = [ConstraintDescription]()
internal init(item: LayoutConstraintItem) {
self.item = item
self.item.prepare()
}
internal func makeExtendableWithAttributes(_ attributes: ConstraintAttributes) -> ConstraintMakerExtendable {
let description = ConstraintDescription(item: self.item, attributes: attributes)
self.descriptions.append(description)
return ConstraintMakerExtendable(description)
}
internal static func prepareConstraints(item: LayoutConstraintItem, closure: (_ make: ConstraintMaker) -> Void) -> [Constraint] {
let maker = ConstraintMaker(item: item)
closure(maker)
var constraints: [Constraint] = []
for description in maker.descriptions {
guard let constraint = description.constraint else {
continue
}
constraints.append(constraint)
}
return constraints
}
internal static func makeConstraints(item: LayoutConstraintItem, closure: (_ make: ConstraintMaker) -> Void) {
let constraints = prepareConstraints(item: item, closure: closure)
for constraint in constraints {
constraint.activateIfNeeded(updatingExisting: false)
}
}
internal static func remakeConstraints(item: LayoutConstraintItem, closure: (_ make: ConstraintMaker) -> Void) {
self.removeConstraints(item: item)
self.makeConstraints(item: item, closure: closure)
}
internal static func updateConstraints(item: LayoutConstraintItem, closure: (_ make: ConstraintMaker) -> Void) {
guard item.constraints.count > 0 else {
self.makeConstraints(item: item, closure: closure)
return
}
let constraints = prepareConstraints(item: item, closure: closure)
for constraint in constraints {
constraint.activateIfNeeded(updatingExisting: true)
}
}
internal static func removeConstraints(item: LayoutConstraintItem) {
let constraints = item.constraints
for constraint in constraints {
constraint.deactivateIfNeeded()
}
}
}

View File

@ -1,64 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public class ConstraintMakerEditable: ConstraintMakerPrioritizable {
@discardableResult
public func multipliedBy(_ amount: ConstraintMultiplierTarget) -> ConstraintMakerEditable {
self.description.multiplier = amount
return self
}
@discardableResult
public func dividedBy(_ amount: ConstraintMultiplierTarget) -> ConstraintMakerEditable {
return self.multipliedBy(1.0 / amount.constraintMultiplierTargetValue)
}
@discardableResult
public func offset(_ amount: ConstraintOffsetTarget) -> ConstraintMakerEditable {
self.description.constant = amount.constraintOffsetTargetValue
return self
}
@discardableResult
public func inset(_ amount: ConstraintInsetTarget) -> ConstraintMakerEditable {
self.description.constant = amount.constraintInsetTargetValue
return self
}
#if canImport(UIKit)
@discardableResult
@available(iOS 11.0, tvOS 11.0, *)
public func inset(_ amount: ConstraintDirectionalInsetTarget) -> ConstraintMakerEditable {
self.description.constant = amount.constraintDirectionalInsetTargetValue
return self
}
#endif
}

View File

@ -1,195 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public class ConstraintMakerExtendable: ConstraintMakerRelatable {
public var left: ConstraintMakerExtendable {
self.description.attributes += .left
return self
}
public var top: ConstraintMakerExtendable {
self.description.attributes += .top
return self
}
public var bottom: ConstraintMakerExtendable {
self.description.attributes += .bottom
return self
}
public var right: ConstraintMakerExtendable {
self.description.attributes += .right
return self
}
public var leading: ConstraintMakerExtendable {
self.description.attributes += .leading
return self
}
public var trailing: ConstraintMakerExtendable {
self.description.attributes += .trailing
return self
}
public var width: ConstraintMakerExtendable {
self.description.attributes += .width
return self
}
public var height: ConstraintMakerExtendable {
self.description.attributes += .height
return self
}
public var centerX: ConstraintMakerExtendable {
self.description.attributes += .centerX
return self
}
public var centerY: ConstraintMakerExtendable {
self.description.attributes += .centerY
return self
}
@available(*, deprecated, renamed:"lastBaseline")
public var baseline: ConstraintMakerExtendable {
self.description.attributes += .lastBaseline
return self
}
public var lastBaseline: ConstraintMakerExtendable {
self.description.attributes += .lastBaseline
return self
}
@available(iOS 8.0, OSX 10.11, *)
public var firstBaseline: ConstraintMakerExtendable {
self.description.attributes += .firstBaseline
return self
}
@available(iOS 8.0, *)
public var leftMargin: ConstraintMakerExtendable {
self.description.attributes += .leftMargin
return self
}
@available(iOS 8.0, *)
public var rightMargin: ConstraintMakerExtendable {
self.description.attributes += .rightMargin
return self
}
@available(iOS 8.0, *)
public var topMargin: ConstraintMakerExtendable {
self.description.attributes += .topMargin
return self
}
@available(iOS 8.0, *)
public var bottomMargin: ConstraintMakerExtendable {
self.description.attributes += .bottomMargin
return self
}
@available(iOS 8.0, *)
public var leadingMargin: ConstraintMakerExtendable {
self.description.attributes += .leadingMargin
return self
}
@available(iOS 8.0, *)
public var trailingMargin: ConstraintMakerExtendable {
self.description.attributes += .trailingMargin
return self
}
@available(iOS 8.0, *)
public var centerXWithinMargins: ConstraintMakerExtendable {
self.description.attributes += .centerXWithinMargins
return self
}
@available(iOS 8.0, *)
public var centerYWithinMargins: ConstraintMakerExtendable {
self.description.attributes += .centerYWithinMargins
return self
}
public var edges: ConstraintMakerExtendable {
self.description.attributes += .edges
return self
}
public var horizontalEdges: ConstraintMakerExtendable {
self.description.attributes += .horizontalEdges
return self
}
public var verticalEdges: ConstraintMakerExtendable {
self.description.attributes += .verticalEdges
return self
}
public var directionalEdges: ConstraintMakerExtendable {
self.description.attributes += .directionalEdges
return self
}
public var directionalHorizontalEdges: ConstraintMakerExtendable {
self.description.attributes += .directionalHorizontalEdges
return self
}
public var directionalVerticalEdges: ConstraintMakerExtendable {
self.description.attributes += .directionalVerticalEdges
return self
}
public var size: ConstraintMakerExtendable {
self.description.attributes += .size
return self
}
@available(iOS 8.0, *)
public var margins: ConstraintMakerExtendable {
self.description.attributes += .margins
return self
}
@available(iOS 8.0, *)
public var directionalMargins: ConstraintMakerExtendable {
self.description.attributes += .directionalMargins
return self
}
@available(iOS 8.0, *)
public var centerWithinMargins: ConstraintMakerExtendable {
self.description.attributes += .centerWithinMargins
return self
}
}

View File

@ -1,49 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public class ConstraintMakerFinalizable {
internal let description: ConstraintDescription
internal init(_ description: ConstraintDescription) {
self.description = description
}
@discardableResult
public func labeled(_ label: String) -> ConstraintMakerFinalizable {
self.description.label = label
return self
}
public var constraint: Constraint {
return self.description.constraint!
}
}

View File

@ -1,70 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
@available(*, deprecated, message:"Use ConstraintMakerPrioritizable instead.")
public typealias ConstraintMakerPriortizable = ConstraintMakerPrioritizable
public class ConstraintMakerPrioritizable: ConstraintMakerFinalizable {
@discardableResult
public func priority(_ amount: ConstraintPriority) -> ConstraintMakerFinalizable {
self.description.priority = amount.value
return self
}
@discardableResult
public func priority(_ amount: ConstraintPriorityTarget) -> ConstraintMakerFinalizable {
self.description.priority = amount
return self
}
@available(*, deprecated, message:"Use priority(.required) instead.")
@discardableResult
public func priorityRequired() -> ConstraintMakerFinalizable {
return self.priority(.required)
}
@available(*, deprecated, message:"Use priority(.high) instead.")
@discardableResult
public func priorityHigh() -> ConstraintMakerFinalizable {
return self.priority(.high)
}
@available(*, deprecated, message:"Use priority(.medium) instead.")
@discardableResult
public func priorityMedium() -> ConstraintMakerFinalizable {
return self.priority(.medium)
}
@available(*, deprecated, message:"Use priority(.low) instead.")
@discardableResult
public func priorityLow() -> ConstraintMakerFinalizable {
return self.priority(.low)
}
}

View File

@ -1,57 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
extension ConstraintMakerRelatable {
@discardableResult
public func equalToSuperview<T: ConstraintRelatableTarget>(_ closure: (ConstraintView) -> T, _ file: String = #file, line: UInt = #line) -> ConstraintMakerEditable {
guard let other = self.description.item.superview else {
fatalError("Expected superview but found nil when attempting make constraint `equalToSuperview`.")
}
return self.relatedTo(closure(other), relation: .equal, file: file, line: line)
}
@discardableResult
public func lessThanOrEqualToSuperview<T: ConstraintRelatableTarget>(_ closure: (ConstraintView) -> T, _ file: String = #file, line: UInt = #line) -> ConstraintMakerEditable {
guard let other = self.description.item.superview else {
fatalError("Expected superview but found nil when attempting make constraint `lessThanOrEqualToSuperview`.")
}
return self.relatedTo(closure(other), relation: .lessThanOrEqual, file: file, line: line)
}
@discardableResult
public func greaterThanOrEqualToSuperview<T: ConstraintRelatableTarget>(_ closure: (ConstraintView) -> T, _ file: String = #file, line: UInt = #line) -> ConstraintMakerEditable {
guard let other = self.description.item.superview else {
fatalError("Expected superview but found nil when attempting make constraint `greaterThanOrEqualToSuperview`.")
}
return self.relatedTo(closure(other), relation: .greaterThanOrEqual, file: file, line: line)
}
}

View File

@ -1,115 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public class ConstraintMakerRelatable {
internal let description: ConstraintDescription
internal init(_ description: ConstraintDescription) {
self.description = description
}
internal func relatedTo(_ other: ConstraintRelatableTarget, relation: ConstraintRelation, file: String, line: UInt) -> ConstraintMakerEditable {
let related: ConstraintItem
let constant: ConstraintConstantTarget
if let other = other as? ConstraintItem {
guard other.attributes == ConstraintAttributes.none ||
other.attributes.layoutAttributes.count <= 1 ||
other.attributes.layoutAttributes == self.description.attributes.layoutAttributes ||
other.attributes == .edges && self.description.attributes == .margins ||
other.attributes == .margins && self.description.attributes == .edges ||
other.attributes == .directionalEdges && self.description.attributes == .directionalMargins ||
other.attributes == .directionalMargins && self.description.attributes == .directionalEdges else {
fatalError("Cannot constraint to multiple non identical attributes. (\(file), \(line))");
}
related = other
constant = 0.0
} else if let other = other as? ConstraintView {
related = ConstraintItem(target: other, attributes: ConstraintAttributes.none)
constant = 0.0
} else if let other = other as? ConstraintConstantTarget {
related = ConstraintItem(target: nil, attributes: ConstraintAttributes.none)
constant = other
} else if #available(iOS 9.0, OSX 10.11, *), let other = other as? ConstraintLayoutGuide {
related = ConstraintItem(target: other, attributes: ConstraintAttributes.none)
constant = 0.0
} else {
fatalError("Invalid constraint. (\(file), \(line))")
}
let editable = ConstraintMakerEditable(self.description)
editable.description.sourceLocation = (file, line)
editable.description.relation = relation
editable.description.related = related
editable.description.constant = constant
return editable
}
@discardableResult
public func equalTo(_ other: ConstraintRelatableTarget, _ file: String = #file, _ line: UInt = #line) -> ConstraintMakerEditable {
return self.relatedTo(other, relation: .equal, file: file, line: line)
}
@discardableResult
public func equalToSuperview(_ file: String = #file, _ line: UInt = #line) -> ConstraintMakerEditable {
guard let other = self.description.item.superview else {
fatalError("Expected superview but found nil when attempting make constraint `equalToSuperview`.")
}
return self.relatedTo(other, relation: .equal, file: file, line: line)
}
@discardableResult
public func lessThanOrEqualTo(_ other: ConstraintRelatableTarget, _ file: String = #file, _ line: UInt = #line) -> ConstraintMakerEditable {
return self.relatedTo(other, relation: .lessThanOrEqual, file: file, line: line)
}
@discardableResult
public func lessThanOrEqualToSuperview(_ file: String = #file, _ line: UInt = #line) -> ConstraintMakerEditable {
guard let other = self.description.item.superview else {
fatalError("Expected superview but found nil when attempting make constraint `lessThanOrEqualToSuperview`.")
}
return self.relatedTo(other, relation: .lessThanOrEqual, file: file, line: line)
}
@discardableResult
public func greaterThanOrEqualTo(_ other: ConstraintRelatableTarget, _ file: String = #file, line: UInt = #line) -> ConstraintMakerEditable {
return self.relatedTo(other, relation: .greaterThanOrEqual, file: file, line: line)
}
@discardableResult
public func greaterThanOrEqualToSuperview(_ file: String = #file, line: UInt = #line) -> ConstraintMakerEditable {
guard let other = self.description.item.superview else {
fatalError("Expected superview but found nil when attempting make constraint `greaterThanOrEqualToSuperview`.")
}
return self.relatedTo(other, relation: .greaterThanOrEqual, file: file, line: line)
}
}

View File

@ -1,75 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public protocol ConstraintMultiplierTarget {
var constraintMultiplierTargetValue: CGFloat { get }
}
extension Int: ConstraintMultiplierTarget {
public var constraintMultiplierTargetValue: CGFloat {
return CGFloat(self)
}
}
extension UInt: ConstraintMultiplierTarget {
public var constraintMultiplierTargetValue: CGFloat {
return CGFloat(self)
}
}
extension Float: ConstraintMultiplierTarget {
public var constraintMultiplierTargetValue: CGFloat {
return CGFloat(self)
}
}
extension Double: ConstraintMultiplierTarget {
public var constraintMultiplierTargetValue: CGFloat {
return CGFloat(self)
}
}
extension CGFloat: ConstraintMultiplierTarget {
public var constraintMultiplierTargetValue: CGFloat {
return self
}
}

View File

@ -1,69 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public protocol ConstraintOffsetTarget: ConstraintConstantTarget {
}
extension Int: ConstraintOffsetTarget {
}
extension UInt: ConstraintOffsetTarget {
}
extension Float: ConstraintOffsetTarget {
}
extension Double: ConstraintOffsetTarget {
}
extension CGFloat: ConstraintOffsetTarget {
}
extension ConstraintOffsetTarget {
internal var constraintOffsetTargetValue: CGFloat {
let offset: CGFloat
if let amount = self as? Float {
offset = CGFloat(amount)
} else if let amount = self as? Double {
offset = CGFloat(amount)
} else if let amount = self as? CGFloat {
offset = CGFloat(amount)
} else if let amount = self as? Int {
offset = CGFloat(amount)
} else if let amount = self as? UInt {
offset = CGFloat(amount)
} else {
offset = 0.0
}
return offset
}
}

View File

@ -1,77 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public struct ConstraintPriority : ExpressibleByFloatLiteral, Equatable, Strideable {
public typealias FloatLiteralType = Float
public let value: Float
public init(floatLiteral value: Float) {
self.value = value
}
public init(_ value: Float) {
self.value = value
}
public static var required: ConstraintPriority {
return 1000.0
}
public static var high: ConstraintPriority {
return 750.0
}
public static var medium: ConstraintPriority {
#if os(OSX)
return 501.0
#else
return 500.0
#endif
}
public static var low: ConstraintPriority {
return 250.0
}
public static func ==(lhs: ConstraintPriority, rhs: ConstraintPriority) -> Bool {
return lhs.value == rhs.value
}
// MARK: Strideable
public func advanced(by n: FloatLiteralType) -> ConstraintPriority {
return ConstraintPriority(floatLiteral: value + n)
}
public func distance(to other: ConstraintPriority) -> FloatLiteralType {
return other.value - value
}
}

View File

@ -1,85 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public protocol ConstraintPriorityTarget {
var constraintPriorityTargetValue: Float { get }
}
extension Int: ConstraintPriorityTarget {
public var constraintPriorityTargetValue: Float {
return Float(self)
}
}
extension UInt: ConstraintPriorityTarget {
public var constraintPriorityTargetValue: Float {
return Float(self)
}
}
extension Float: ConstraintPriorityTarget {
public var constraintPriorityTargetValue: Float {
return self
}
}
extension Double: ConstraintPriorityTarget {
public var constraintPriorityTargetValue: Float {
return Float(self)
}
}
extension CGFloat: ConstraintPriorityTarget {
public var constraintPriorityTargetValue: Float {
return Float(self)
}
}
#if canImport(UIKit)
extension UILayoutPriority: ConstraintPriorityTarget {
public var constraintPriorityTargetValue: Float {
return self.rawValue
}
}
#endif

View File

@ -1,72 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public protocol ConstraintRelatableTarget {
}
extension Int: ConstraintRelatableTarget {
}
extension UInt: ConstraintRelatableTarget {
}
extension Float: ConstraintRelatableTarget {
}
extension Double: ConstraintRelatableTarget {
}
extension CGFloat: ConstraintRelatableTarget {
}
extension CGSize: ConstraintRelatableTarget {
}
extension CGPoint: ConstraintRelatableTarget {
}
extension ConstraintInsets: ConstraintRelatableTarget {
}
#if canImport(UIKit)
@available(iOS 11.0, tvOS 11.0, *)
extension ConstraintDirectionalInsets: ConstraintRelatableTarget {
}
#endif
extension ConstraintItem: ConstraintRelatableTarget {
}
extension ConstraintView: ConstraintRelatableTarget {
}
@available(iOS 9.0, OSX 10.11, *)
extension ConstraintLayoutGuide: ConstraintRelatableTarget {
}

View File

@ -1,48 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
internal enum ConstraintRelation : Int {
case equal = 1
case lessThanOrEqual
case greaterThanOrEqual
internal var layoutRelation: LayoutRelation {
get {
switch(self) {
case .equal:
return .equal
case .lessThanOrEqual:
return .lessThanOrEqual
case .greaterThanOrEqual:
return .greaterThanOrEqual
}
}
}
}

View File

@ -1,152 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public extension ConstraintView {
@available(*, deprecated, renamed:"snp.left")
var snp_left: ConstraintItem { return self.snp.left }
@available(*, deprecated, renamed:"snp.top")
var snp_top: ConstraintItem { return self.snp.top }
@available(*, deprecated, renamed:"snp.right")
var snp_right: ConstraintItem { return self.snp.right }
@available(*, deprecated, renamed:"snp.bottom")
var snp_bottom: ConstraintItem { return self.snp.bottom }
@available(*, deprecated, renamed:"snp.leading")
var snp_leading: ConstraintItem { return self.snp.leading }
@available(*, deprecated, renamed:"snp.trailing")
var snp_trailing: ConstraintItem { return self.snp.trailing }
@available(*, deprecated, renamed:"snp.width")
var snp_width: ConstraintItem { return self.snp.width }
@available(*, deprecated, renamed:"snp.height")
var snp_height: ConstraintItem { return self.snp.height }
@available(*, deprecated, renamed:"snp.centerX")
var snp_centerX: ConstraintItem { return self.snp.centerX }
@available(*, deprecated, renamed:"snp.centerY")
var snp_centerY: ConstraintItem { return self.snp.centerY }
@available(*, deprecated, renamed:"snp.baseline")
var snp_baseline: ConstraintItem { return self.snp.baseline }
@available(*, deprecated, renamed:"snp.lastBaseline")
@available(iOS 8.0, OSX 10.11, *)
var snp_lastBaseline: ConstraintItem { return self.snp.lastBaseline }
@available(iOS, deprecated, renamed:"snp.firstBaseline")
@available(iOS 8.0, OSX 10.11, *)
var snp_firstBaseline: ConstraintItem { return self.snp.firstBaseline }
@available(iOS, deprecated, renamed:"snp.leftMargin")
@available(iOS 8.0, *)
var snp_leftMargin: ConstraintItem { return self.snp.leftMargin }
@available(iOS, deprecated, renamed:"snp.topMargin")
@available(iOS 8.0, *)
var snp_topMargin: ConstraintItem { return self.snp.topMargin }
@available(iOS, deprecated, renamed:"snp.rightMargin")
@available(iOS 8.0, *)
var snp_rightMargin: ConstraintItem { return self.snp.rightMargin }
@available(iOS, deprecated, renamed:"snp.bottomMargin")
@available(iOS 8.0, *)
var snp_bottomMargin: ConstraintItem { return self.snp.bottomMargin }
@available(iOS, deprecated, renamed:"snp.leadingMargin")
@available(iOS 8.0, *)
var snp_leadingMargin: ConstraintItem { return self.snp.leadingMargin }
@available(iOS, deprecated, renamed:"snp.trailingMargin")
@available(iOS 8.0, *)
var snp_trailingMargin: ConstraintItem { return self.snp.trailingMargin }
@available(iOS, deprecated, renamed:"snp.centerXWithinMargins")
@available(iOS 8.0, *)
var snp_centerXWithinMargins: ConstraintItem { return self.snp.centerXWithinMargins }
@available(iOS, deprecated, renamed:"snp.centerYWithinMargins")
@available(iOS 8.0, *)
var snp_centerYWithinMargins: ConstraintItem { return self.snp.centerYWithinMargins }
@available(*, deprecated, renamed:"snp.edges")
var snp_edges: ConstraintItem { return self.snp.edges }
@available(*, deprecated, renamed:"snp.size")
var snp_size: ConstraintItem { return self.snp.size }
@available(*, deprecated, renamed:"snp.center")
var snp_center: ConstraintItem { return self.snp.center }
@available(iOS, deprecated, renamed:"snp.margins")
@available(iOS 8.0, *)
var snp_margins: ConstraintItem { return self.snp.margins }
@available(iOS, deprecated, renamed:"snp.centerWithinMargins")
@available(iOS 8.0, *)
var snp_centerWithinMargins: ConstraintItem { return self.snp.centerWithinMargins }
@available(*, deprecated, renamed:"snp.prepareConstraints(_:)")
func snp_prepareConstraints(_ closure: (_ make: ConstraintMaker) -> Void) -> [Constraint] {
return self.snp.prepareConstraints(closure)
}
@available(*, deprecated, renamed:"snp.makeConstraints(_:)")
func snp_makeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
self.snp.makeConstraints(closure)
}
@available(*, deprecated, renamed:"snp.remakeConstraints(_:)")
func snp_remakeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
self.snp.remakeConstraints(closure)
}
@available(*, deprecated, renamed:"snp.updateConstraints(_:)")
func snp_updateConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
self.snp.updateConstraints(closure)
}
@available(*, deprecated, renamed:"snp.removeConstraints()")
func snp_removeConstraints() {
self.snp.removeConstraints()
}
var snp: ConstraintViewDSL {
return ConstraintViewDSL(view: self)
}
}

View File

@ -1,35 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
#if canImport(UIKit)
public typealias ConstraintView = UIView
#else
public typealias ConstraintView = NSView
#endif

View File

@ -1,101 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public struct ConstraintViewDSL: ConstraintAttributesDSL {
@discardableResult
public func prepareConstraints(_ closure: (_ make: ConstraintMaker) -> Void) -> [Constraint] {
return ConstraintMaker.prepareConstraints(item: self.view, closure: closure)
}
public func makeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
ConstraintMaker.makeConstraints(item: self.view, closure: closure)
}
public func remakeConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
ConstraintMaker.remakeConstraints(item: self.view, closure: closure)
}
public func updateConstraints(_ closure: (_ make: ConstraintMaker) -> Void) {
ConstraintMaker.updateConstraints(item: self.view, closure: closure)
}
public func removeConstraints() {
ConstraintMaker.removeConstraints(item: self.view)
}
public var contentHuggingHorizontalPriority: Float {
get {
return self.view.contentHuggingPriority(for: .horizontal).rawValue
}
nonmutating set {
self.view.setContentHuggingPriority(LayoutPriority(rawValue: newValue), for: .horizontal)
}
}
public var contentHuggingVerticalPriority: Float {
get {
return self.view.contentHuggingPriority(for: .vertical).rawValue
}
nonmutating set {
self.view.setContentHuggingPriority(LayoutPriority(rawValue: newValue), for: .vertical)
}
}
public var contentCompressionResistanceHorizontalPriority: Float {
get {
return self.view.contentCompressionResistancePriority(for: .horizontal).rawValue
}
nonmutating set {
self.view.setContentCompressionResistancePriority(LayoutPriority(rawValue: newValue), for: .horizontal)
}
}
public var contentCompressionResistanceVerticalPriority: Float {
get {
return self.view.contentCompressionResistancePriority(for: .vertical).rawValue
}
nonmutating set {
self.view.setContentCompressionResistancePriority(LayoutPriority(rawValue: newValue), for: .vertical)
}
}
public var target: AnyObject? {
return self.view
}
internal let view: ConstraintView
internal init(view: ConstraintView) {
self.view = view
}
}

View File

@ -1,169 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public extension LayoutConstraint {
override var description: String {
var description = "<"
description += descriptionForObject(self)
if let firstItem = conditionalOptional(from: self.firstItem) {
description += " \(descriptionForObject(firstItem))"
}
if self.firstAttribute != .notAnAttribute {
description += ".\(descriptionForAttribute(self.firstAttribute))"
}
description += " \(descriptionForRelation(self.relation))"
if let secondItem = self.secondItem {
description += " \(descriptionForObject(secondItem))"
}
if self.secondAttribute != .notAnAttribute {
description += ".\(descriptionForAttribute(self.secondAttribute))"
}
if self.multiplier != 1.0 {
description += " * \(self.multiplier)"
}
if self.secondAttribute == .notAnAttribute {
description += " \(self.constant)"
} else {
if self.constant > 0.0 {
description += " + \(self.constant)"
} else if self.constant < 0.0 {
description += " - \(abs(self.constant))"
}
}
if self.priority.rawValue != 1000.0 {
description += " ^\(self.priority)"
}
description += ">"
return description
}
}
private func descriptionForRelation(_ relation: LayoutRelation) -> String {
switch relation {
case .equal: return "=="
case .greaterThanOrEqual: return ">="
case .lessThanOrEqual: return "<="
#if swift(>=5.0)
@unknown default: return "unknown"
#endif
}
}
private func descriptionForAttribute(_ attribute: LayoutAttribute) -> String {
#if canImport(UIKit)
switch attribute {
case .notAnAttribute: return "notAnAttribute"
case .top: return "top"
case .left: return "left"
case .bottom: return "bottom"
case .right: return "right"
case .leading: return "leading"
case .trailing: return "trailing"
case .width: return "width"
case .height: return "height"
case .centerX: return "centerX"
case .centerY: return "centerY"
case .lastBaseline: return "lastBaseline"
case .firstBaseline: return "firstBaseline"
case .topMargin: return "topMargin"
case .leftMargin: return "leftMargin"
case .bottomMargin: return "bottomMargin"
case .rightMargin: return "rightMargin"
case .leadingMargin: return "leadingMargin"
case .trailingMargin: return "trailingMargin"
case .centerXWithinMargins: return "centerXWithinMargins"
case .centerYWithinMargins: return "centerYWithinMargins"
#if swift(>=5.0)
@unknown default: return "unknown"
#endif
}
#else
switch attribute {
case .notAnAttribute: return "notAnAttribute"
case .top: return "top"
case .left: return "left"
case .bottom: return "bottom"
case .right: return "right"
case .leading: return "leading"
case .trailing: return "trailing"
case .width: return "width"
case .height: return "height"
case .centerX: return "centerX"
case .centerY: return "centerY"
case .lastBaseline: return "lastBaseline"
case .firstBaseline: return "firstBaseline"
#if swift(>=5.0)
@unknown default: return "unknown"
#endif
}
#endif
}
private func conditionalOptional<T>(from object: Optional<T>) -> Optional<T> {
return object
}
private func conditionalOptional<T>(from object: T) -> Optional<T> {
return Optional.some(object)
}
private func descriptionForObject(_ object: AnyObject) -> String {
let pointerDescription = String(format: "%p", UInt(bitPattern: ObjectIdentifier(object)))
var desc = ""
desc += type(of: object).description()
if let object = object as? ConstraintView {
desc += ":\(object.snp.label() ?? pointerDescription)"
} else if let object = object as? LayoutConstraint {
desc += ":\(object.label ?? pointerDescription)"
} else {
desc += ":\(pointerDescription)"
}
if let object = object as? LayoutConstraint, let file = object.constraint?.sourceLocation.0, let line = object.constraint?.sourceLocation.1 {
desc += "@\((file as NSString).lastPathComponent)#\(line)"
}
desc += ""
return desc
}

View File

@ -1,61 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public class LayoutConstraint : NSLayoutConstraint {
public var label: String? {
get {
return self.identifier
}
set {
self.identifier = newValue
}
}
internal weak var constraint: Constraint? = nil
}
internal func ==(lhs: LayoutConstraint, rhs: LayoutConstraint) -> Bool {
// If firstItem or secondItem on either constraint has a dangling pointer
// this comparison can cause a crash. The solution for this is to ensure
// your layout code hold strong references to things like Views, LayoutGuides
// and LayoutAnchors as SnapKit will not keep strong references to any of these.
guard lhs.firstAttribute == rhs.firstAttribute &&
lhs.secondAttribute == rhs.secondAttribute &&
lhs.relation == rhs.relation &&
lhs.priority == rhs.priority &&
lhs.multiplier == rhs.multiplier &&
lhs.secondItem === rhs.secondItem &&
lhs.firstItem === rhs.firstItem else {
return false
}
return true
}

View File

@ -1,93 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
public protocol LayoutConstraintItem: AnyObject {
}
@available(iOS 9.0, OSX 10.11, *)
extension ConstraintLayoutGuide : LayoutConstraintItem {
}
extension ConstraintView : LayoutConstraintItem {
}
extension LayoutConstraintItem {
internal func prepare() {
if let view = self as? ConstraintView {
view.translatesAutoresizingMaskIntoConstraints = false
}
}
internal var superview: ConstraintView? {
if let view = self as? ConstraintView {
return view.superview
}
if #available(iOS 9.0, OSX 10.11, *), let guide = self as? ConstraintLayoutGuide {
return guide.owningView
}
return nil
}
internal var constraints: [Constraint] {
return self.constraintsSet.allObjects as! [Constraint]
}
internal func add(constraints: [Constraint]) {
let constraintsSet = self.constraintsSet
for constraint in constraints {
constraintsSet.add(constraint)
}
}
internal func remove(constraints: [Constraint]) {
let constraintsSet = self.constraintsSet
for constraint in constraints {
constraintsSet.remove(constraint)
}
}
private var constraintsSet: NSMutableSet {
let constraintsSet: NSMutableSet
if let existing = objc_getAssociatedObject(self, &constraintsKey) as? NSMutableSet {
constraintsSet = existing
} else {
constraintsSet = NSMutableSet()
objc_setAssociatedObject(self, &constraintsKey, constraintsSet, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
return constraintsSet
}
}
private var constraintsKey: UInt8 = 0

View File

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTrackingDomains</key>
<array/>
</dict>
</plist>

View File

@ -1,42 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import Foundation
#if canImport(UIKit)
import UIKit
#if swift(>=4.2)
typealias LayoutRelation = NSLayoutConstraint.Relation
typealias LayoutAttribute = NSLayoutConstraint.Attribute
#else
typealias LayoutRelation = NSLayoutRelation
typealias LayoutAttribute = NSLayoutAttribute
#endif
typealias LayoutPriority = UILayoutPriority
#else
import AppKit
typealias LayoutRelation = NSLayoutConstraint.Relation
typealias LayoutAttribute = NSLayoutConstraint.Attribute
typealias LayoutPriority = NSLayoutConstraint.Priority
#endif

View File

@ -1,36 +0,0 @@
//
// SnapKit
//
// Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#if canImport(UIKit)
import UIKit
#endif
@available(iOS 8.0, *)
public extension ConstraintLayoutSupport {
var snp: ConstraintLayoutSupportDSL {
return ConstraintLayoutSupportDSL(support: self)
}
}

View File

@ -53,29 +53,6 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
## SnapKit
Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## ZIPFoundation
MIT License

View File

@ -76,35 +76,6 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
<key>Type</key>
<string>PSGroupSpecifier</string>
</dict>
<dict>
<key>FooterText</key>
<string>Copyright (c) 2011-Present SnapKit Team - https://github.com/SnapKit
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
</string>
<key>License</key>
<string>MIT</string>
<key>Title</key>
<string>SnapKit</string>
<key>Type</key>
<string>PSGroupSpecifier</string>
</dict>
<dict>
<key>FooterText</key>
<string>MIT License

View File

@ -1,7 +1,5 @@
${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh
${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework
${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework
${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework
${BUILT_PRODUCTS_DIR}/SSAlertSwift/SSAlertSwift.framework
${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework
${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework
${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework

View File

@ -1,6 +1,4 @@
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTCoreText.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTFoundation.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDReaderView.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SSAlertSwift.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SnapKit.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDEpubReaderView.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework

View File

@ -1,7 +1,5 @@
${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh
${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework
${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework
${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework
${BUILT_PRODUCTS_DIR}/SSAlertSwift/SSAlertSwift.framework
${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework
${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework
${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework

View File

@ -1,6 +1,4 @@
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTCoreText.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DTFoundation.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDReaderView.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SSAlertSwift.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SnapKit.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RDEpubReaderView.framework
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework

View File

@ -178,17 +178,13 @@ code_sign_if_enabled() {
if [[ "$CONFIGURATION" == "Debug" ]]; then
install_framework "${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework"
install_framework "${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework"
install_framework "${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework"
install_framework "${BUILT_PRODUCTS_DIR}/SSAlertSwift/SSAlertSwift.framework"
install_framework "${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework"
install_framework "${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework"
install_framework "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework"
fi
if [[ "$CONFIGURATION" == "Release" ]]; then
install_framework "${BUILT_PRODUCTS_DIR}/DTCoreText/DTCoreText.framework"
install_framework "${BUILT_PRODUCTS_DIR}/DTFoundation/DTFoundation.framework"
install_framework "${BUILT_PRODUCTS_DIR}/RDReaderView/RDReaderView.framework"
install_framework "${BUILT_PRODUCTS_DIR}/SSAlertSwift/SSAlertSwift.framework"
install_framework "${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework"
install_framework "${BUILT_PRODUCTS_DIR}/RDEpubReaderView/RDEpubReaderView.framework"
install_framework "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework"
fi
if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then

View File

@ -1,12 +1,12 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView/RDReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift/SSAlertSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
OTHER_LDFLAGS = $(inherited) -l"swiftCoreGraphics" -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDReaderView" -framework "SSAlertSwift" -framework "SnapKit" -framework "ZIPFoundation"
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "ZIPFoundation"
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)

View File

@ -1,12 +1,12 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView/RDReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift/SSAlertSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
OTHER_LDFLAGS = $(inherited) -l"swiftCoreGraphics" -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDReaderView" -framework "SSAlertSwift" -framework "SnapKit" -framework "ZIPFoundation"
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -l"xml2" -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "ZIPFoundation"
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)

View File

@ -0,0 +1,5 @@
#import <Foundation/Foundation.h>
@interface PodsDummy_RDEpubReaderView : NSObject
@end
@implementation PodsDummy_RDEpubReaderView
@end

View File

@ -11,6 +11,6 @@
#endif
FOUNDATION_EXPORT double RDReaderViewVersionNumber;
FOUNDATION_EXPORT const unsigned char RDReaderViewVersionString[];
FOUNDATION_EXPORT double RDEpubReaderViewVersionNumber;
FOUNDATION_EXPORT const unsigned char RDEpubReaderViewVersionString[];

View File

@ -1,9 +1,9 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "SSAlertSwift" -framework "SnapKit" -framework "ZIPFoundation"
OTHER_LDFLAGS = $(inherited) -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "ZIPFoundation"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)

View File

@ -0,0 +1,6 @@
framework module RDEpubReaderView {
umbrella header "RDEpubReaderView-umbrella.h"
export *
module * { export * }
}

View File

@ -1,9 +1,9 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/SSAlertSwift" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "SSAlertSwift" -framework "SnapKit" -framework "ZIPFoundation"
OTHER_LDFLAGS = $(inherited) -framework "CoreGraphics" -framework "CoreText" -framework "DTCoreText" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "ZIPFoundation"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)

Some files were not shown because too many files have changed in this diff Show More