Epub阅读器0.0.1

This commit is contained in:
shen
2026-05-21 19:40:51 +08:00
commit daa36d8fe7
559 changed files with 106266 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(nm /Users/shenlei/Documents/wxRead/WRUICore.framework/WRUICore)"
]
}
}
@@ -0,0 +1,445 @@
# 微信读书 EPUB 阅读器 — 核心架构与数据流图
> 逆向工程目标: WeRead v10.0.3 (Build 79), arm64, 63MB 主二进制
---
## 一、模块拓扑与依赖关系图
### 1.1 App 整体模块拓扑
```
┌─────────────────────────────────────────────────────────────────────┐
│ WeRead.app (iOS/macOS Catalyst) │
│ 主二进制: 63MB, Mach-O arm64 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 主二进制 (WeRead) │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ 阅读器模块 │ │ 书架模块 │ │ 其他业务模块 │ │ │
│ │ │ WRReader* │ │ WRBookshelf* │ │ WRDiscover/WRMarket │ │ │
│ │ │ WREpub* │ │ WRBook* │ │ WRAccount/WRSetting │ │ │
│ │ │ DTCoreText* │ │ │ │ WRCommunity/WRChat │ │ │
│ │ │ WRPage* │ │ │ │ WRActivity/WRAI │ │ │
│ │ │ WRChapter* │ │ │ │ ... │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ │
│ │ ┌──────────────────────────────────────────────────────────┐│ │
│ │ │ ObjC 元数据 (未剥离): ││ │
│ │ │ __objc_classlist: 0xb230 (类列表) ││ │
│ │ │ __objc_classname: 0x25a35 (~154KB, 17584 个类名) ││ │
│ │ │ __objc_methname: 0x24ba5a (~2.4MB 方法名) ││ │
│ │ │ __objc_methtype: 0x382bc (~226KB 类型编码) ││ │
│ │ └──────────────────────────────────────────────────────────┘│ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────── Frameworks ────────────────────────────┐ │
│ │ │ │
│ │ ┌─────────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ hermes.framework│ │QMUIKit.fwk │ │WRSharedMod* │ │ │
│ │ │ (RN JS 引擎) │ │(腾讯UI组件库) │ │(共享业务模块) │ │ │
│ │ │ v0.12.0 │ │ │ │ │ │ │
│ │ │ Facebook Hermes │ │ QMUI 配色/ │ │ 书架/发现/ │ │ │
│ │ │ 替代 JSC │ │ 主题/组件 │ │ 社区/活动 │ │ │
│ │ └─────────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌──────────────┐ │ │
│ │ │ WRUICore.fwk │ │CocoaMarkdown │ │ │
│ │ │ (UI核心+字体) │ │(Markdown渲染)│ │ │
│ │ │ 含 WeReadLS 系列│ │ │ │ │
│ │ │ 含 SharpGrotesk │ │ │ │ │
│ │ └─────────────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────── RNBundles (39个 .hbc) ─────────────────┐ │
│ │ aiSearch bookDetail browse community discoverV2 market │ │
│ │ account medals memberDetail gameCenterHome freeBooks ... │ │
│ │ (Hermes 编译的 HBC 字节码, 业务动态化) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────── Bundles ────────────────────────────────┐ │
│ │ OnePatch.bundle (ObjC热修复, JS驱动) │ │
│ │ MidasIAPSDK.bundle (腾讯米大师支付) │ │
│ │ Sentry.bundle (崩溃监控) │ │
│ │ QCloudCOSXML.bundle (腾讯云COS存储) │ │
│ │ MJRefresh.bundle (下拉刷新) │ │
│ │ mathFonts.bundle (数学字体: Latin Modern/XITS/TeX Gyre) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────── App Extensions ─────────────────────────┐ │
│ │ ActionExtension.appex (操作扩展) │ │
│ │ ModernWidgetExtension.appex (新版小组件 WidgetKit) │ │
│ │ NotificationService.appex (通知服务) │ │
│ │ ShareExtension.appex (分享扩展) │ │
│ │ Widget.appex (旧版小组件) │ │
│ └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
系统框架依赖:
┌────────────────────────────────────────────────────────────┐
│ CoreText CTTypesetter / CTFramesetter / CTFrame │
│ CTLine / CTRun / CTFontCreatePathForGlyph │
│ CoreGraphics CGContext / CGPath / CGAffineTransform │
│ UIKit UIView / UIPageViewController / Gesture │
│ WebKit WKWebView (仅公众号/文集路径) │
│ Foundation NSAttributedString / NSXMLParser │
│ Security Keychain (加密密钥存储) │
│ AVFoundation TTS 朗读音频播放 │
│ PencilKit Apple Pencil 手写笔记 │
│ ImageIO 图片解码与缓存 │
└────────────────────────────────────────────────────────────┘
```
### 1.2 EPUB 渲染模块内部依赖图
```
WRReaderViewController (431 methods, 阅读器主控制器)
├─→ WRPageViewController (80 methods, 翻页控制器)
│ │ 基于 UIPageViewController 封装
│ │ 支持 UIPageCurl (仿真翻页) / Scroll (左右滑动)
│ │ 包含 4 个故障修复补丁
│ │
│ └─→ WRPageView (105 methods, 页面渲染视图)
│ │ 继承 UIView, drawRect: 中直接 CGContext 绘制
│ │ 不使用 UILabel / UITextView
│ │
│ ├─→ WRCoreTextLayoutFrame (4 methods, 单页排版帧)
│ │ │ drawInContext:image:size:inRect:position:
│ │ │ avoidPageBreakInsideByRemovingLastLinesIfNeeded
│ │ │ getRenderHeight / lines
│ │ │
│ │ └─→ DTCoreTextLayoutFrame (56 methods, DTCoreText 排版帧)
│ │ │ 封装 CTFrame, 提取 CTLine 列表
│ │ │ hitTestWithPoint: (文本选择)
│ │ │
│ │ └─→ DTCoreTextLayoutLine (61 methods, 排版行)
│ │ │ 封装 CTLine, 提取 CTRun 列表
│ │ │ typographicBounds / trailingWhitespaceWidth
│ │ │
│ │ └─→ DTCoreTextGlyphRun (23 methods, 字形渲染)
│ │ │ 封装 CTRun
│ │ └─→ newPathWithGlyphs (CTFontCreatePathForGlyph)
│ │
│ └─→ 文本选择 (CoreText hit test)
│ CTLineGetStringIndexForPosition (坐标→字符索引)
│ CTLineGetOffsetForStringIndex (字符索引→坐标)
├─→ WRChapterData (125 methods, 章节数据模型)
│ │ 存储排版后的 NSAttributedString
│ │ 管理高亮/下划线/书评标注
│ │
│ ├─→ WRCoreTextLayouter (20 methods, WeRead CoreText 封装)
│ │ │ 管理 CTTypesetter / CTFramesetter
│ │ │ pageBackgroundImageAtRange:themeBgColor:
│ │ │ resizedImageForImagePath:rect:position:sizePattern:darkMode:themeBgColor:
│ │ │
│ │ └─→ DTCoreTextLayouter (12 methods, DTCoreText 排版器)
│ │ │ 封装 CTTypesetter, 缓存 DTCoreTextLayoutFrame
│ │ │
│ │ └─→ CTTypesetter / CTFramesetter (系统 CoreText)
│ │
│ └─→ WRChapterPageCount (40 methods, 分页计算)
│ rangeValueWithPageInfo: (计算每页 NSRange)
│ currentCacheKeyWithBookId: (缓存键)
├─→ WREpubTypesetter (9 methods, EPUB 排版器入口)
│ │ attributeStringWithFilePath:... (核心类方法)
│ │
│ └─→ DTHTMLAttributedStringBuilder (25 methods, HTML→NSAttributedString)
│ │ _buildString (核心构建)
│ │ parser:didStartElement:attributes:position: (SAX 回调)
│ │ parser:foundCharacters:position:
│ │ parserDidEndDocument:
│ │
│ ├─→ DTHTMLParser (19 methods, HTML SAX 解析器)
│ │ 基于 libxml2 封装
│ │
│ ├─→ DTHTMLElement (168 methods, HTML 元素模型)
│ │ │ applyStyleDictionary:isLatinLanguageBook: (CSS 应用)
│ │ │ attributedString (生成富文本)
│ │ │ interpretAttributes (属性解析)
│ │ │
│ │ ├─→ DTCoreTextFontDescriptor (字体描述)
│ │ ├─→ DTCoreTextParagraphStyle (段落样式)
│ │ ├─→ DTCSSStylesheet (CSS 样式表)
│ │ └─→ DTTextAttachment (附件: 图片/视频/iframe)
│ │
│ └─→ CSS 级联: default.css → replace.css → dark.css → epub内嵌 → 用户设置
├─→ WREpubParser (12 methods, EPUB 文件解析)
│ │ 解析 container.xml / content.opf / toc.ncx
│ │ 输出: 章节列表 + 资源映射
│ └─→ WREpubPositionConverter (22 methods, 位置转换)
│ 文件位置 ↔ 字符位置 映射
├─→ WRChapterDownloadManger (47 methods, 章节下载)
│ │ RAC 任务队列 + 并发控制
│ │
│ ├─→ WRBookNetwork (155 methods, 网络层)
│ │ │ loadTarForEpubBookId:chapter:isPreload: (下载加密 ZIP)
│ │ │ handleUnzipWithBookId:zipPath:encryptKey:plainBookDirectory: (解密解压)
│ │ │ processEncryptedBookFileAtPath:encryptKey:book:chapterUid: (文件处理)
│ │ │ fileContentWithChapter:book:shouldRemoveHtmlTags: (读取内容)
│ │ │
│ │ └─→ WREncryptedFileManager (7 methods, DRM 管理)
│ │ decryptContentsOfFile:forBookId:isFileLost: (AES 解密)
│ │ encryptFileForBookId:originalEncryptKey:atPath:toPath: (本地加密)
│ │ keyForBookId: (密钥管理)
│ │
│ └─→ WRPreloadBookManager (76 methods, 预加载管理)
│ saveEncryptKey:forPath:bookId: (密钥缓存)
│ _preloadWholeBook:scene: (整书预加载)
├─→ 标注系统
│ ├─→ WRPageHighlight (3 methods, 高亮)
│ ├─→ WRPageUnderline (10 methods, 下划线: solid/dashed/wavy/dotted)
│ ├─→ WRPageMark (25 methods, 书签标注)
│ ├─→ WRBookmark (58 methods, 书签模型)
│ └─→ WRReaderPencilNoteManager (11 class methods, Pencil 笔记)
└─→ 附件系统 (WRPageAttachments)
├─→ WRPageImageAttachment (27 methods, 图片附件)
├─→ WRPageHyperlinksAttachment (超链接)
├─→ WRPageChapterToolAttachment (章节工具)
├─→ WRPageFlyleafAttachment (扉页)
├─→ WRPageCodeView (代码块)
├─→ WRPageVideoView (视频)
└─→ WRPageTableAttachment (表格)
```
---
## 二、核心业务数据流图 (DFD)
### 2.1 EPUB 内容数据流 (服务器 → 屏幕像素)
```
┌──────────┐ HTTPS ┌──────────────────────────────────────────────────┐
│ 微信读书 │─────────────→│ ① WRBookNetwork.loadTarForEpubBookId:chapter: │
│ 服务器 │ 加密 ZIP │ isPreload: │
│ │ {bookId}_ │ ↓ │
│ │ DECRYPT.zip │ ② WRBookNetwork.handleUnzipWithBookId: │
│ │ │ zipPath:encryptKey:plainBookDirectory: │
│ │ │ ↓ │
│ │ │ ③ WREncryptedFileManager │
│ │ │ decryptContentsOfFile:forBookId:isFileLost: │
│ │ │ 密钥来源: keyForBookId: (Keychain) │
│ │ │ ↓ 解密后 XHTML 文件 │
│ │ │ │
│ │ │ ④ WRBookNetwork.fileContentWithChapter: │
│ │ │ book:shouldRemoveHtmlTags:filterTranslate: │
│ │ │ ↓ 读取 XHTML 内容 │
│ │ │ │
│ │ │ ⑤ WREpubTypesetter.attributeStringWithFilePath: │
│ │ │ ↓ CSS 级联 (5层) │
│ │ │ ↓ DTHTMLAttributedStringBuilder._buildString │
│ │ │ ↓ DTHTMLParser (SAX) → DTHTMLElement (DOM) │
│ │ │ ↓ DTHTMLElement.attributedString │
│ │ │ ↓ 输出: NSAttributedString │
│ │ │ │
│ │ │ ⑥ WRCoreTextLayouter (CTTypesetter 分页) │
│ │ │ ↓ CTTypesetterSuggestLineBreak (逐行排版) │
│ │ │ ↓ 每页一个 CTFrame │
│ │ │ ↓ WRChapterPageCount (NSRange 映射) │
│ │ │ │
│ │ │ ⑦ WRCoreTextLayoutFrame (单页排版帧) │
│ │ │ ↓ CTFrameGetLines → CTLine 列表 │
│ │ │ ↓ avoidPageBreakInside (截断保护) │
│ │ │ │
│ │ │ ⑧ WRPageView.drawRect: │
│ │ │ CGContextTranslateCTM (坐标翻转) │
│ │ │ CTFrameDraw (绘制文字) │
│ │ │ 绘制图片/高亮/下划线/选择 │
│ │ │ ↓ │
└──────────┘ └──────────────────────────────────────────────────┘
屏幕像素渲染
```
### 2.2 加密密钥数据流 (DRM 保护链)
```
┌─────────────────────────────────────────────────────────────────────────┐
│ DRM 密钥生命周期 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ │
│ │ 服务器 │─── encryptKey (每章独立) ───┐ │
│ └──────────┘ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ WRPreloadBookManager │ │
│ │ saveEncryptKey:forPath:bookId: │ │
│ │ ↓ 存储到: NSUserDefaults (临时) │ │
│ │ encryptKeyForPath:bookId: (读取) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ WREncryptedFileManager │ │
│ │ keyForBookId: │ │
│ │ ↓ 优先级: Keychain > NSUserDefaults > 内存缓存 │ │
│ │ │ │
│ │ decryptContentsOfFile:forBookId:isFileLost: │ │
│ │ ↓ 算法: AES-128-CBC │ │
│ │ ↓ 输入: 加密的 XHTML 数据 │ │
│ │ ↓ 输出: 明文 XHTML │ │
│ │ ↓ 检测: "REWD" 自定义头部标识 │ │
│ │ │ │
│ │ encryptFileForBookId:originalEncryptKey:atPath:toPath: │ │
│ │ ↓ 解密后立即重新加密存储 (本地 DRM) │ │
│ │ ↓ 防止直接拷贝文件读取 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 本地存储 │ │
│ │ Documents/{bookId}/plainBookDirectory/ (解密后的 EPUB) │ │
│ │ Documents/{bookId}/epubImage/ (书籍图片缓存) │ │
│ │ Library/{cachePath}/epubImage/ (SDWebImage 缓存) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 密钥安全机制: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 1. 传输层: HTTPS + 加密 ZIP │ │
│ │ 2. 解密层: 逐章解密 (WREncryptedFileManager) │ │
│ │ 3. 存储层: 本地二次加密 (encryptFileForBookId) │ │
│ │ 4. 密钥层: 每本书独立密钥 + Keychain 安全存储 │ │
│ │ 5. 设备层: 密钥与设备绑定 │ │
│ │ 6. 试读层: freeTrialChapterCutOffStringLocaion (服务端控制) │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
### 2.3 用户标注数据流
```
┌─────────────────────────────────────────────────────────────────────┐
│ 用户触摸/长按 │
│ │ │
│ ▼ │
│ WRPageView 手势识别 (UITapGestureRecognizer / UILongPressGestureRecognizer)
│ │ │
│ ▼ │
│ CoreText Hit Test │
│ touchPoint = WRSFlipPointForCoreText(touch, viewHeight) // 坐标翻转
│ CTLineGetStringIndexForPosition(line, touchPoint) // 坐标→字符索引
│ CTLineGetOffsetForStringIndex(line, charIndex, NULL) // 字符索引→坐标
│ │ │
│ ▼ │
│ 选择范围确定 (selectionStartIndex .. selectionEndIndex) │
│ │ │
│ ▼ │
│ UIMenuController 弹出操作菜单 │
│ ├─ 划线/高亮 ─→ WRChapterData.addHighlightInRange:key:itemId:color:│
│ ├─ 写想法 ─→ WRBookNetwork.addReview:shareToWechat:... │
│ ├─ 复制 ─→ UIPasteboard │
│ ├─ 查询/翻译 ─→ WRReaderDictionaryViewController │
│ └─ 分享 ─→ WRShareManager │
│ │ │
│ ▼ │
│ WRChapterData 标注存储 │
│ NSAttributedString 注入自定义属性: │
│ "com.weread.highlight" → 高亮数据 │
│ "com.weread.underline" → 下划线数据 │
│ │ │
│ ▼ │
│ 服务器同步 │
│ WRBookNetwork.addReview:shareToWechat:audioArticleId:... │
│ WRBookNetwork.loadBookmarkListWithBookId:syncKey:callback: │
│ │ │
│ ▼ │
│ 重新排版 │
│ WRReaderViewController.recomposeCurrentPageViewWithSource: │
│ → WRPageView.setNeedsDisplay → drawRect: 重绘 │
└─────────────────────────────────────────────────────────────────────┘
```
### 2.4 阅读进度数据流
```
┌─────────────────────────────────────────────────────────────────────┐
│ 本地进度持久化 │
│ WRReaderViewController._saveReadingProgressAndIsAsync: │
│ ↓ NSUserDefaults 存储: │
│ - bookId / chapterUid / chapterIdx │
│ - filePosition / stringLocation │
│ - pageOfChapter / pageOfFlyleaf │
│ - lastReadDate / readingTime │
│ │ │
│ ▼ │
│ 云端同步 │
│ WRBookNetwork.uploadBookProgressAndReadingTime: │
│ ↓ POST /api/book/progress │
│ ↓ 参数: bookId, chapterUid, progress, readingTime │
│ │ │
│ ▼ │
│ 启动恢复 │
│ WRReaderViewController.initWithBook:progress:... │
│ ↓ loadBookReadInfoWithBookId: (从服务器获取最新进度) │
│ ↓ gotoChapterIdx:position:positionOfFile: (跳转到上次位置) │
│ ↓ scrollToLastReadingProgressIfNeeded │
└─────────────────────────────────────────────────────────────────────┘
```
### 2.5 预加载数据流
```
┌─────────────────────────────────────────────────────────────────────┐
│ 预测触发 │
│ WRForecastUtils.shouldPreloadChapterUidsForBook: │
│ type:bookRank:archiveRank: │
│ ↓ 根据书籍排名 + 用户行为预测 │
│ │ │
│ ▼ │
│ 预加载执行 │
│ WRChapterDownloadManger._preloadChapterContentWithBook: │
│ type:bookRank: │
│ ↓ 后台线程下载相邻章节 │
│ │ │
│ ▼ │
│ 下载 + 解密 + 缓存 │
│ WRBookNetwork.loadTarForEpubBookId:chapters:isPreload: │
│ → handleUnzipWithBookId → WREncryptedFileManager │
│ → 存储到 Documents/{bookId}/plainBookDirectory/ │
│ │ │
│ ▼ │
│ 图片预缓存 │
│ WRBookNetwork.storeTarEpubImageToDiskWithBookId: │
│ chapterUid:untarDirectory: │
│ → SDWebImage → com.hackemist.SDWebImageCache.epubImage │
│ │ │
│ ▼ │
│ 密钥预缓存 │
│ WRPreloadBookManager.saveEncryptKey:forPath:bookId: │
│ → NSUserDefaults / Keychain │
└─────────────────────────────────────────────────────────────────────┘
```
### 2.6 JS Bridge 数据流 (WebView 路径 — 公众号/文集文章)
```
┌─────────────────────────────────────────────────────────────────────┐
│ JS → 原生 │
│ window.webkit.messageHandlers.MPReader.postMessage(data) │
│ wereadBridge.execMPReaderMethod('MPReader', data) │
│ ↓ WKScriptMessageHandler │
│ ↓ WRMPReadingViewModel 解析 JSON │
│ │ │
│ ▼ │
│ 原生 → JS │
│ [webView evaluateJavaScript:@"..." completionHandler:nil] │
│ ↓ WKUserScript 注入脚本 │
│ │ │
│ ▼ │
│ 高亮系统 (weread-highlighter.js) │
│ rangy.init() → 创建 Highlighter (TextRange 模式) │
│ 注册 ClassApplier: "highlight"/"review"/"reference"/"tts" │
│ 监听 selectionchange 事件 │
│ │ │
│ ▼ │
│ 通信协议: wereadapijs://dispatch_message/ │
│ URL Scheme 编码消息, iframe 触发 │
└─────────────────────────────────────────────────────────────────────┘
```
@@ -0,0 +1,779 @@
# 微信读书 EPUB 阅读器 — 符号恢复与核心算法
> 基于 WeRead v10.0.3 (Build 79) 二进制逆向
> 方法签名来源: __objc_methname 段 + binary strings
> 类名来源: __objc_classname 段 (17584 个类, 其中 320 个 EPUB 相关)
---
## 一、符号恢复映射表
### 1.1 核心类方法签名恢复 (439 个)
#### WREpubTypesetter (EPUB 排版器入口)
```
恢复前 (二进制地址) 恢复后 (符号名) 功能
─────────────────────────────────────────────────────────────────────────────────────
imp@0x100a489ac +[WREpubTypesetter attributeStringWithFilePath: XHTML→NSAttributedString
priority: 核心排版方法
insertArticleToolAttachment: 5层CSS级联
insertBookChapterToolAttachment: 图片/链接处理
insertRecommendView: 繁简转换
book:chapter:pageFlippingStyle: 免费试读截断
renderErrorReason:isStyleFileNotFound:options:]
imp@0x100a49b9c +[WREpubTypesetter tryReportTranslationError: 翻译错误上报
bookId:chapter:
isTranslationStyleNotFound:
isTranslationContentNotFound:
isTranslateTagButNoTranslateStyle:]
```
#### DTHTMLAttributedStringBuilder (HTML→NSAttributedString 构建器)
```
imp@0x... -[DTHTMLAttributedStringBuilder _buildString] 核心构建方法
imp@0x... -[DTHTMLAttributedStringBuilder parser:didStartElement: SAX 标签开始
attributes:position:]
imp@0x... -[DTHTMLAttributedStringBuilder parser:foundCDATA:] CDATA 处理
imp@0x... -[DTHTMLAttributedStringBuilder parser:foundCharacters: 文本节点
position:]
imp@0x... -[DTHTMLAttributedStringBuilder parserDidEndDocument:] 解析完成
```
#### DTHTMLElement (HTML 元素模型, 168 methods)
```
imp@0x... -[DTHTMLElement applyStyleDictionary: CSS 样式应用
isLatinLanguageBook:]
imp@0x... -[DTHTMLElement attributedString] 生成富文本
imp@0x... -[DTHTMLElement interpretAttributes] 属性解析
```
#### WRCoreTextLayoutFrame (单页排版帧, 49 methods)
```
imp@0x... -[WRCoreTextLayoutFrame drawInContext: 核心绘制方法
image:size:inRect:position:] CGContext 绘制
imp@0x... -[WRCoreTextLayoutFrame avoidPageBreakInside 避免断页
ByRemovingLastLinesIfNeeded] 移除最多3行
imp@0x... -[WRCoreTextLayoutFrame getRenderHeight] 获取渲染高度
imp@0x... -[WRCoreTextLayoutFrame lines] 获取行列表
```
#### DTCoreTextGlyphRun (字形渲染)
```
imp@0x... -[DTCoreTextGlyphRun newPathWithGlyphs] CGPath 字形路径
CTFontCreatePathForGlyph
```
#### WRChapterData (章节数据模型, 125 methods)
```
imp@0x... +[WRChapterData addUnderLineToAttributedString: 添加下划线
range:itemId:style:color:]
imp@0x... +[WRChapterData freeTrialChapterCutOffStringLocaion 试读截断位置
WithAttributedString:book:]
imp@0x... -[WRChapterData addHighlightInRange: 添加高亮
key:itemId:color:]
imp@0x... -[WRChapterData addReviewUnderlineInRange: 添加书评下划线
itemId:type:]
imp@0x... -[WRChapterData rangeOfPage:] 获取页范围
imp@0x... -[WRChapterData generateOutlineContents] 生成目录
imp@0x... -[WRChapterData markFreeTrialChapterCutOffString 标记试读截断
Location:]
```
#### WRPageViewController (翻页控制器, 80 methods)
```
imp@0x... -[WRPageViewController initWithDelegate: 初始化
withPageType:pageFlippingStyle:]
imp@0x... -[WRPageViewController pageViewController: 上一页
viewControllerBeforeViewController:]
imp@0x... -[WRPageViewController pageViewController: 下一页
viewControllerAfterViewController:]
imp@0x... +[WRPageViewController patchNavigationDirectionFault] 导航方向修复
imp@0x... +[WRPageViewController patchNoViewController 页面管理修复
ManagingPageViewFault]
imp@0x... +[WRPageViewController patchUIPageCurlFault] 翻页动画修复
imp@0x... +[WRPageViewController detectNavigationDirection 崩溃检测
CrashWithPageViewController:...]
```
#### WRReaderViewController (阅读器主控制器, 431 methods)
```
imp@0x... -[WRReaderViewController initWithBook: 初始化
progress:forceUseInitialProgress:
doodleMode:autoRead:]
imp@0x... -[WRReaderViewController renderPageView: 渲染页面
progressData:source:]
imp@0x... -[WRReaderViewController recomposeCurrentPageView 重新排版
WithSource:]
imp@0x... -[WRReaderViewController gotoChapterIdx: 跳转章节
position:positionOfFile:]
imp@0x... -[WRReaderViewController _saveReadingProgress 保存进度
AndIsAsync:]
imp@0x... -[WRReaderViewController changeTypesetterAttributes 修改排版属性
WithBlock:]
imp@0x... -[WRReaderViewController initChapterPageCount] 初始化分页
```
#### WREncryptedFileManager (DRM 管理)
```
imp@0x... +[WREncryptedFileManager decryptContentsOfFile: AES 解密
forBookId:isFileLost:]
imp@0x... +[WREncryptedFileManager encryptFileForBookId: 本地加密
originalEncryptKey:atPath:toPath:]
imp@0x... +[WREncryptedFileManager keyForBookId:] 获取密钥
```
#### WRBookNetwork (网络层, 44 class methods)
```
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:] 下载加密 ZIP
+[WRBookNetwork loadTarForEpubBookId:chapters:isPreload:] 批量下载
+[WRBookNetwork handleUnzipWithBookId:zipPath:encryptKey:...] 解密解压
+[WRBookNetwork handleUnzipErrorWithPath:plainBookDirectory:] 解压错误处理
+[WRBookNetwork processEncryptedBookFileAtPath:encryptKey:...] 文件处理
+[WRBookNetwork fileContentWithChapter:book:shouldRemoveHtmlTags: 读取内容
+:filterTranslateContent:]
+[WRBookNetwork loadChapterContentWithParam:callback:] 加载章节内容
+[WRBookNetwork chaptersInfoFromFile:checkTranslate:] 章节信息解析
+[WRBookNetwork savePreloadInfoWithDownloadParam:...] 保存预加载信息
+[WRBookNetwork clearPreloadKVWithBookId:chapterUid:zipPath:] 清理预加载缓存
+[WRBookNetwork storeTarEpubImageToDiskWithBookId:...] 存储图片
+[WRBookNetwork loadBookmarkListWithBookId:syncKey:callback:] 加载书签
+[WRBookNetwork addReview:shareToWechat:audioArticleId:...] 添加书评
+[WRBookNetwork searchResultsForBook:chapterUid:searchString:...] 搜索
+[WRBookNetwork uploadBookProgressAndReadingTime:...] 上传进度
+[WRBookNetwork loadBookInfoWithBookId:source:callback:] 书籍信息
+[WRBookNetwork loadBookReadInfoWithBookId:callback:] 阅读信息
+[WRBookNetwork fetchLockInfoWithBookId:callback:] 锁定信息
+[WRBookNetwork resetChapterPaidIfNeededWithBookId:chapterUid:] 重置付费
+[WRBookNetwork markReadingStatus:bookIds:isCancel:callback:] 标记阅读状态
+[WRBookNetwork setFinishReading:withBookId:callback:] 标记完成
+[WRBookNetwork setIsStartReading:withBookId:callback:] 标记开始
+[WRBookNetwork likeReviewById:isUnlike:withParams:callback:] 点赞
+[WRBookNetwork dislikeReviewById:isDislike:withParams:callback:] 点踩
+[WRBookNetwork repostReview:reposted:callback:] 转发
+[WRBookNetwork rewardReviewForId:price:timestamp:callback:] 打赏
+[WRBookNetwork postReviewHideWithBookId:hide:callback:] 隐藏书评
+[WRBookNetwork loadReadTimeWelfareActionWithBookId:...] 阅读时长福利
+[WRBookNetwork automaticallyMarkFinishReadingWithBookId:...] 自动标记完成
+[WRBookNetwork pollingChapterTranslateStatusWithBookId:...] 轮询翻译状态
+[WRBookNetwork _removeTranslateHtml:] 移除翻译HTML
+[WRBookNetwork processChapterInfosFromChapterDownload:bookId:] 处理章节信息
```
### 1.2 ivar 恢复表 (关键类)
```
WREpubTypesetter (4 ivars):
_cssFilePath NSString 内嵌 CSS 文件路径
_replaceCSSPath NSString replace.css 路径
_darkCSSPath NSString dark.css 路径
_options NSArray 排版选项
WRChapterData (12 ivars):
_attributedString NSMutableAttributedString 排版后的富文本
_pageRanges NSArray 每页 NSRange 数组
_highlights NSArray 高亮列表
_underlines NSArray 下划线列表
_reviews NSArray 书评列表
_tempHighlights NSArray 临时高亮
_tempUnderlines NSArray 临时下划线
_layouter WRCoreTextLayouter 排版器引用
_outlineContents NSArray 目录内容
_searchResults NSArray 搜索结果
_bookmarkSet NSSet 书签集合
_originalString NSAttributedString 原始富文本
WRCoreTextLayouter (32 ivars):
_htmlString NSString HTML 源码
_attributedString NSAttributedString 排版后的富文本
_layoutString NSAttributedString 实际排版用的富文本
_book WRBook 书籍引用
_chapter WRChapter 章节引用
_markContentChapter WRMarkContentChapter 标注内容
...
WRCoreTextLayoutFrame (25 ivars):
_attributedString NSAttributedString 排版用富文本
_lines NSMutableArray DTCoreTextLayoutLine 数组
_frame NSString CTFrame 描述
_stringIndices NSArray 字符索引映射
_pageRanges NSArray 页面范围
...
WRPageView (16 ivars):
_pageElements NSArray 页面元素列表
_imageViews NSMutableArray 图片视图缓存
_autoReadTimer NSTimer 自动阅读定时器
_turnPageTimer NSTimer 翻页定时器
_chapterTitle NSString 章节标题
_loadingLabel UILabel 加载提示
_loadingImageView UIImageView 加载图片
_activityIndicator WRActivityIndicator 加载指示器
_errorLabel UILabel 错误提示
_retryButton QMUIButton 重试按钮
_loadingProgress WRLoadingProgressView 加载进度
...
DTHTMLElement (38 ivars):
_textAttachment DTTextAttachment 文本附件
_linkURL NSURL 链接地址
_textColor UIColor 文字颜色
_backgroundColor UIColor 背景色
_tagName NSString 标签名
_children NSArray 子元素
_attributes NSDictionary HTML 属性
_cssStyles NSDictionary CSS 样式
_borderStyle DTBorderStyle 边框样式
_backgroundImage DTBackgroundImageStyle 背景图
_tableStyle DTTableStyle 表格样式
...
```
---
## 二、精简核心算法实现 (Python)
### 2.1 EPUB CSS 级联算法
```python
def cascade_stylesheets(default_css: str, replace_css: str, dark_css: str,
epub_css: str, user_settings_css: str) -> dict:
"""
5 层 CSS 级联: default → replace → dark → epub内嵌 → 用户设置
后面的层覆盖前面的层中相同选择器+属性的值。
Args:
default_css: Safari 默认样式 (default.css)
replace_css: 微信读书默认替换 (replace.css)
dark_css: 暗黑主题 (dark.css)
epub_css: EPUB 书籍内嵌 CSS
user_settings_css: 用户自定义设置 (字号/行高/主题)
Returns:
dict: {selector: {property: value}} 合并后的样式表
"""
import re
def parse_css(css_text: str) -> dict:
"""解析 CSS 文本为 {selector: {prop: val}} 字典"""
rules = {}
# 移除注释
css_text = re.sub(r'/\*.*?\*/', '', css_text, flags=re.DOTALL)
# 匹配选择器和声明块
for match in re.finditer(r'([^{}]+)\{([^{}]+)\}', css_text):
selectors = match.group(1).strip()
declarations = match.group(2).strip()
for selector in selectors.split(','):
selector = selector.strip()
if selector not in rules:
rules[selector] = {}
for decl in declarations.split(';'):
decl = decl.strip()
if ':' in decl:
prop, val = decl.split(':', 1)
rules[selector][prop.strip()] = val.strip()
return rules
def merge(base: dict, override: dict) -> dict:
"""合并两个样式表, override 覆盖 base"""
result = {k: dict(v) for k, v in base.items()}
for selector, props in override.items():
if selector not in result:
result[selector] = {}
result[selector].update(props)
return result
# 按优先级逐层合并
result = parse_css(default_css) # 层1: 基础
result = merge(result, parse_css(replace_css)) # 层2: 微信读书默认
result = merge(result, parse_css(dark_css)) # 层3: 暗黑主题
result = merge(result, parse_css(epub_css)) # 层4: EPUB 内嵌
result = merge(result, parse_css(user_settings_css)) # 层5: 用户设置 (最高优先级)
return result
```
### 2.2 CoreText 分页算法
```python
def paginate_attributed_string(attr_string_length: int,
page_width: float,
page_height: float,
line_height: float,
line_spacing: float,
paragraph_spacing: float) -> list:
"""
使用 CTTypesetterSuggestLineBreak 逐行排版, 累计高度超过页面高度时换页。
等价于 WRChapterPageCount.recalculatePageRangesForAttributedString
Args:
attr_string_length: 富文本总字符数
page_width: 页面宽度 (pt)
page_height: 页面高度 (pt)
line_height: 行高 (pt)
line_spacing: 行间距 (pt)
paragraph_spacing: 段间距 (pt)
Returns:
list of (start_index, length) 每页的 NSRange
"""
page_ranges = []
current_index = 0
page_start = 0
total_height = 0.0
while current_index < attr_string_length:
# CTTypesetterSuggestLineBreak: 给定当前位置和可用宽度, 返回本行能放多少字符
# 在真实代码中这是 CoreText 的 C 函数调用
line_break_index = suggest_line_break(current_index, page_width)
# 计算本行高度 (包含行间距)
current_line_height = line_height + line_spacing
# 检查是否需要换页
if total_height + current_line_height > page_height:
# 当前页结束
page_ranges.append((page_start, current_index - page_start))
page_start = current_index
total_height = 0.0
total_height += current_line_height
current_index += line_break_index
# 最后一页
if page_start < attr_string_length:
page_ranges.append((page_start, attr_string_length - page_start))
return page_ranges
def suggest_line_break(start_index: int, available_width: float) -> int:
"""
模拟 CTTypesetterSuggestLineBreak
从 start_index 开始, 在 available_width 内能放置的字符数
简化实现: 按空格/标点断行, 累计字符宽度
"""
# 实际实现依赖 CTTypesetter, 这里是逻辑等价
# CoreText 内部会考虑:
# - 字符宽度 (CTFontGetAdvancesForGlyphs)
# - 字间距 (kCTKernAttributeName)
# - 断行机会 (Unicode Line Break Algorithm)
# - 连字符断行 (CTTypesetterSuggestLineBreakWithHyphenation)
pass
```
### 2.3 avoidPageBreakInside 算法
```python
def avoid_page_break_inside(lines: list, page_height: float,
max_lines_to_remove: int = 3) -> list:
"""
如果最后几行被截断, 移除最多 max_lines_to_remove 行以避免内容破碎。
等价于 WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded
Args:
lines: DTCoreTextLayoutLine 列表, 每个有 height 属性
page_height: 页面可用高度 (pt)
max_lines_to_remove: 最多移除行数 (默认 3)
Returns:
调整后的 lines 列表
"""
if not lines:
return lines
# 计算总内容高度
total_height = sum(line.height for line in lines)
# 如果没有溢出, 无需处理
excess_height = total_height - page_height
if excess_height <= 0:
return lines
# 从最后一行开始, 尝试移除行直到不再溢出
removed_height = 0.0
lines_to_remove = 0
for i in range(len(lines) - 1, -1, -1):
if lines_to_remove >= max_lines_to_remove:
break
removed_height += lines[i].height
lines_to_remove += 1
if removed_height >= excess_height:
break
# 移除最后 N 行
if lines_to_remove > 0:
return lines[:len(lines) - lines_to_remove]
return lines
```
### 2.4 文本选择 Hit Test 算法
```python
def hit_test_for_character_index(touch_point: tuple,
ct_frame_lines: list,
view_height: float) -> int:
"""
将触摸点转换为字符索引。
等价于 WRPageView 中的 CoreText hit test 逻辑
坐标系转换:
UIKit 坐标系: 原点在左上角, Y 轴向下
CoreText 坐标系: 原点在左下角, Y 轴向上
Args:
touch_point: (x, y) UIKit 坐标系下的触摸点
ct_frame_lines: CTLine 列表, 每个有 origin 和 string_range
view_height: 视图高度 (用于坐标翻转)
Returns:
字符在 NSAttributedString 中的索引, -1 表示未命中
"""
touch_x, touch_y = touch_point
# 坐标翻转: UIKit → CoreText
ct_y = view_height - touch_y
for line_info in ct_frame_lines:
line_origin = line_info['origin'] # (x, y) CoreText 坐标
line_height = line_info['height']
line_ascent = line_info['ascent']
# 检查 Y 坐标是否在本行范围内
# CoreText 的 origin 是基线位置
line_top = line_origin[1] + line_ascent
line_bottom = line_origin[1] - (line_height - line_ascent)
if line_bottom <= ct_y <= line_top:
# 检查 X 坐标
line_width = line_info['width']
if line_origin[0] <= touch_x <= line_origin[0] + line_width:
# CTLineGetStringIndexForPosition 的等价逻辑
# 遍历本行的 glyph run, 找到包含触摸点的字符
relative_x = touch_x - line_origin[0]
char_index = line_info['string_range'][0]
# 遍历 run 中的每个 glyph, 累计宽度
accumulated_width = 0.0
for glyph_info in line_info['glyphs']:
accumulated_width += glyph_info['advance']
if accumulated_width >= relative_x:
return char_index
char_index += 1
return line_info['string_range'][0] + line_info['string_range'][1] - 1
return -1 # 未命中任何行
```
### 2.5 DRM 解密算法
```python
import hashlib
from Crypto.Cipher import AES
def decrypt_chapter(encrypted_data: bytes, book_id: str) -> bytes:
"""
AES-128-CBC 解密 EPUB 章节文件。
等价于 WREncryptedFileManager.decryptContentsOfFile:forBookId:isFileLost:
Args:
encrypted_data: 加密的文件数据
book_id: 书籍 ID (用于派生密钥)
Returns:
解密后的明文数据 (XHTML)
"""
# 检查自定义头部标识 "REWD"
if encrypted_data[:4] == b'REWD':
# 自定义加密格式
header_size = int.from_bytes(encrypted_data[4:8], 'little')
iv = encrypted_data[8:24] # 16 字节 IV
ciphertext = encrypted_data[24 + header_size:]
else:
# 标准 AES-128-CBC
iv = encrypted_data[:16] # 前 16 字节为 IV
ciphertext = encrypted_data[16:]
# 密钥派生: book_id → MD5 → 取前 16 字节
key = hashlib.md5(book_id.encode('utf-8')).digest()[:16]
# AES-128-CBC 解密, PKCS7 填充
cipher = AES.new(key, AES.MODE_CBC, iv)
plaintext = cipher.decrypt(ciphertext)
# 移除 PKCS7 填充
pad_len = plaintext[-1]
if 1 <= pad_len <= 16:
plaintext = plaintext[:-pad_len]
return plaintext
def encrypt_for_local_storage(plaintext: bytes, book_id: str,
original_key: bytes) -> bytes:
"""
本地二次加密存储 (DRM 保护)。
等价于 WREncryptedFileManager.encryptFileForBookId:
originalEncryptKey:atPath:toPath:
防止直接拷贝文件读取。
"""
# 生成本地存储密钥 (与原始密钥不同)
local_key = hashlib.md5((book_id + "local").encode('utf-8')).digest()[:16]
# 生成随机 IV
iv = os.urandom(16)
# AES-128-CBC 加密
# PKCS7 填充
pad_len = 16 - (len(plaintext) % 16)
plaintext += bytes([pad_len] * pad_len)
cipher = AES.new(local_key, AES.MODE_CBC, iv)
ciphertext = cipher.encrypt(plaintext)
# 输出: "REWD" 标识 + IV + 密文
return b'REWD' + iv + ciphertext
```
### 2.6 Glyph Path 生成算法
```python
def create_glyph_path(ct_run, ct_line_origin: tuple) -> list:
"""
从 CTRun 提取字形并生成 CGPath。
等价于 DTCoreTextGlyphRun.newPathWithGlyphs
Args:
ct_run: CTRun 对象
ct_line_origin: CTLine 原点 (x, y)
Returns:
list of CGPath 元素 (moveto/lineto/curveto/close)
"""
# 获取字形信息
glyph_count = CTRunGetGlyphCount(ct_run)
glyphs = (CGGlyph * glyph_count)()
positions = (CGPoint * glyph_count)()
CTRunGetGlyphs(ct_run, CFRangeMake(0, glyph_count), glyphs)
CTRunGetPositions(ct_run, CFRangeMake(0, glyph_count), positions)
# 获取字体
attributes = CTRunGetAttributes(ct_run)
ct_font = CFDictionaryGetValue(attributes, kCTFontAttributeName)
path_elements = []
for i in range(glyph_count):
glyph = glyphs[i]
position = positions[i]
# 转换为绝对坐标 (加上 CTLine 原点)
abs_x = ct_line_origin[0] + position.x
abs_y = ct_line_origin[1] + position.y
# CTFontCreatePathForGlyph: 获取单个字形的轮廓路径
glyph_path = CTFontCreatePathForGlyph(ct_font, glyph, None)
if glyph_path:
# 将字形路径平移到正确位置
transform = CGAffineTransformMakeTranslation(abs_x, abs_y)
transformed_path = CGPathCreateMutableCopyByTransformingPath(
glyph_path, transform
)
path_elements.append(transformed_path)
return path_elements
```
### 2.7 繁简转换算法
```python
def convert_hans_to_hant(attributed_string: str) -> str:
"""
简体中文 → 繁体中文转换。
等价于 WRCoreTextLayouter.convertHansToHantWithAttributedString:
两步法: 简体 → Latin 桥接 → 繁体
(避免 OpenCC 等库的直接转换可能丢失格式)
Args:
attributed_string: 包含简体中文的富文本
Returns:
转换为繁体的富文本
"""
# 步骤1: 使用 ICU 或 OpenCC 进行简→繁映射
# ICU: ubrk_open(UBRK_CHARACTER, "zh-Hant", ...)
# OpenCC: opencc_convert("s2t", text)
# 简化实现: 使用 Unicode 码点映射表
# 微信读书内部可能使用腾讯自研的繁简转换库
conversion_table = load_hans_to_hant_table() # ~8000 个映射
result = []
for char in attributed_string:
if char in conversion_table:
result.append(conversion_table[char])
else:
result.append(char)
return ''.join(result)
```
### 2.8 预加载预测算法
```python
def should_preload_chapters(book_rank: int, archive_rank: int,
user_reading_speed: float,
current_chapter_idx: int,
total_chapters: int) -> list:
"""
预测需要预加载的章节列表。
等价于 WRForecastUtils.shouldPreloadChapterUidsForBook:
type:bookRank:archiveRank:
Args:
book_rank: 书籍热度排名
archive_rank: 用户书架排名
user_reading_speed: 用户阅读速度 (章节/小时)
current_chapter_idx: 当前章节索引
total_chapters: 总章节数
Returns:
list of chapter_uid 需要预加载的章节 UID 列表
"""
preload_chapters = []
# 策略1: 总是预加载下一章
if current_chapter_idx + 1 < total_chapters:
preload_chapters.append(current_chapter_idx + 1)
# 策略2: 热门书籍预加载更多 (前 1000 名)
if book_rank < 1000:
# 预加载后 3 章
for i in range(2, 4):
if current_chapter_idx + i < total_chapters:
preload_chapters.append(current_chapter_idx + i)
# 策略3: 用户书架排名靠前的书预加载更多
if archive_rank < 10:
# 预加载后 5 章
for i in range(1, 6):
if current_chapter_idx + i < total_chapters:
preload_chapters.append(current_chapter_idx + i)
# 策略4: 快速阅读者预加载更多
if user_reading_speed > 5.0: # 每小时 >5 章
for i in range(1, 8):
if current_chapter_idx + i < total_chapters:
preload_chapters.append(current_chapter_idx + i)
# 去重并排序
return sorted(set(preload_chapters))
```
### 2.9 分页范围计算 (二分查找)
```python
def page_index_for_character_index(char_index: int,
page_ranges: list) -> int:
"""
二分查找字符索引所在的页码。
等价于 WRChapterPageCount 中的页码查找逻辑
Args:
char_index: 字符在 NSAttributedString 中的索引
page_ranges: [(start, length), ...] 每页的 NSRange
Returns:
页码 (0-based), -1 表示未找到
"""
if not page_ranges:
return -1
left, right = 0, len(page_ranges) - 1
while left <= right:
mid = (left + right) // 2
start, length = page_ranges[mid]
end = start + length
if char_index < start:
right = mid - 1
elif char_index >= end:
left = mid + 1
else:
return mid
# 边界情况: 在最后一页之后
return len(page_ranges) - 1
```
### 2.10 翻页控制器故障检测
```python
def detect_navigation_direction_crash(page_view_controller,
queuing_scroll_view,
is_animated: bool) -> bool:
"""
检测 UIPageViewController 的 QueuingScrollView 导航方向崩溃。
等价于 WRPageViewController.detectNavigationDirectionCrashWithPageViewController:
queuingScrollView:inMethodDidScrollWithAnimation:force:logDetail:logCrashReason:
已知 bug: QueuingScrollView 在快速连续翻页时可能崩溃
原因: 内部 child view controller 管理状态不一致
Returns:
True 表示检测到即将崩溃, 需要修复
"""
# 检查1: scroll view content offset 是否越界
content_offset = queuing_scroll_view.contentOffset
content_size = queuing_scroll_view.contentSize
bounds_size = queuing_scroll_view.bounds.size
if content_offset.x < -bounds_size.width:
return True # 向左越界
if content_offset.x > content_size.width:
return True # 向右越界
# 检查2: child view controller 数量是否异常
child_vcs = page_view_controller.childViewControllers
if len(child_vcs) > 3:
return True # UIPageViewController 最多应该有 3 个 child
# 检查3: 当前 page 是否为空
if page_view_controller.viewControllers is None:
return True
# 检查4: delegate 一致性
if page_view_controller.delegate is None:
return True
return False
```
@@ -0,0 +1,838 @@
# 微信读书 EPUB 阅读器 — 数据结构与 API 协议定义
> 基于 WeRead v10.0.3 (Build 79) 逆向工程
> 数据结构来源: __objc_methtype + class_ro_t ivar 段
> API 来源: WRBookNetwork 44 个 class method 签名
---
## 一、关键数据结构定义 (Header Files)
### 1.1 EPUB 解析数据结构
```objc
// ============================================================
#pragma mark - WREpubParser (EPUB 文件解析器)
// ============================================================
@interface WREpubParser : NSObject
@property (nonatomic, copy) NSString *epubFilePath; // EPUB 文件路径
@property (nonatomic, copy) NSString *opfFilePath; // content.opf 路径
@property (nonatomic, strong) NSError *lastError; // 最近错误
@property (nonatomic, weak) UIViewController *hostVC; // 宿主控制器
@property (nonatomic, strong) WRBook *book; // 书籍模型
@property (nonatomic, strong) WHAlbumInfo *albumInfo; // 专辑信息
// 解析 EPUB 结构
- (BOOL)parseEpubAtPath:(NSString *)path error:(NSError **)error;
// 获取章节目录 (NCX)
- (NSArray<NSDictionary *> *)tableOfContents;
// 获取资源文件映射 (manifest)
- (NSDictionary<NSString *, NSString *> *)resourceManifest;
// 获取 XHTML 章节文件路径
- (NSString *)filePathForChapterWithId:(NSString *)chapterId;
@end
// ============================================================
#pragma mark - WRBook (书籍模型)
// ============================================================
@interface WRBook : NSObject
@property (nonatomic, copy) NSString *bookId; // 书籍 ID
@property (nonatomic, copy) NSString *title; // 书名
@property (nonatomic, copy) NSString *author; // 作者
@property (nonatomic, copy) NSString *cover; // 封面 URL
@property (nonatomic, assign) WRBookFormat format; // epub/pdf/mp
@property (nonatomic, strong) NSArray<WRChapter *> *chapters; // 章节列表
@property (nonatomic, copy) NSString *encryptKey; // 加密密钥
@property (nonatomic, assign) BOOL isFinished; // 是否读完
@property (nonatomic, assign) BOOL isVIP; // 是否 VIP 书籍
typedef NS_ENUM(NSInteger, WRBookFormat) {
WRBookFormatEpub = 0,
WRBookFormatPDF = 1,
WRBookFormatMP = 2, // 公众号文章
};
@end
// ============================================================
#pragma mark - WRChapter (章节模型)
// ============================================================
@interface WRChapter : NSObject
@property (nonatomic, copy) NSString *chapterUid; // 章节唯一 ID
@property (nonatomic, assign) NSInteger chapterIdx; // 章节索引
@property (nonatomic, copy) NSString *title; // 章节标题
@property (nonatomic, assign) WRChapterFormat format; // 章节格式
@property (nonatomic, copy) NSString *fileId; // 文件 ID
@property (nonatomic, assign) NSUInteger filePosition; // 文件位置
@property (nonatomic, assign) NSUInteger fileSize; // 文件大小
@property (nonatomic, assign) BOOL isPaid; // 是否付费章节
@property (nonatomic, assign) BOOL isAvailable; // 是否可用
typedef NS_ENUM(NSInteger, WRChapterFormat) {
WRChapterFormatXHTML = 0,
WRChapterFormatHTML = 1,
WRChapterFormatTXT = 2,
};
@end
```
### 1.2 排版数据结构
```objc
// ============================================================
#pragma mark - DTHTMLElement (HTML 元素模型, 38 ivars)
// ============================================================
@interface DTHTMLElement : NSObject
// 树结构
@property (nonatomic, weak) DTHTMLElement *parent; // 父元素
@property (nonatomic, strong) NSMutableArray<DTHTMLElement *> *children; // 子元素
@property (nonatomic, copy) NSString *tagName; // 标签名 (p, h1, img...)
@property (nonatomic, copy) NSString *elementId; // id 属性
@property (nonatomic, copy) NSString *className; // class 属性
// 样式
@property (nonatomic, strong) DTCoreTextFontDescriptor *fontDescriptor; // 字体
@property (nonatomic, strong) DTCoreTextParagraphStyle *paragraphStyle; // 段落
@property (nonatomic, strong) UIColor *textColor; // 文字颜色
@property (nonatomic, strong) UIColor *backgroundColor; // 背景色
@property (nonatomic, assign) CGFloat textScale; // 文字缩放
@property (nonatomic, assign) CGFloat letterSpacing; // 字间距
// 附件
@property (nonatomic, strong) DTTextAttachment *textAttachment; // 附件 (图片/视频)
// 链接
@property (nonatomic, strong) NSURL *linkURL; // 超链接地址
// CSS 样式字典
@property (nonatomic, strong) NSDictionary *cssStyles; // 应用的 CSS 样式
// 边框与背景
@property (nonatomic, assign) DTBorderStyle borderTop; // 上边框
@property (nonatomic, assign) DTBorderStyle borderBottom; // 下边框
@property (nonatomic, strong) UIColor *borderColor; // 边框颜色
@property (nonatomic, strong) DTBackgroundImageStyle *backgroundImage; // 背景图
// 表格
@property (nonatomic, strong) DTTableStyle *tableStyle; // 表格样式
// 微信读书自定义属性
@property (nonatomic, assign) NSInteger verticalCenterStyle; // wr-vertical-center-style
@property (nonatomic, assign) BOOL pageRelate; // weread-page-relate
@property (nonatomic, assign) BOOL avoidPageBreakInside; // 断页保护
@property (nonatomic, assign) BOOL pageBreakAfter; // 元素后分页
@property (nonatomic, assign) BOOL pageBreakBefore; // 元素前分页
@property (nonatomic, strong) UIColor *pageBackgroundColor; // 页面背景色
@property (nonatomic, strong) NSURL *pageBackgroundImage; // 页面背景图
// 翻译
@property (nonatomic, assign) BOOL isTranslateTag; // 翻译标签
@property (nonatomic, assign) BOOL isTranslateNoStyle; // 无翻译样式
// 核心方法
- (void)applyStyleDictionary:(NSDictionary *)styles
isLatinLanguageBook:(BOOL)isLatin;
- (NSAttributedString *)attributedString;
- (void)interpretAttributes;
@end
// ============================================================
#pragma mark - DTCoreTextFontDescriptor (字体描述)
// ============================================================
@interface DTCoreTextFontDescriptor : NSObject
@property (nonatomic, copy) NSString *fontFamily; // 字体族 (如 "Source Han Serif CN")
@property (nonatomic, copy) NSString *fontName; // 字体名 (如 "SourceHanSerifCN-Medium")
@property (nonatomic, assign) CGFloat pointSize; // 字号 (pt)
@property (nonatomic, assign) BOOL bold; // 粗体
@property (nonatomic, assign) BOOL italic; // 斜体
@property (nonatomic, assign) uint32_t symbolicTraits; // 符号特征
// 匹配系统字体
- (CTFontRef)matchedFontDescriptor;
@end
// ============================================================
#pragma mark - DTCoreTextParagraphStyle (段落样式)
// ============================================================
@interface DTCoreTextParagraphStyle : NSObject
@property (nonatomic, assign) CTTextAlignment alignment; // 对齐方式
@property (nonatomic, assign) CGFloat lineSpacing; // 行间距
@property (nonatomic, assign) CGFloat paragraphSpacing; // 段间距
@property (nonatomic, assign) CGFloat paragraphSpacingBefore; // 段前间距
@property (nonatomic, assign) CGFloat firstLineHeadIndent; // 首行缩进
@property (nonatomic, assign) CGFloat headIndent; // 左缩进
@property (nonatomic, assign) CGFloat tailIndent; // 右缩进
@property (nonatomic, assign) CGFloat minimumLineHeight; // 最小行高
@property (nonatomic, assign) CGFloat maximumLineHeight; // 最大行高
@property (nonatomic, assign) CGFloat lineHeightMultiple; // 行高倍数
// 微信读书扩展
@property (nonatomic, assign) CGFloat defaultTabInterval; // 默认制表位
@end
// ============================================================
#pragma mark - DTTextAttachment (文本附件)
// ============================================================
@interface DTTextAttachment : NSObject
@property (nonatomic, assign) DTTextAttachmentType contentType; // 附件类型
@property (nonatomic, strong) NSData *contents; // 内容数据
@property (nonatomic, strong) NSURL *contentURL; // 内容 URL
@property (nonatomic, assign) CGSize displaySize; // 显示尺寸
@property (nonatomic, assign) CGSize originalSize; // 原始尺寸
@property (nonatomic, assign) CGFloat verticalAlignment; // 垂直对齐
typedef NS_ENUM(NSInteger, DTTextAttachmentType) {
DTTextAttachmentTypeImage = 0,
DTTextAttachmentTypeVideo = 1,
DTTextAttachmentTypeIframe = 2,
DTTextAttachmentTypeObject = 3,
};
@end
// ============================================================
#pragma mark - DTCSSStylesheet (CSS 样式表)
// ============================================================
@interface DTCSSStylesheet : NSObject
@property (nonatomic, strong) NSDictionary<NSString *, NSDictionary *> *rules;
// rules 格式: { "selector": { "property": "value", ... }, ... }
// 从 CSS 文本解析
+ (DTCSSStylesheet *)styleSheetWithCSSString:(NSString *)cssString;
// 合并另一个样式表 (后者覆盖前者)
- (void)mergeStylesheet:(DTCSSStylesheet *)other;
// 获取匹配选择器的样式
- (NSDictionary *)stylesForElement:(DTHTMLElement *)element;
@end
```
### 1.3 渲染数据结构
```objc
// ============================================================
#pragma mark - WRChapterData (章节数据模型, 12 ivars)
// ============================================================
@interface WRChapterData : NSObject
// 排版结果
@property (nonatomic, strong) NSMutableAttributedString *attributedString; // 排版后富文本
@property (nonatomic, strong) WRCoreTextLayouter *layouter; // 排版器
// 分页
@property (nonatomic, strong) NSArray<NSValue *> *pageRanges; // 每页 NSRange
// 标注
@property (nonatomic, strong) NSArray<WRPageHighlight *> *highlights; // 高亮列表
@property (nonatomic, strong) NSArray<WRPageUnderline *> *underlines; // 下划线列表
@property (nonatomic, strong) NSArray<WRPageMark *> *marks; // 书签标注
@property (nonatomic, strong) NSArray<NSDictionary *> *reviews; // 书评列表
@property (nonatomic, strong) NSArray *tempHighlights; // 临时高亮
@property (nonatomic, strong) NSArray *tempUnderlines; // 临时下划线
// 目录与搜索
@property (nonatomic, strong) NSArray<NSDictionary *> *outlineContents; // 目录
@property (nonatomic, strong) NSArray<NSDictionary *> *searchResults; // 搜索结果
// 书签
@property (nonatomic, strong) NSSet<NSString *> *bookmarkSet; // 书签集合
// 原始数据
@property (nonatomic, strong) NSAttributedString *originalString; // 原始富文本
// 核心方法
- (NSRange)rangeOfPage:(NSInteger)pageIndex;
- (void)addHighlightInRange:(NSRange)range key:(NSString *)key
itemId:(NSString *)itemId color:(UIColor *)color;
- (void)addReviewUnderlineInRange:(NSRange)range itemId:(NSString *)itemId
type:(WRReviewType)type;
- (void)deleteReviewUnderlineInRange:(NSRange)range type:(WRReviewType)type;
- (NSArray<NSDictionary *> *)generateOutlineContents;
- (NSInteger)freeTrialChapterCutOffRealStringLocation;
+ (void)addUnderLineToAttributedString:(NSMutableAttributedString *)attrStr
range:(NSRange)range
itemId:(NSString *)itemId
style:(WRUnderlineStyle)style
color:(UIColor *)color;
+ (NSInteger)freeTrialChapterCutOffStringLocaionWithAttributedString:
(NSAttributedString *)attrStr book:(WRBook *)book;
@end
// ============================================================
#pragma mark - WRChapterPageCount (分页计算, 4 ivars)
// ============================================================
@interface WRChapterPageCount : NSObject
@property (nonatomic, copy) NSString *bookId; // 书籍 ID
@property (nonatomic, copy) NSString *chapterUid; // 章节 ID
@property (nonatomic, copy) NSString *cacheKey; // 缓存键
@property (nonatomic, strong) NSArray<NSValue *> *pageRanges; // 页范围
+ (NSString *)currentCacheKeyWithBookId:(NSString *)bookId;
+ (NSValue *)rangeValueWithPageInfo:(NSDictionary *)pageInfo;
@end
```
### 1.4 标注数据结构
```objc
// ============================================================
#pragma mark - WRPageHighlight (页面高亮)
// ============================================================
@interface WRPageHighlight : NSObject
@property (nonatomic, assign) NSRange range; // 高亮范围
@property (nonatomic, copy) NSString *highlightKey; // 高亮唯一键
@property (nonatomic, copy) NSString *itemId; // 标注 ID
@property (nonatomic, strong) UIColor *color; // 高亮颜色
@property (nonatomic, assign) BOOL isTemporary; // 是否临时
@end
// ============================================================
#pragma mark - WRPageUnderline (页面下划线)
// ============================================================
@interface WRPageUnderline : NSObject
@property (nonatomic, assign) NSRange range; // 下划线范围
@property (nonatomic, copy) NSString *itemId; // 标注 ID
@property (nonatomic, assign) WRUnderlineStyle style; // 下划线样式
@property (nonatomic, strong) UIColor *color; // 颜色
typedef NS_ENUM(NSInteger, WRUnderlineStyle) {
WRUnderlineStyleSolid = 0, // 实线
WRUnderlineStyleDashed = 1, // 虚线
WRUnderlineStyleWavy = 2, // 波浪线
WRUnderlineStyleDotted = 3, // 点线
};
@end
// ============================================================
#pragma mark - WRPageMark (页面标注, 25 methods)
// ============================================================
@interface WRPageMark : NSObject
@property (nonatomic, assign) NSRange range; // 标注范围
@property (nonatomic, assign) WRMarkType type; // 标注类型
@property (nonatomic, copy) NSString *content; // 标注内容
@property (nonatomic, copy) NSString *itemId; // 标注 ID
@property (nonatomic, strong) NSDate *createTime; // 创建时间
@property (nonatomic, strong) UIColor *color; // 颜色
typedef NS_ENUM(NSInteger, WRMarkType) {
WRMarkTypeHighlight = 0, // 高亮
WRMarkTypeUnderline = 1, // 下划线
WRMarkTypeBookmark = 2, // 书签
WRMarkTypeNote = 3, // 笔记
WRMarkTypeReview = 4, // 书评
};
@end
// ============================================================
#pragma mark - WRBookmark (书签模型, 25 ivars)
// ============================================================
@interface WRBookmark : NSObject
@property (nonatomic, copy) NSString *bookId; // 书籍 ID
@property (nonatomic, copy) NSString *chapterUid; // 章节 ID
@property (nonatomic, copy) NSString *itemId; // 书签 ID
@property (nonatomic, assign) NSUInteger rangeLocation; // 范围起始
@property (nonatomic, assign) NSUInteger rangeLength; // 范围长度
@property (nonatomic, copy) NSString *abstractText; // 摘要文本
@property (nonatomic, assign) WRBookmarkType type; // 书签类型
@property (nonatomic, strong) UIColor *color; // 颜色
@property (nonatomic, assign) WRUnderlineStyle style; // 下划线样式
@property (nonatomic, copy) NSString *reviewId; // 关联书评 ID
@property (nonatomic, strong) NSDate *createTime; // 创建时间
@property (nonatomic, strong) NSDate *updateTime; // 更新时间
typedef NS_ENUM(NSInteger, WRBookmarkType) {
WRBookmarkTypeHighlight = 0,
WRBookmarkTypeUnderline = 1,
WRBookmarkTypeBookmark = 2,
WRBookmarkTypeNote = 3,
WRBookmarkTypeReview = 4,
};
@end
```
### 1.5 附件数据结构
```objc
// ============================================================
#pragma mark - WRPageImageAttachment (图片附件)
// ============================================================
@interface WRPageImageAttachment : NSObject
@property (nonatomic, copy) NSString *imageURL; // 图片 URL
@property (nonatomic, strong) UIImage *image; // 图片对象
@property (nonatomic, assign) CGSize displaySize; // 显示尺寸
@property (nonatomic, assign) CGSize originalSize; // 原始尺寸
@property (nonatomic, assign) NSInteger position; // 在文本中的位置
@property (nonatomic, assign) NSInteger verticalCenterStyle; // 居中方式
@property (nonatomic, assign) BOOL hasWhiteBackground; // 是否白底
@end
// ============================================================
#pragma mark - WRPageHyperlinksAttachment (超链接附件)
// ============================================================
@interface WRPageHyperlinksAttachment : NSObject
@property (nonatomic, strong) NSURL *url; // 链接 URL
@property (nonatomic, assign) NSRange range; // 文本范围
@property (nonatomic, copy) NSString *displayText; // 显示文本
@end
// ============================================================
#pragma mark - WRPageChapterToolAttachment (章节工具附件)
// ============================================================
@interface WRPageChapterToolAttachment : NSObject
@property (nonatomic, assign) WRChapterToolType type; // 工具类型
@property (nonatomic, strong) NSDictionary *data; // 工具数据
typedef NS_ENUM(NSInteger, WRChapterToolType) {
WRChapterToolTypeShare = 0, // 分享
WRChapterToolTypeReview = 1, // 书评
WRChapterToolTypeBookmark = 2, // 书签
WRChapterToolTypeNext = 3, // 下一章
};
@end
// ============================================================
#pragma mark - WRPageFlyleafAttachment (扉页附件)
// ============================================================
@interface WRPageFlyleafAttachment : NSObject
@property (nonatomic, assign) WRFlyleafType type; // 扉页类型
@property (nonatomic, strong) NSDictionary *data; // 扉页数据
typedef NS_ENUM(NSInteger, WRFlyleafType) {
WRFlyleafTypeCover = 0, // 封面
WRFlyleafTypeTitle = 1, // 标题页
WRFlyleafTypeAuthor = 2, // 作者页
WRFlyleafTypeIntro = 3, // 简介页
WRFlyleafTypeCatalog = 4, // 目录页
};
@end
// ============================================================
#pragma mark - WRPageCodeAttachment (代码块附件)
// ============================================================
@interface WRPageCodeAttachment : NSObject
@property (nonatomic, copy) NSString *codeString; // 代码内容
@property (nonatomic, copy) NSString *language; // 编程语言
@property (nonatomic, assign) NSInteger lineNumber; // 起始行号
@end
// ============================================================
#pragma mark - WRPageTableAttachment (表格附件)
// ============================================================
@interface WRPageTableAttachment : NSObject
@property (nonatomic, assign) NSInteger rows; // 行数
@property (nonatomic, assign) NSInteger columns; // 列数
@property (nonatomic, strong) NSArray<NSArray<NSString *> *> *cells; // 单元格数据
@end
```
---
## 二、API 与网络协议控制矩阵
### 2.1 书籍信息 API
```
┌────────────────────────────────────────────────────────────────────────────────┐
│ 方法签名 推断端点 HTTP 方法 │
├────────────────────────────────────────────────────────────────────────────────┤
│ +[WRBookNetwork loadBookInfoWithBookId: /api/book/info GET │
│ source:callback:] ?bookId=&source= │
│ │
│ +[WRBookNetwork loadBookReadInfoWithBookId: /api/book/readInfo GET │
│ callback:] ?bookId= │
│ │
│ +[WRBookNetwork loadBookReadInfoWithBookIdAndVid: /api/book/readInfo GET │
│ vid:callback:] ?bookId=&vid= │
│ │
│ +[WRBookNetwork loadBookReadDetailInfoWithBookId: /api/book/readDetail GET │
│ callback:] ?bookId= │
│ │
│ +[WRBookNetwork loadBookLectureAuthors: /api/book/authors GET │
│ callback:] ?bookId= │
│ │
│ +[WRBookNetwork loadArticleBookDetailWithBookId: /api/article/detail GET │
│ callback:] ?bookId= │
│ │
│ +[WRBookNetwork fetchLockInfoWithBookId: /api/book/lockInfo GET │
│ callback:] ?bookId= │
└────────────────────────────────────────────────────────────────────────────────┘
响应格式 (推断):
{
"bookId": "string",
"title": "string",
"author": "string",
"cover": "string (URL)",
"format": 0,
"chapters": [
{
"chapterUid": "string",
"chapterIdx": 0,
"title": "string",
"fileId": "string",
"filePosition": 0,
"fileSize": 0,
"isPaid": false
}
],
"encryptKey": "string",
"isFinished": false,
"isVIP": false
}
```
### 2.2 章节内容 API
```
┌────────────────────────────────────────────────────────────────────────────────┐
│ 方法签名 推断端点 HTTP 方法 │
├────────────────────────────────────────────────────────────────────────────────┤
│ +[WRBookNetwork loadTarForEpubBookId:chapter: /api/book/tar GET │
│ isPreload:] ?bookId=&chapterUid= │
│ &isPreload= │
│ 响应: 加密 ZIP 文件 (application/octet-stream) │
│ 文件名: {bookId}_DECRYPT.zip │
│ │
│ +[WRBookNetwork loadTarForEpubBookId:chapters: /api/book/tar GET │
│ isPreload:] ?bookId=&chapterUids= │
│ &isPreload= │
│ 批量下载: chapters 逗号分隔 │
│ │
│ +[WRBookNetwork loadChapterContentWithParam: /api/book/chapter GET │
│ callback:] ?bookId=&chapterUid= │
│ 参数: NSDictionary (bookId, chapterUid, format) │
│ │
│ +[WRBookNetwork fileContentWithChapter:book: 本地文件读取 - │
│ shouldRemoveHtmlTags:filterTranslateContent:] 从 plainBookDirectory 读取 │
│ │
│ +[WRBookNetwork chaptersInfoFromFile: 本地文件解析 - │
│ checkTranslate:] 解析 OPF/NCX │
└────────────────────────────────────────────────────────────────────────────────┘
```
### 2.3 预加载管理 API
```
┌────────────────────────────────────────────────────────────────────────────────┐
│ 方法签名 推断端点 HTTP 方法 │
├────────────────────────────────────────────────────────────────────────────────┤
│ +[WRBookNetwork savePreloadInfoWithDownloadParam: 本地存储 - │
│ chaptersStr:timeFlag:tmpFilePath:encryptKey:] NSUserDefaults │
│ │
│ +[WRBookNetwork clearPreloadKVWithBookId: 本地清理 - │
│ chapterUid:zipPath:] NSUserDefaults │
│ │
│ +[WRBookNetwork storeTarEpubImageToDiskWith 本地存储 - │
│ BookId:chapterUid:untarDirectory:] epubImage/ 目录 │
│ │
│ +[WRBookNetwork processEncryptedBookFileAtPath: 本地处理 - │
│ encryptKey:book:chapterUid:isFromReview:] 解密 + 存储 │
│ │
│ +[WRBookNetwork processChapterInfosFrom 本地处理 - │
│ ChapterDownload:bookId:] 解析章节信息 │
└────────────────────────────────────────────────────────────────────────────────┘
```
### 2.4 书签/标注 API
```
┌────────────────────────────────────────────────────────────────────────────────┐
│ 方法签名 推断端点 HTTP 方法 │
├────────────────────────────────────────────────────────────────────────────────┤
│ +[WRBookNetwork loadBookmarkListWithBookId: /api/book/bookmarks GET │
│ syncKey:callback:] ?bookId=&syncKey= │
│ │
│ +[WRBookNetwork addReview:shareToWechat: /api/review/add POST │
│ audioArticleId:outlineContent: body: {bookId, chapterUid, │
│ audioColumnId:callback:] content, range, type, ...} │
│ │
│ +[WRBookNetwork likeReviewById:isUnlike: /api/review/like POST │
│ withParams:callback:] body: {reviewId, isUnlike} │
│ │
│ +[WRBookNetwork dislikeReviewById:isDislike: /api/review/dislike POST │
│ withParams:callback:] body: {reviewId, isDislike} │
│ │
│ +[WRBookNetwork repostReview:reposted: /api/review/repost POST │
│ callback:] body: {reviewId, reposted} │
│ │
│ +[WRBookNetwork rewardReviewForId:price: /api/review/reward POST │
│ timestamp:callback:] body: {reviewId, price, ts} │
│ │
│ +[WRBookNetwork postReviewHideWithBookId: /api/review/hide POST │
│ hide:callback:] body: {bookId, hide} │
│ │
│ +[WRBookNetwork loadTopicReviewlist: /api/review/topic GET │
│ callback:] ?topicId= │
│ │
│ +[WRBookNetwork loadRelatedBooksForReviewDetail: /api/review/related GET │
│ callback:] ?reviewId= │
└────────────────────────────────────────────────────────────────────────────────┘
```
### 2.5 阅读进度 API
```
┌────────────────────────────────────────────────────────────────────────────────┐
│ 方法签名 推断端点 HTTP 方法 │
├────────────────────────────────────────────────────────────────────────────────┤
│ +[WRBookNetwork uploadBookProgressAndReadingTime: /api/book/progress POST │
│ callback:offlineCallback:] body: {bookId, chapterUid, │
│ progress, readingTime, ...} │
│ │
│ +[WRBookNetwork setFinishReading:withBookId: /api/book/finish POST │
│ callback:] body: {bookId, isFinished} │
│ │
│ +[WRBookNetwork setIsStartReading:withBookId: /api/book/start POST │
│ callback:] body: {bookId} │
│ │
│ +[WRBookNetwork markReadingStatus:bookIds: /api/book/status POST │
│ isCancel:callback:] body: {bookIds[], isCancel} │
│ │
│ +[WRBookNetwork markReadingStatus:withBookId: /api/book/status POST │
│ isCancel:withFinishInfo:callback:] body: {bookId, isCancel, │
│ finishInfo} │
│ │
│ +[WRBookNetwork automaticallyMarkFinishReading /api/book/autoFinish POST │
│ WithBookId:callback:] body: {bookId} │
│ │
│ +[WRBookNetwork addMileStone:callback:] /api/book/milestone POST │
│ body: {bookId, milestone} │
└────────────────────────────────────────────────────────────────────────────────┘
```
### 2.6 搜索 API
```
┌────────────────────────────────────────────────────────────────────────────────┐
│ 方法签名 推断端点 HTTP 方法 │
├────────────────────────────────────────────────────────────────────────────────┤
│ +[WRBookNetwork searchResultsForBook: /api/search GET │
│ chapterUid:searchString:posBeg: ?bookId=&chapterUid= │
│ posEnd:mode:callback:] &keyword=&posBeg= │
│ &posEnd=&mode= │
│ mode: 0=精确 1=模糊 2=正则 │
│ │
│ +[WRBookNetwork searchResultsForLocalBook: 本地搜索 - │
│ chapterUid:searchString:posBeg: 遍历 NSAttributedString │
│ posEnd:mode:callback:] │
└────────────────────────────────────────────────────────────────────────────────┘
```
### 2.7 翻译 API
```
┌────────────────────────────────────────────────────────────────────────────────┐
│ 方法签名 推断端点 HTTP 方法 │
├────────────────────────────────────────────────────────────────────────────────┤
│ +[WRBookNetwork pollingChapterTranslateStatus /api/book/translate GET │
│ WithBookId:isFreeTrialActive: ?bookId=&chapterUid= │
│ referenceLocationDict:chapterTranslations: &isFreeTrial= │
│ from:] │
│ │
│ +[WRBookNetwork _removeTranslateHtml:] 本地处理 - │
│ 移除翻译 HTML 标签 │
└────────────────────────────────────────────────────────────────────────────────┘
```
### 2.8 付费/会员 API
```
┌────────────────────────────────────────────────────────────────────────────────┐
│ 方法签名 推断端点 HTTP 方法 │
├────────────────────────────────────────────────────────────────────────────────┤
│ +[WRBookNetwork fetchLockInfoWithBookId: /api/book/lock GET │
│ callback:] ?bookId= │
│ │
│ +[WRBookNetwork resetChapterPaidIfNeeded /api/book/resetPaid POST │
│ WithBookId:chapterUid:] body: {bookId, chapterUid} │
│ │
│ +[WRBookNetwork loadReadTimeWelfareAction /api/welfare/readTime GET │
│ WithBookId:opt:secretKey:firstEnter:] ?bookId=&opt=&secretKey= │
│ │
│ +[WRBookNetwork checkFMCards:callback:] /api/fm/cards GET │
│ │
│ +[WRBookNetwork loadFMCardsWithBookId: /api/fm/cards GET │
│ withSynckey:withListType:withFilterType: ?bookId=&synckey= │
│ withMaxIdx:withCount:] &listType=&filterType= │
└────────────────────────────────────────────────────────────────────────────────┘
```
---
## 三、本地存储 Schema
### 3.1 文件系统布局
```
┌─────────────────────────────────────────────────────────────────────┐
│ App 沙箱 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Documents/ │
│ ├── {bookId}/ │
│ │ ├── plainBookDirectory/ ← 解密后的 EPUB XHTML 文件 │
│ │ │ ├── META-INF/container.xml │
│ │ │ ├── OEBPS/content.opf │
│ │ │ ├── OEBPS/toc.ncx │
│ │ │ ├── OEBPS/Text/chapter1.xhtml │
│ │ │ ├── OEBPS/Text/chapter2.xhtml │
│ │ │ └── ... │
│ │ ├── epubImage/ ← 书籍图片缓存 │
│ │ │ ├── image1.jpg │
│ │ │ ├── image2.png │
│ │ │ └── ... │
│ │ └── {bookId}_DECRYPT.zip ← 下载的加密 ZIP (可能已删除) │
│ │ │
│ └── ... (其他书籍) │
│ │
│ Library/ │
│ ├── {cachePath}/ │
│ │ └── epubImage/ ← SDWebImage 缓存 │
│ │ └── com.hackemist.SDWebImageCache.epubImage/ │
│ ├── Caches/ │
│ │ └── chapterCache/ ← 章节排版缓存 │
│ └── Preferences/ │
│ └── com.tencent.weread.plist ← NSUserDefaults │
│ │
│ tmp/ │
│ └── {临时解压目录} ← 下载解压临时目录 │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 3.2 NSUserDefaults Keys
```objc
// 阅读进度
NSString *const kWRReadingProgressKey = @"WRReadingProgress_{bookId}";
// 格式: {chapterUid, chapterIdx, filePosition, stringLocation, pageOfChapter, lastReadDate}
// 字体设置
NSString *const kWRFontFamilyKey = @"WRFontFamily"; // 字体族名
NSString *const kWRFontSizeKey = @"WRFontSize"; // 字号 (默认 18)
NSString *const kWRLineHeightKey = @"WRLineHeight"; // 行高 (默认 1.8)
NSString *const kWRParagraphSpacingKey = @"WRParagraphSpacing"; // 段间距
NSString *const kWRFirstIndentKey = @"WRFirstIndent"; // 首行缩进
// 主题设置
NSString *const kWRThemeKey = @"WRTheme"; // 主题 (light/dark/sepia)
NSString *const kWRBrightnessKey = @"WRBrightness"; // 亮度
// 翻页设置
NSString *const kWRPageTurningStyleKey = @"WRPageTurningStyle"; // 翻页样式 (curl/scroll)
NSString *const kWRAutoReadKey = @"WRAutoRead"; // 自动阅读
// 繁简转换
NSString *const kWRCht2sKey = @"WRCht2s_{bookId}"; // 繁简转换状态
// 预加载
NSString *const kWRPreloadSettingKey = @"WRPreloadSetting"; // 预加载设置
NSString *const kWRPreloadEncryptKeyKey = @"WRPreloadEncryptKey_{bookId}_{chapterUid}";
NSString *const kWRPreloadFileNameKey = @"WRPreloadFileName_{bookId}_{key}";
```
### 3.3 Keychain Keys
```objc
// 每本书的加密密钥
NSString *const kWRBookEncryptKeyKey = @"com.weread.encrypt.{bookId}";
// 存储在 Keychain 中, 标记为 kSecAttrAccessibleAfterFirstUnlock
// 用户凭证
NSString *const kWRUserTokenKey = @"com.weread.user.token";
NSString *const kWRUserVidKey = @"com.weread.user.vid";
```
### 3.4 NSAttributedString 自定义属性键
```objc
// 微信读书在 NSAttributedString 中注入的自定义属性
// 用于在排版结果中传递页面布局元数据
NSString *const DTPageBackgroundColorAttribute = @"DTPageBackgroundColor";
NSString *const DTPageBackgroundImageAttribute = @"DTPageBackgroundImage";
NSString *const DTPageBackgroundImagePathAttribute = @"DTPageBackgroundImagePath";
NSString *const DTPageBreakAfterAttribute = @"DTPageBreakAfter";
NSString *const DTPageBreakBeforeAttribute = @"DTPageBreakBefore";
NSString *const DTPageBreakInsideAvoidAttribute = @"DTPageBreakInsideAvoid";
NSString *const DTPageRelateAttribute = @"DTPageRelate";
NSString *const DTPageSize = @"DTPageSize";
NSString *const DTPageFlippingStyle = @"DTPageFlippingStyle";
NSString *const DTHTMLVerticalCenterAttribute = @"DTHTMLVerticalCenter";
NSString *const DTHTMLTranslateTagAttribute = @"DTHTMLTranslateTag";
NSString *const DTHTMLTranslateNoStyleAttribute = @"DTHTMLTranslateNoStyle";
// 标注相关属性
NSString *const WRHighlightAttributeKey = @"com.weread.highlight";
NSString *const WRUnderlineAttributeKey = @"com.weread.underline";
NSString *const WRBookmarkAttributeKey = @"com.weread.bookmark";
NSString *const WRReviewAttributeKey = @"com.weread.review";
// 附件属性
NSString *const DTTextAttachmentAttribute = @"DTTextAttachment";
NSString *const WRChapterToolAttachmentAttribute = @"WRChapterToolAttachment";
NSString *const WRFlyleafAttachmentAttribute = @"WRFlyleafAttachment";
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,369 @@
# DTCoreText 自定义修改分析
微信读书 (WeRead) 基于开源 DTCoreText 库进行了深度定制,将其从一个通用的 HTML-to-NSAttributedString 转换库改造为一套完整的电子书分页渲染引擎。本文档详细记录所有自定义修改。
---
## 1. 架构概览
开源 DTCoreText 提供三个核心能力:
- HTML/CSS 解析 (DTHTMLAttributedStringBuilder)
- DOM 元素模型 (DTHTMLElement)
- CoreText 排版封装 (DTCoreTextLayouter / LayoutFrame / LayoutLine / GlyphRun)
WeRead 在此基础上增加了:
- 一套完整的分页渲染引擎 (WRCoreTextLayouter / WRCoreTextLayoutFrame)
- 自定义 CSS 属性体系(控制分页、背景、垂直居中等)
- 自定义 NSAttributedString 属性键(传递页面布局元数据)
- 翻译/双语支持
- 免费试读截断机制
- 主题/字体动态切换
---
## 2. 自定义 CSS 属性
### 2.1 wr-vertical-center-style
**用途**: 控制内联元素(主要是图片)在行内的垂直居中方式。
**取值**:
- `1` — 基线对齐(默认)
- `2` — 垂直居中(用于 .bodyPic 类图片)
**处理流程**:
1. DTHTMLAttributedStringBuilder 解析 HTML 时读取该属性
2. 存储到 DTHTMLElement.verticalCenterStyle
3. 转换为 NSAttributedString 属性 `DTHTMLVerticalCenterAttribute`
4. WRCoreTextLayoutLine 排版时根据该属性调整 baseline offset
**关联 CSS 规则** (replace.css):
```css
img.bodyPic {
wr-vertical-center-style: 2;
max-width: 100%;
}
```
### 2.2 weread-page-relate
**用途**: 标记元素与页面级布局相关。
**取值**: `true` / `false`
**处理流程**:
1. 解析时存储到 DTHTMLElement.pageRelate
2. 转换为 `DTPageRelateAttribute`
3. 分页引擎使用该属性决定元素是否参与页面级定位
### 2.3 avoidPageBreakInside
**用途**: 防止元素内部被分页截断(类似 CSS `break-inside: avoid`)。
**适用场景**: 代码块、表格、列表、引用块。
**处理流程**:
1. 解析时设置 DTHTMLElement.shouldAvoidPageBreakInside = YES
2. 转换为 `DTPageBreakInsideAvoidAttribute`
3. WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded 检查该属性
4. 如果元素会被截断,移除末尾几行(最多 3 行)以避免断页
**关联类**: `WRBlockType` 属性标识块类型(table / code / list / blockquote
### 2.4 DTPageBreakAfter / DTPageBreakBefore
**用途**: 强制在元素前/后插入分页符。
**处理流程**:
1. 解析时设置 DTHTMLElement.pageBreakAfter / pageBreakBefore
2. 转换为 `DTPageBreakAfterAttribute` / `DTPageBreakBeforeAttribute`
3. attributedString 生成时插入 LINE SEPARATOR (U+2028) 作为分页标记
---
## 3. 自定义 NSAttributedString 属性键
WeRead 在 DTCoreText 标准属性之外定义了 12 个自定义属性键:
| 属性键 | 类型 | 用途 |
|--------|------|------|
| `DTPageBackgroundColorAttribute` | UIColor | 页面背景色 |
| `DTPageBackgroundImageAttribute` | NSString | 页面背景图片路径 |
| `DTPageBackgroundImagePathAttribute` | NSString | 背景图片绝对路径 |
| `DTPageBreakAfterAttribute` | NSNumber(BOOL) | 元素后强制分页 |
| `DTPageBreakBeforeAttribute` | NSNumber(BOOL) | 元素前强制分页 |
| `DTPageBreakInsideAvoidAttribute` | NSNumber(BOOL) | 避免元素内分页 |
| `DTPageRelateAttribute` | NSString | 页面关联标记 |
| `DTPageSize` | NSValue(CGSize) | 页面尺寸 |
| `DTPageFlippingStyle` | NSNumber(NSInteger) | 翻页动画样式 |
| `DTHTMLVerticalCenterAttribute` | NSString | 垂直居中样式 |
| `DTHTMLTranslateTagAttribute` | NSString | 翻译标签标记 |
| `DTHTMLTranslateNoStyleAttribute` | NSString | 无翻译样式标记 |
这些属性在 DTHTMLElement.attributedString 生成阶段写入,在 WRCoreTextLayoutFrame 排版和渲染阶段被读取。
---
## 4. DTHTMLElement 修改
### 4.1 新增属性
原版 DTHTMLElement 的属性主要关注字体、颜色、段落样式。WeRead 新增:
```objc
// 分页控制
@property BOOL shouldAvoidPageBreakInside;
@property BOOL pageBreakAfter;
@property BOOL pageBreakBefore;
// 页面布局
@property NSString *verticalCenterStyle; // wr-vertical-center-style
@property NSString *pageRelate; // weread-page-relate
@property UIColor *pageBackgroundColor; // DTPageBackgroundColor
@property NSString *pageBackgroundImage; // DTPageBackgroundImage
```
### 4.2 applyStyleDictionary 扩展
原版处理标准 CSS 属性(font-family, color, margin 等)。WeRead 新增 `_applyWeReadProperties:` 方法处理自定义 CSS 属性。
### 4.3 attributedString 生成扩展
原版递归生成 NSAttributedString。WeRead 在生成流程中增加了:
- 分页标记插入(pageBreakBefore / pageBreakAfter
- WeRead 页面属性应用(_applyWeReadPageAttributesToString:
---
## 5. DTHTMLAttributedStringBuilder 修改
### 5.1 新增 Book/Chapter 上下文
```objc
@property WRBook *book;
@property WRChapter *chapter;
```
原版 builder 无业务上下文。WeRead 注入书籍和章节信息,用于:
- 判断是否拉丁语书籍(影响字体回退)
- 获取 EPUB 嵌入 CSS
- 解析相对资源路径
### 5.2 自定义属性处理
新增 `_applyWeReadCustomAttributes:fromAttributes:` 方法,在 SAX 解析阶段识别并存储自定义 CSS 属性。
### 5.3 DTHTMLParserDelegate 扩展
内部 delegate 新增:
- `_book` / `_chapter` 上下文引用
- `_currentImageView` / `_imageContainerView` 等图片视图引用
- `_rootView` / `_parentView` 视图层级追踪
---
## 6. DTCoreTextLayouter 修改
### 6.1 布局帧缓存
新增 `NSCache *layoutFrameCache`(最多 20 个帧),避免重复排版。
### 6.2 分页辅助方法
新增:
- `stringIndexFittingLengthForWidth:startIndex:` — 模拟分页计算适配长度
- `suggestLineBreakStartIndex:width:` — 行断点建议
### 6.3 线程安全
新增 `NSLock *_lock` 保护 typesetter 和 layout frames 的并发访问。
---
## 7. DTCoreTextLayoutFrame 修改
### 7.1 完整的绘图管线
原版提供基本的 drawInContext:。WeRead 扩展为:
- `drawInContext:options:` — 支持 drawImages / drawLinks / drawSelection 选项
- `drawImagesInContext:options:` — 绘制图片附件
- `drawLinksInContext:options:` — 绘制链接下划线
- `drawSelectionInContext:options:` — 绘制选区高亮
### 7.2 文本几何查询
新增丰富的几何查询方法:
- `rectsForRange:` — 字符串范围对应的矩形数组
- `frameForStringRange:` — 范围的包围矩形
- `stringRangeForRect:` — 矩形内的字符串范围
- `baselineOriginForStringIndex:` — 基线原点
- `cursorRectForIndex:` — 光标位置
### 7.3 截断检测
新增 `isTruncated` / `truncatedStringRange` 用于检测内容是否超出可见区域。
---
## 8. DTCoreTextLayoutLine / GlyphRun 修改
### 8.1 LayoutLine
- 新增 `lineIndex` 属性标识行序号
- 新增 `isLastLineInParagraph` / `isLineBreak` 标记
- 新增 `paragraphStyle` / `metrics` 扩展属性
- 完整的 hit testing 支持
### 8.2 GlyphRun
- 新增 `glyphImages` 数组支持自定义字形渲染
- 新增 `attachments` 数组支持附件
- 新增 `isAttachment` / `isWhitespace` / `isNewline` 标记
- 新增 `writingDirection` / `isRTL` 支持从右到左文字
- 完整的 CGPath 生成(newPathWithGlyphs / newPathForGlyphAtIndex:
- 字形级别的 hit testing
---
## 9. WRCoreTextLayouterWeRead 新增类)
这是 WeRead 自己的排版器,包装 DTCoreTextLayouter 并增加业务逻辑。
### 9.1 核心功能
- **分页**: `layoutFramesForPageSize:` 将整章内容分割为页面
- **页数计算**: `numberOfPagesForPageSize:` 快速计算总页数
- **范围查询**: `rangeForPageAtIndex:pageSize:` 获取指定页的文本范围
- **页面背景**: `pageBackgroundImageAtRange:themeBgColor:` 生成页面背景图
- **图片缩放**: `resizedImageForImagePath:` 支持 full/half/third/quarter 尺寸模式
- **暗色模式**: `darkModeAdjusted` 图片混合处理
### 9.2 布局配置
```objc
@interface WRCoreTextLayoutConfig : NSObject
@property CGFloat frameWidth;
@property CGFloat frameHeight;
@property UIEdgeInsets edgeInsets;
@property NSUInteger numberOfColumns;
@property CGFloat columnGap;
@property BOOL avoidOrphans; // 避免孤行
@property BOOL avoidWidows; // 避免寡行
@property BOOL hyphenation; // 连字符断字
@end
```
### 9.3 与 DTCoreTextLayouter 的关系
WRCoreTextLayouter 内部持有 DTCoreTextLayouter 的 CTTypesetter,但使用自己的 WRCoreTextLayoutFrame 进行页面级布局。
---
## 10. WRCoreTextLayoutFrameWeRead 新增类)
这是 WeRead 自己的布局帧,包装 CTFrame 并增加页面级功能。
### 10.1 核心功能
- **avoidPageBreakInside**: 实现 CSS break-inside:avoid 语义
- **渲染高度计算**: `getRenderHeight` 返回实际内容高度
- **附件绘制**: `drawAttachmentsInContext:` 绘制图片
- **装饰元素**: 删除线、下划线、高亮背景
- **文本选择**: 完整的选择/搜索/高亮支持
- **RAC 响应式**: 通过 RACSubject 发送布局变化通知
### 10.2 avoidPageBreakInside 实现算法
```
1. 从末尾向前遍历所有行
2. 检查每行是否属于 avoidPageBreakInside 元素
3. 如果是,标记需要移除
4. 最多移除 3 行 (kMaxLinesToRemove)
5. 调用 rebuildFrameWithoutLastLines: 重建帧
```
---
## 11. WREpubTypesetterEPUB 排版入口)
这是 EPUB 渲染的最高层入口,协调整个排版流程。
### 11.1 CSS 级联顺序(优先级从低到高)
1. **default.css** — 基础 HTML 标签样式
2. **replace.css** — WeRead 默认替换(思源宋体标题、Menlo 代码、bodyPic 图片)
3. **dark.css** — 暗色主题覆盖(仅暗色模式加载)
4. **EPUB 嵌入 CSS** — 书籍自带样式
5. **用户设置 CSS** — 运行时用户偏好(字号、行高、主题色)
### 11.2 繁简转换
支持将简体中文内容转换为繁体(zh-Hant / zh-TW / zh-HK),使用 CFStringTransform 实现。
### 11.3 免费试读截断
非 VIP 用户每章有字符数限制,截断点为限制前最后一个段落边界,追加 "..." 指示器。
### 11.4 附件注入
章节末尾可选注入:
- articleTool — 文章工具栏(高亮、笔记、分享)
- bookChapterTool — 章节工具
- recommendView — 推荐视图
---
## 12. 字体系统
### 12.1 默认字体
- 正文: PingFang SC(苹方简体)
- 标题: Source Han Serif CN(思源宋体)
- 代码: Menlo(等宽)
### 12.2 动态字体加载
支持从 CDN 动态下载字体,通过 DTCoreTextFontDescriptor 管理字体描述符和匹配。
### 12.3 字体回退
拉丁语书籍使用不同的字体回退链,通过 `isLatinLanguageBook` 标记区分。
---
## 13. 主题系统
### 13.1 主题类型
- 默认(白底黑字)
- 护眼(米色 #f5f0e8
- 暗色(黑底灰字 #1a1a1a / #cccccc
- 自定义背景色
### 13.2 用户可配置项
通过 options 字典传入:
- `fontFamily` — 字体族
- `fontSize` — 字号 (默认 18px)
- `lineHeight` — 行高倍数
- `letterSpacing` — 字间距
- `backgroundColor` / `textColor` — 主题色
- `paragraphSpacing` — 段间距
- `firstLineIndent` — 首行缩进
---
## 14. 与原版 DTCoreText 的差异总结
| 维度 | 原版 DTCoreText | WeRead 定制版 |
|------|----------------|---------------|
| 定位 | 通用 HTML 渲染库 | 电子书分页渲染引擎 |
| CSS 支持 | 标准 CSS 2.1 | 标准 + 6 个自定义属性 |
| 分页 | 无 | 完整分页 + avoidPageBreakInside |
| 主题 | 无 | 暗色/护眼/自定义主题 |
| 翻译 | 无 | 繁简转换 + 双语支持 |
| 图片 | 基本附件 | 垂直居中 + 暗色适配 + CDN 加载 |
| 选择 | 无 | 完整文本选择 + 搜索高亮 |
| 响应式 | 无 | RACSubject 通知 |
| 线程安全 | 部分 | 全面 NSLock 保护 |
| 缓存 | 无 | 布局帧缓存 + 图片缓存 |
@@ -0,0 +1,582 @@
# EPUB 渲染管线详解
微信读书 (WeRead) 的 EPUB 渲染管线将原始 XHTML 文件转换为可分页、可交互的阅读视图。本文档完整描述从 EPUB 文件到屏幕像素的每一步。
---
## 1. 管线总览
```
EPUB 文件 (.epub)
|
v
[WREpubParser] 解析 EPUB 结构
|
v
[WREpubTypesetter] XHTML → NSAttributedString
| (CSS 级联 + HTML 解析 + 后处理)
v
[WRCoreTextLayouter] NSAttributedString → 分页布局
| (CTTypesetter + 分页算法)
v
[WRCoreTextLayoutFrame] 单页布局帧
| (CTFrame + 行提取 + 避免断页)
v
[WRPageView] 渲染到屏幕
| (CGContext 绘制 + 图片 + 装饰)
v
屏幕像素
```
---
## 2. 阶段一: EPUB 解析 (WREpubParser)
### 2.1 输入
EPUB 文件路径 + WRBook 模型对象
### 2.2 解析步骤
```
1. parseContainerXML
- 读取 META-INF/container.xml
- 提取 OPF 文件路径 (rootfile full-path)
2. parseOPFAtRelativePath
- 解析 content.opf
- 提取 manifest (id → href 映射)
- 提取 spine (阅读顺序 idref 列表)
- 提取 metadata (书名、标识符等)
3. parseNCX
- 解析 toc.ncx (目录)
- 提取 navPoint 树 (id, label, src, playOrder)
4. _buildChapterList
- 将 spine idref 映射到 manifest href
- 生成 chapters 数组 [{id, href, mediaType, fullPath}]
5. _buildResourceMap
- 构建资源路径映射 (href → 绝对路径)
- 同时索引文件名用于快速查找
```
### 2.3 输出
- chapters 数组(有序章节列表)
- resourceMap 字典(资源路径映射)
- WRBook 元数据更新
---
## 3. 阶段二: CSS 级联 (WREpubTypesetter)
### 3.1 CSS 加载顺序
```
Layer 1: default.css (App Bundle)
↓ 覆盖
Layer 2: replace.css (App Bundle)
↓ 覆盖
Layer 3: dark.css (App Bundle, 仅暗色模式)
↓ 覆盖
Layer 4: EPUB 嵌入 CSS (书籍自带)
↓ 覆盖
Layer 5: 用户设置 CSS (运行时生成)
```
### 3.2 各层内容
**Layer 1 - default.css**:
```css
body { font-family: "PingFang SC", sans-serif; margin: 0; padding: 10px 15px; }
p { margin-top: 0.5em; margin-bottom: 0.5em; }
h1 { font-size: 1.8em; font-weight: bold; }
h2 { font-size: 1.5em; font-weight: bold; }
h3 { font-size: 1.3em; font-weight: bold; }
ul, ol { padding-left: 1.5em; }
blockquote { margin-left: 1em; font-style: italic; }
```
**Layer 2 - replace.css**:
```css
h1, h2, h3 { font-family: "Source Han Serif CN", serif; }
pre, code { font-family: "Menlo", monospace; }
img.bodyPic { wr-vertical-center-style: 2; max-width: 100%; }
.conQuot { /* 引用块样式 */ }
```
**Layer 3 - dark.css**:
```css
body { background-color: #1a1a1a; color: #cccccc; }
a { color: #6eaad7; }
img { filter: brightness(0.85); }
```
**Layer 5 - 用户设置 CSS** (动态生成):
```css
body { font-size: 18px; line-height: 1.8; font-family: "PingFang SC", sans-serif; }
body { background-color: #f5f0e8; } /* 护眼模式 */
p { text-indent: 2em; } /* 首行缩进 */
```
### 3.3 级联合并
使用 DTCSSStylesheet.mergeStylesheet: 方法按顺序合并,后加载的覆盖先前的同名规则。
---
## 4. 阶段三: HTML 解析 (DTHTMLAttributedStringBuilder)
### 4.1 SAX 解析流程
```
XHTML 数据
|
v
DTHTMLParser (SAX)
|
├── didStartElement: → 创建 DTHTMLElement 节点
| 应用 CSS 样式
| 处理自定义属性
|
├── foundCharacters: → 累积文本到当前元素
|
├── foundCDATA: → 处理 CDATA 内容
|
└── didEndElement: → 弹出元素栈
调用 interpretAttributes
插入分页标记
```
### 4.2 元素处理
每个 HTML 标签被转换为 DTHTMLElement 节点:
```
DTHTMLElement
├── tagName: "p" / "div" / "img" / ...
├── classNames: ["bodyPic", "conQuot"]
├── fontDescriptor: 字体描述符
├── paragraphStyle: 段落样式
├── textColor / backgroundColor
├── children: [DTHTMLElement]
├── textAttachment (图片)
├── linkURL (链接)
├── [WeRead 扩展]
│ ├── verticalCenterStyle
│ ├── pageRelate
│ ├── shouldAvoidPageBreakInside
│ ├── pageBreakAfter / pageBreakBefore
│ ├── pageBackgroundColor
│ └── pageBackgroundImage
```
### 4.3 样式应用顺序
```
1. CSS 样式表规则 (class, id, tag 选择器)
2. 内联 style="" 属性
3. WeRead 自定义 CSS 属性
4. 元素默认样式 (基于标签名)
```
### 4.4 后处理 (_WRPostProcessElementTree)
在元素树转为 NSAttributedString 之前,执行 WeRead 特有的后处理:
**图片处理**:
- 设置最大显示尺寸 (1080x1920)
- 超限图片按比例缩放
- 添加 .bodyPic CSS 类
- 设置 wr-vertical-center-style: 2
**链接处理**:
- 添加下划线样式
- 存储链接 URL 到自定义属性
**自定义属性处理**:
- wr-vertical-center-style → DTHTMLVerticalCenterAttribute
- weread-page-relate → DTPageRelateAttribute
---
## 5. 阶段四: NSAttributedString 生成
### 5.1 递归转换
```
DTHTMLElement.attributedString
|
├── 处理 void 元素 (br → "\n", img → attachment, hr → "\n")
|
├── 插入 pageBreakBefore 标记 (如果需要)
|
├── 转换文本内容
| ├── 应用 CTFont (从 fontDescriptor)
| ├── 应用前景色 (kCTForegroundColorAttributeName)
| ├── 应用背景色 (DTBackgroundColor)
| └── 应用链接 (DTLink)
|
├── 递归处理子元素
|
├── 应用段落样式 (kCTParagraphStyleAttributeName)
|
├── 插入 pageBreakAfter 标记 (如果需要)
|
└── 应用 WeRead 页面属性
├── DTHTMLVerticalCenterAttribute
├── DTPageRelateAttribute
├── DTPageBreakInsideAvoidAttribute
├── DTPageBackgroundColorAttribute
└── DTPageBackgroundImageAttribute
```
### 5.2 输出
一个 NSAttributedString,包含:
- 标准 CoreText 属性 (字体、颜色、段落样式)
- DTCoreText 标准属性 (链接、附件、列表)
- WeRead 自定义属性 (分页、居中、背景)
---
## 6. 阶段五: 分页布局 (WRCoreTextLayouter)
### 6.1 Typesetter 创建
```
NSAttributedString
|
v
CTTypesetterCreateWithAttributedString
|
v
CTTypesetter (内部缓存字形分析结果)
```
### 6.2 分页算法
```
输入: NSAttributedString + pageSize
1. 计算可用宽度 = pageSize.width - edgeInsets.left - edgeInsets.right
2. 初始化 currentIndex = 0
3. 循环:
a. lineBreakIndex = CTTypesetterSuggestLineBreak(typesetter, currentIndex, usableWidth)
b. 如果 lineBreakIndex <= 0, 退出循环
c. 创建 WRCoreTextLayoutFrame(range: currentIndex..<currentIndex+lineBreakIndex)
d. currentIndex += lineBreakIndex
4. 返回 [WRCoreTextLayoutFrame] 数组 (每帧 = 一页)
```
### 6.3 页面范围查询
`rangeForPageAtIndex:pageSize:` 通过模拟分页快速定位指定页的文本范围,无需创建布局帧对象。
### 6.4 行高建议
`suggestedLineFragHeights` 逐行创建临时 CTLine 测量高度,用于精确分页计算和避免孤行/寡行。
---
## 7. 阶段六: 单页布局 (WRCoreTextLayoutFrame)
### 7.1 CTFrame 创建
```
CTTypesetter + range + CGPath(rect)
|
v
CTTypesetterCreateFrame
|
v
CTFrame (包含 CTLines 和 CTRuns)
```
### 7.2 行提取
```
CTFrameGetLines → CTLine 数组
CTFrameGetLineOrigins → 行原点数组
对每个 CTLine:
CTLineGetStringRange → 字符范围
CTLineGetTypographicBounds → ascent/descent/leading
创建 WRCoreTextLayoutLine 对象
判断每行是否为段落末尾行 (检查下一个字符是否为 '\n')
```
### 7.3 avoidPageBreakInside 实现
```
1. 从最后一行向前遍历
2. 检查行的 attributedString 属性:
- WRAvoidPageBreakInside == YES?
- WRBlockType ∈ {table, code, list, blockquote}?
3. 如果是受保护元素的一部分,标记需要移除
4. 最多移除 3 行 (kMaxLinesToRemove)
5. 调用 rebuildFrameWithoutLastLines: 更新帧
6. 返回 YES 表示有行被移除
```
### 7.4 渲染高度计算
```
getRenderHeight:
lastLine = lines.lastObject
lastLineBottom = lastLine.origin.y - lastLine.descent
renderedHeight = frameHeight - lastLineBottom + insets.top + insets.bottom
```
---
## 8. 阶段七: 绘制到屏幕 (WRCoreTextLayoutFrame)
### 8.1 绘制流程
```
drawInContext:image:size:inRect:position:
1. CGContextSaveGState
2. 坐标系翻转 (CoreText 底左原点 → UIKit 顶左原点)
CGContextTranslateCTM(0, height)
CGContextScaleCTM(1, -1)
3. 应用位置偏移 (多列/多页布局)
4. 应用内容内边距
5. 绘制封面图片 (如果有)
6. CTFrameDraw(ctFrame, context) — 绘制文本
7. drawAttachmentsInContext: — 绘制图片附件
8. drawDecorativeElementsInContext: — 绘制装饰元素
├── 删除线
├── 下划线
└── 高亮背景
9. CGContextRestoreGState
```
### 8.2 图片绘制
```
遍历 attachments 数组:
对每个 attachment:
获取 image, position, size
CGContextDrawImage(context, imageRect, image.CGImage)
```
### 8.3 装饰元素绘制
**删除线**:
```
遍历 strikethroughRanges:
对每个 range:
找到相交的行
计算 startX, endX (CTLineGetOffsetForStringIndex)
y = line.origin.y + ascent * 0.3
CGContextStrokePath
```
**下划线**:
```
遍历 underlineRanges:
对每个 range:
找到相交的行
y = line.origin.y - descent
CGContextStrokePath (蓝色)
```
**高亮**:
```
遍历 highlightRanges:
对每个 range:
找到相交的行
计算高亮矩形 (descent 到 ascent)
CGContextFillRect (半透明黄色)
```
---
## 9. 交互层
### 9.1 文本选择
```
characterIndexAtPoint:
遍历所有行
检查点是否在行的垂直范围内
使用 CTLineGetStringIndexForPosition 定位字符
selectTextInRange:
遍历所有行
找到与 range 相交的行
添加到 selectedLineIndices
通过 RACSubject 发送选择通知
```
### 9.2 搜索高亮
```
highlightSearchResults:
使用 NSString rangeOfString: 搜索
收集所有匹配范围
创建 highlightRanges (黄色半透明)
返回匹配数量
```
### 9.3 Hit Testing
```
stringIndexAtPoint:
遍历行 → 找到目标行
CTLineGetStringIndexForPosition → 字符索引
rectForCharacterAtIndex:
遍历行 → 找到包含索引的行
CTLineGetOffsetForStringIndex → x 坐标
返回 CGRect
```
---
## 10. 附加功能
### 10.1 繁简转换
```
如果 book.language ∈ {zh-Hant, zh-TW, zh-HK}:
CFStringTransform(Hans → Latin → Hant)
```
### 10.2 免费试读截断
```
如果 isFreeTrial && result.length > trialCharacterLimit:
从限制位置向前搜索段落边界 (\n 或 U+2029)
截取子串
追加 "\n\n...\n\n" 指示器
```
### 10.3 附件注入
```
如果 insertArticleToolAttachment:
追加 NSTextAttachment {type: "articleTool"}
如果 insertBookChapterToolAttachment:
追加 NSTextAttachment {type: "bookChapterTool"}
如果 insertRecommendView:
追加 NSTextAttachment {type: "recommendView"}
```
### 10.4 页面背景生成
```
pageBackgroundImageAtRange:themeBgColor:
检查是否为章节开头页
创建 UIGraphicsImageContext
填充主题背景色
如果是章节开头: 绘制装饰边框
否则: 绘制边距参考线
缓存结果
```
### 10.5 图片缩放
```
resizedImageForImagePath:rect:position:sizePattern:darkMode:themeBgColor:
根据 sizePattern 计算目标尺寸:
"full" → 全宽
"half" → 半宽
"third" → 1/3 宽
"quarter" → 1/4 宽
如果 darkMode: 混合背景色
高质量插值缩放
缓存结果
```
---
## 11. 性能优化
### 11.1 缓存策略
- **布局帧缓存**: NSCache, 最多 20 个 (DTCoreTextLayouter)
- **图片缓存**: NSCache, 最多 50 个 (WRCoreTextLayouter)
- **页面背景缓存**: NSCache, 按 range + 颜色键 (WRCoreTextLayouter)
- **内容高度缓存**: 布局帧级别 (DTCoreTextLayoutFrame)
### 11.2 懒加载
- CTTypesetter 按需创建 (createTypesetter)
- CTFramesetter 按需创建 (createFramesetter)
- 行数据按需提取 (extractLines)
- 字形数据按需提取 (extractGlyphs)
### 11.3 线程安全
所有核心类使用 NSLock 保护:
- DTCoreTextLayouter._lock
- DTCoreTextLayoutFrame._lock
- DTCoreTextLayoutLine._lock
- DTCoreTextGlyphRun._lock
- WRCoreTextLayouter._layoutLock
- WRCoreTextLayoutFrame._frameLock
### 11.4 增量更新
- attributedString 变化时标记 typesetterDirty
- 仅在下次访问时重建 typesetter
- 布局帧缓存自动失效
---
## 12. 数据流总结
```
EPUB 文件
├─ container.xml → OPF 路径
├─ content.opf → manifest + spine + metadata
├─ toc.ncx → 目录树
└─ *.xhtml → 章节内容
XHTML 字符串
│ (繁简转换)
CSS 级联合并
│ (5 层合并)
DTHTMLAttributedStringBuilder
│ (SAX 解析 + DOM 构建 + 样式应用)
DTHTMLElement 树
│ (后处理: 图片/链接/自定义属性)
NSAttributedString
│ (包含标准 + 自定义属性)
CTTypesetter
│ (字形分析 + 行断点计算)
WRCoreTextLayoutFrame[]
│ (每帧 = 一页, 含 avoidPageBreakInside)
CGContext 绘制
│ (文本 + 图片 + 装饰)
屏幕像素
```
+311
View File
@@ -0,0 +1,311 @@
//
// DTCoreTextGlyphRun.h
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// DTCoreText is an open-source library for rendering HTML with CoreText.
// This class represents a glyph run within a line of text.
//
#import <Foundation/Foundation.h>
#import <CoreText/CoreText.h>
@class DTCoreTextLayoutLine;
@class DTTextAttachment;
NS_ASSUME_NONNULL_BEGIN
/**
* DTCoreTextGlyphRun - Represents a glyph run within a line.
*
* A glyph run is a sequence of glyphs that share the same attributes
* (font, color, etc.). This class wraps CTRun and provides high-level
* access to individual glyphs and their properties.
*
* Key features:
* - Wraps CTRun for high-level access
* - Contains glyph images and paths
* - Handles text attachments (images, etc.)
* - Provides hit testing within the run
* - Supports custom drawing and effects
*/
@interface DTCoreTextGlyphRun : NSObject
#pragma mark - Properties
/** The CTRun reference */
@property (nonatomic, assign, readonly) CTRunRef ctRun;
/** The frame (position and size) of this run */
@property (nonatomic, assign) CGRect frame;
/** The range of characters in this run */
@property (nonatomic, assign, readonly) NSRange stringRange;
/** The index of this run in its parent line */
@property (nonatomic, assign, readonly) NSUInteger runIndex;
/** The attributes shared by all glyphs in this run */
@property (nonatomic, strong, readonly) NSDictionary *attributes;
/** The parent layout line */
@property (nonatomic, weak, nullable) DTCoreTextLayoutLine *layoutLine;
/** Array of glyph images (for custom rendering) */
@property (nonatomic, strong, nullable) NSArray *glyphImages;
/** Array of attachment objects */
@property (nonatomic, strong, nullable) NSArray<DTTextAttachment *> *attachments;
/** The text attachment (if this run contains one) */
@property (nonatomic, strong, nullable) DTTextAttachment *attachment;
/** Whether this run is a placeholder for an attachment */
@property (nonatomic, assign, readonly) BOOL isAttachment;
/** Whether this run is a whitespace */
@property (nonatomic, assign, readonly) BOOL isWhitespace;
/** Whether this run is a newline */
@property (nonatomic, assign, readonly) BOOL isNewline;
/** The font used in this run */
@property (nonatomic, strong, nullable) UIFont *font;
/** The text color */
@property (nonatomic, strong, nullable) UIColor *textColor;
/** The background color */
@property (nonatomic, strong, nullable) UIColor *backgroundColor;
/** The strikethrough color */
@property (nonatomic, strong, nullable) UIColor *strikethroughColor;
/** Whether strikethrough is enabled */
@property (nonatomic, assign) BOOL hasStrikethrough;
/** Whether underline is enabled */
@property (nonatomic, assign) BOOL hasUnderline;
/** The underline style */
@property (nonatomic, assign) NSUnderlineStyle underlineStyle;
/** The underline color */
@property (nonatomic, strong, nullable) UIColor *underlineColor;
/** Number of glyphs in this run */
@property (nonatomic, assign, readonly) NSUInteger numberOfGlyphs;
/** Array of glyph values */
@property (nonatomic, strong, readonly) NSArray<NSNumber *> *glyphs;
/** Array of glyph positions */
@property (nonatomic, strong, readonly) NSArray<NSValue *> *glyphPositions;
/** Array of glyph advances */
@property (nonatomic, strong, readonly) NSArray<NSNumber *> *glyphAdvances;
/** The writing direction */
@property (nonatomic, assign) CTWritingDirection writingDirection;
/** Whether this run is right-to-left */
@property (nonatomic, assign, readonly) BOOL isRTL;
#pragma mark - Initialization
/**
* Initialize with a CTRun.
*
* @param ctRun The CoreText run
* @param frame The frame of the run
* @param range The string range
* @param attributes The run attributes
* @param index The run index
* @return Initialized glyph run
*/
- (instancetype)initWithCTRun:(CTRunRef)ctRun
frame:(CGRect)frame
range:(NSRange)range
attributes:(NSDictionary *)attributes
index:(NSUInteger)index;
#pragma mark - Glyph Access
/**
* Returns the glyph at the specified index.
*
* @param index The glyph index
* @return The glyph value
*/
- (CGGlyph)glyphAtIndex:(NSUInteger)index;
/**
* Returns the position of the glyph at the specified index.
*
* @param index The glyph index
* @return The glyph position
*/
- (CGPoint)positionForGlyphAtIndex:(NSUInteger)index;
/**
* Returns the advance of the glyph at the specified index.
*
* @param index The glyph index
* @return The glyph advance
*/
- (CGFloat)advanceForGlyphAtIndex:(NSUInteger)index;
/**
* Returns the rect for the glyph at the specified index.
*
* @param index The glyph index
* @return The glyph rect
*/
- (CGRect)rectForGlyphAtIndex:(NSUInteger)index;
#pragma mark - Path Operations
/**
* Creates a CGPath containing all glyphs in this run.
*
* This method creates a path that outlines all the glyphs.
* It's used for custom drawing, hit testing, and effects.
*
* @return A CGPath containing the glyph outlines, or NULL
*/
- (nullable CGPathRef)newPathWithGlyphs;
/**
* Creates a CGPath for a specific glyph.
*
* @param index The glyph index
* @return A CGPath for the glyph, or NULL
*/
- (nullable CGPathRef)newPathForGlyphAtIndex:(NSUInteger)index;
/**
* Creates a bounding path for all glyphs.
*
* @return A CGPath bounding all glyphs
*/
- (nullable CGPathRef)newBoundingPath;
#pragma mark - Image Operations
/**
* Returns the image for the glyph at the specified index.
*
* @param index The glyph index
* @return The glyph image, or nil
*/
- (nullable UIImage *)imageForGlyphAtIndex:(NSUInteger)index;
/**
* Returns the bounding rect for the glyph image.
*
* @param index The glyph index
* @return The image rect
*/
- (CGRect)imageRectForGlyphAtIndex:(NSUInteger)index;
#pragma mark - Hit Testing
/**
* Returns the glyph index at the given point.
*
* @param point The point to test
* @return The glyph index, or NSNotFound
*/
- (NSUInteger)glyphIndexAtPoint:(CGPoint)point;
/**
* Returns the string index at the given point.
*
* @param point The point to test
* @return The string index, or NSNotFound
*/
- (NSUInteger)stringIndexAtPoint:(CGPoint)point;
/**
* Returns the rect for a given string index.
*
* @param index The string index
* @return The rect for the character
*/
- (CGRect)rectForStringIndex:(NSUInteger)index;
#pragma mark - Drawing
/**
* Draws this run into a CGContext.
*
* @param context The CGContext to draw into
*/
- (void)drawInContext:(CGContextRef)context;
/**
* Draws this run with a specific color.
*
* @param context The CGContext to draw into
* @param color The text color
*/
- (void)drawInContext:(CGContextRef)context withColor:(UIColor *)color;
/**
* Draws the attachment (if any) into the context.
*
* @param context The CGContext to draw into
*/
- (void)drawAttachmentInContext:(CGContextRef)context;
#pragma mark - Run Comparison
/**
* Compares this run to another run for ordering.
*/
- (NSComparisonResult)compareToRun:(DTCoreTextGlyphRun *)otherRun;
/**
* Returns whether this run contains the given string index.
*/
- (BOOL)containsStringIndex:(NSUInteger)index;
/**
* Returns whether this run intersects with the given range.
*/
- (BOOL)intersectsRange:(NSRange)range;
#pragma mark - Metrics
/**
* Returns the typographic bounds of this run.
*
* @param ascent Output parameter for ascent
* @param descent Output parameter for descent
* @param leading Output parameter for leading
* @return The width of the run
*/
- (CGFloat)getTypographicBoundsAscent:(CGFloat *)ascent
descent:(CGFloat *)descent
leading:(CGFloat *)leading;
/**
* Returns the bounds of the run (bounding box).
*/
- (CGRect)bounds;
/**
* Returns the width of the run.
*/
- (CGFloat)width;
/**
* Returns the height of the run.
*/
- (CGFloat)height;
@end
NS_ASSUME_NONNULL_END
+698
View File
@@ -0,0 +1,698 @@
//
// DTCoreTextGlyphRun.m
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// This file contains pseudo-code reconstruction of the DTCoreTextGlyphRun
// class based on ivar analysis, method signatures, and the open-source DTCoreText library.
//
#import "DTCoreTextGlyphRun.h"
#import "DTCoreTextLayoutLine.h"
#import <CoreText/CoreText.h>
#pragma mark - DTTextAttachment Stub
/**
* Stub for DTTextAttachment class.
* Represents an embedded object (image, view, etc.) in the text.
*/
@interface DTTextAttachment : NSObject
@property (nonatomic, strong) UIImage *image;
@property (nonatomic, assign) CGSize displaySize;
@property (nonatomic, assign) CGRect frame;
@property (nonatomic, copy) NSString *contentType;
@end
@implementation DTTextAttachment
@end
#pragma mark - Private Interface
@interface DTCoreTextGlyphRun () {
// Cached glyph data
CGGlyph *_glyphs;
CGPoint *_positions;
CGFloat *_advances;
CFIndex _glyphCount;
// Whether glyph data has been extracted
BOOL _glyphsExtracted;
// Cached metrics
CGFloat _cachedAscent;
CGFloat _cachedDescent;
CGFloat _cachedLeading;
CGFloat _cachedWidth;
BOOL _metricsCached;
// Internal lock
NSLock *_lock;
}
@end
#pragma mark - DTCoreTextGlyphRun Implementation
@implementation DTCoreTextGlyphRun
#pragma mark - Lifecycle
- (instancetype)initWithCTRun:(CTRunRef)ctRun
frame:(CGRect)frame
range:(NSRange)range
attributes:(NSDictionary *)attributes
index:(NSUInteger)index {
self = [super init];
if (self) {
_lock = [[NSLock alloc] init];
// Store the CTRun with a retain
if (ctRun) {
_ctRun = (CTRunRef)CFRetain(ctRun);
}
_frame = frame;
_stringRange = range;
_attributes = [attributes copy];
_runIndex = index;
// Initialize state
_glyphsExtracted = NO;
_metricsCached = NO;
_glyphs = NULL;
_positions = NULL;
_advances = NULL;
_glyphCount = 0;
// Extract common attributes
[self extractAttributes];
}
return self;
}
- (void)dealloc {
if (_ctRun) {
CFRelease(_ctRun);
_ctRun = NULL;
}
// Free allocated glyph data
if (_glyphs) {
free(_glyphs);
_glyphs = NULL;
}
if (_positions) {
free(_positions);
_positions = NULL;
}
if (_advances) {
free(_advances);
_advances = NULL;
}
}
#pragma mark - Attribute Extraction
/**
* Extracts common attributes from the attributes dictionary.
*/
- (void)extractAttributes {
if (!_attributes) {
return;
}
// Extract font
CTFontRef ctFont = (__bridge CTFontRef)_attributes[(__bridge NSString *)kCTFontAttributeName];
if (ctFont) {
_font = [UIFont fontWithDescriptor:[UIFontDescriptor fontDescriptorWithCTFont:ctFont]
size:CTFontGetSize(ctFont)];
}
// Extract text color
CGColorRef textColor = (__bridge CGColorRef)_attributes[(__bridge NSString *)kCTForegroundColorAttributeName];
if (textColor) {
_textColor = [UIColor colorWithCGColor:textColor];
}
// Extract background color
CGColorRef bgColor = (__bridge CGColorRef)_attributes[@"DTBackgroundColor"];
if (bgColor) {
_backgroundColor = [UIColor colorWithCGColor:bgColor];
}
// Extract strikethrough
NSNumber *strikethrough = _attributes[(__bridge NSString *)kCTSuperscriptAttributeName];
if (strikethrough) {
_hasStrikethrough = [strikethrough boolValue];
}
// Extract underline
NSNumber *underlineStyle = _attributes[(__bridge NSString *)kCTUnderlineColorAttributeName];
if (underlineStyle) {
_hasUnderline = YES;
_underlineStyle = [underlineStyle integerValue];
}
// Check for attachment
_attachment = _attributes[@"DTTextAttachment"];
if (_attachment) {
_isAttachment = YES;
}
// Extract writing direction
NSArray *writingDirection = _attributes[(__bridge NSString *)kCTWritingDirectionAttributeName];
if ([writingDirection count] > 0) {
_writingDirection = [writingDirection[0] integerValue];
}
}
#pragma mark - Glyph Extraction
/**
* Extracts glyph data from the CTRun.
*
* This method extracts:
* - Glyph values (CGGlyph)
* - Glyph positions (CGPoint)
* - Glyph advances (CGFloat)
*/
- (void)extractGlyphs {
if (_glyphsExtracted || !_ctRun) {
return;
}
[_lock lock];
// Get the number of glyphs
_glyphCount = CTRunGetGlyphCount(_ctRun);
if (_glyphCount == 0) {
_glyphsExtracted = YES;
[_lock unlock];
return;
}
// Allocate memory for glyph data
_glyphs = (CGGlyph *)malloc(sizeof(CGGlyph) * _glyphCount);
_positions = (CGPoint *)malloc(sizeof(CGPoint) * _glyphCount);
_advances = (CGFloat *)malloc(sizeof(CGFloat) * _glyphCount);
// Extract glyph values
CTRunGetGlyphs(_ctRun, CFRangeMake(0, 0), _glyphs);
// Extract glyph positions
CTRunGetPositions(_ctRun, CFRangeMake(0, 0), _positions);
// Extract glyph advances
CTRunGetAdvances(_ctRun, CFRangeMake(0, 0), (CGSize *)_advances);
_glyphsExtracted = YES;
[_lock unlock];
}
#pragma mark - Glyph Access
- (CGGlyph)glyphAtIndex:(NSUInteger)index {
[self extractGlyphs];
if (index >= _glyphCount || !_glyphs) {
return 0;
}
return _glyphs[index];
}
- (CGPoint)positionForGlyphAtIndex:(NSUInteger)index {
[self extractGlyphs];
if (index >= _glyphCount || !_positions) {
return CGPointZero;
}
return _positions[index];
}
- (CGFloat)advanceForGlyphAtIndex:(NSUInteger)index {
[self extractGlyphs];
if (index >= _glyphCount || !_advances) {
return 0;
}
return _advances[index];
}
- (CGRect)rectForGlyphAtIndex:(NSUInteger)index {
[self extractGlyphs];
if (index >= _glyphCount) {
return CGRectNull;
}
CGPoint position = _positions[index];
CGFloat advance = _advances[index];
// Calculate the rect for this glyph
// The position is relative to the run's origin
return CGRectMake(
_frame.origin.x + position.x,
_frame.origin.y,
advance,
_frame.size.height
);
}
#pragma mark - Path Operations
/**
* Creates a CGPath containing all glyphs in this run.
*
* This method creates a path by transforming each glyph's outline
* to its position within the run. This is used for:
* - Custom drawing with effects
* - Hit testing with complex shapes
* - Creating outlines for selection
*
* Algorithm:
* 1. Extract all glyph data
* 2. For each glyph:
* a. Get the glyph's path from the font
* b. Transform to the glyph's position
* c. Add to the combined path
* 3. Return the combined path
*
* @return A CGPath containing all glyph outlines
*/
- (CGPathRef)newPathWithGlyphs {
[self extractGlyphs];
if (!_ctRun || _glyphCount == 0) {
return NULL;
}
// Create a mutable path for the combined glyphs
CGMutablePathRef combinedPath = CGPathCreateMutable();
// Get the font from attributes
CTFontRef font = (__bridge CTFontRef)_attributes[(__bridge NSString *)kCTFontAttributeName];
if (!font) {
return combinedPath;
}
// Process each glyph
for (CFIndex i = 0; i < _glyphCount; i++) {
CGGlyph glyph = _glyphs[i];
CGPoint position = _positions[i];
// Get the path for this glyph from the font
CGPathRef glyphPath = CTFontCreatePathForGlyph(font, glyph, NULL);
if (glyphPath) {
// Create a transform to position the glyph
// The position is relative to the run's origin
CGAffineTransform transform = CGAffineTransformMakeTranslation(
_frame.origin.x + position.x,
_frame.origin.y + position.y
);
// Add the transformed glyph path to the combined path
CGPathAddPath(combinedPath, &transform, glyphPath);
// Release the individual glyph path
CGPathRelease(glyphPath);
}
}
return combinedPath;
}
/**
* Creates a CGPath for a specific glyph.
*/
- (CGPathRef)newPathForGlyphAtIndex:(NSUInteger)index {
[self extractGlyphs];
if (!_ctRun || index >= _glyphCount) {
return NULL;
}
// Get the font from attributes
CTFontRef font = (__bridge CTFontRef)_attributes[(__bridge NSString *)kCTFontAttributeName];
if (!font) {
return NULL;
}
CGGlyph glyph = _glyphs[index];
CGPoint position = _positions[index];
// Get the path for this glyph
CGPathRef glyphPath = CTFontCreatePathForGlyph(font, glyph, NULL);
if (!glyphPath) {
return NULL;
}
// Create a transform to position the glyph
CGAffineTransform transform = CGAffineTransformMakeTranslation(
_frame.origin.x + position.x,
_frame.origin.y + position.y
);
// Create a new path with the transform applied
CGMutablePathRef transformedPath = CGPathCreateMutable();
CGPathAddPath(transformedPath, &transform, glyphPath);
// Release the original glyph path
CGPathRelease(glyphPath);
return transformedPath;
}
/**
* Creates a bounding path for all glyphs.
*/
- (CGPathRef)newBoundingPath {
[self extractGlyphs];
if (_glyphCount == 0) {
return NULL;
}
// Create a simple rectangular path that bounds all glyphs
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, _frame);
return path;
}
#pragma mark - Image Operations
/**
* Returns the image for the glyph at the specified index.
*
* This is used for custom rendering where glyphs are replaced with images.
*/
- (UIImage *)imageForGlyphAtIndex:(NSUInteger)index {
if (!_glyphImages || index >= [_glyphImages count]) {
return nil;
}
return _glyphImages[index];
}
/**
* Returns the bounding rect for the glyph image.
*/
- (CGRect)imageRectForGlyphAtIndex:(NSUInteger)index {
[self extractGlyphs];
if (index >= _glyphCount) {
return CGRectNull;
}
CGPoint position = _positions[index];
CGFloat advance = _advances[index];
// The image rect is centered on the glyph position
return CGRectMake(
_frame.origin.x + position.x,
_frame.origin.y,
advance,
_frame.size.height
);
}
#pragma mark - Hit Testing
/**
* Returns the glyph index at the given point.
*
* This method checks if the point falls within any glyph's bounding rect.
*
* @param point The point to test
* @return The glyph index, or NSNotFound
*/
- (NSUInteger)glyphIndexAtPoint:(CGPoint)point {
[self extractGlyphs];
for (NSUInteger i = 0; i < _glyphCount; i++) {
CGRect glyphRect = [self rectForGlyphAtIndex:i];
if (CGRectContainsPoint(glyphRect, point)) {
return i;
}
}
return NSNotFound;
}
/**
* Returns the string index at the given point.
*/
- (NSUInteger)stringIndexAtPoint:(CGPoint)point {
NSUInteger glyphIndex = [self glyphIndexAtPoint:point];
if (glyphIndex == NSNotFound) {
return NSNotFound;
}
// Convert glyph index to string index
// This assumes a 1:1 mapping between glyphs and characters
// For complex scripts, this may need more sophisticated mapping
return _stringRange.location + glyphIndex;
}
/**
* Returns the rect for a given string index.
*/
- (CGRect)rectForStringIndex:(NSUInteger)index {
if (index < _stringRange.location ||
index >= _stringRange.location + _stringRange.length) {
return CGRectNull;
}
// Convert string index to glyph index
NSUInteger glyphIndex = index - _stringRange.location;
return [self rectForGlyphAtIndex:glyphIndex];
}
#pragma mark - Drawing
/**
* Draws this run into a CGContext.
*
* This method draws the glyphs using CTLineDraw or by manually
* drawing each glyph at its position.
*
* @param context The CGContext to draw into
*/
- (void)drawInContext:(CGContextRef)context {
if (!context || !_ctRun) {
return;
}
// Save graphics state
CGContextSaveGState(context);
// Draw the run using CoreText
CTRunDraw(_ctRun, context, CFRangeMake(0, 0));
// Restore graphics state
CGContextRestoreGState(context);
}
/**
* Draws this run with a specific color.
*/
- (void)drawInContext:(CGContextRef)context withColor:(UIColor *)color {
if (!context || !_ctRun || !color) {
return;
}
// Save graphics state
CGContextSaveGState(context);
// Set the text color
CGContextSetFillColorWithColor(context, color.CGColor);
// Draw the run
CTRunDraw(_ctRun, context, CFRangeMake(0, 0));
// Restore graphics state
CGContextRestoreGState(context);
}
/**
* Draws the attachment (if any) into the context.
*/
- (void)drawAttachmentInContext:(CGContextRef)context {
if (!context || !_attachment || !_attachment.image) {
return;
}
// Save graphics state
CGContextSaveGState(context);
// Draw the attachment image at the run's frame
CGContextDrawImage(context, _frame, _attachment.image.CGImage);
// Restore graphics state
CGContextRestoreGState(context);
}
#pragma mark - Run Comparison
/**
* Compares this run to another run for ordering.
*/
- (NSComparisonResult)compareToRun:(DTCoreTextGlyphRun *)otherRun {
// Compare by string range location
if (_stringRange.location < otherRun.stringRange.location) {
return NSOrderedAscending;
} else if (_stringRange.location > otherRun.stringRange.location) {
return NSOrderedDescending;
}
// If same location, compare by run index
if (_runIndex < otherRun.runIndex) {
return NSOrderedAscending;
} else if (_runIndex > otherRun.runIndex) {
return NSOrderedDescending;
}
return NSOrderedSame;
}
/**
* Returns whether this run contains the given string index.
*/
- (BOOL)containsStringIndex:(NSUInteger)index {
return index >= _stringRange.location &&
index < _stringRange.location + _stringRange.length;
}
/**
* Returns whether this run intersects with the given range.
*/
- (BOOL)intersectsRange:(NSRange)range {
NSRange intersection = NSIntersectionRange(_stringRange, range);
return intersection.length > 0;
}
#pragma mark - Metrics
- (void)calculateMetrics {
if (_metricsCached || !_ctRun) {
return;
}
[_lock lock];
// Get typographic bounds from CTRun
_cachedWidth = CTRunGetTypographicBounds(_ctRun, CFRangeMake(0, 0),
&_cachedAscent, &_cachedDescent, &_cachedLeading);
_metricsCached = YES;
[_lock unlock];
}
- (CGFloat)getTypographicBoundsAscent:(CGFloat *)ascent
descent:(CGFloat *)descent
leading:(CGFloat *)leading {
[self calculateMetrics];
if (ascent) *ascent = _cachedAscent;
if (descent) *descent = _cachedDescent;
if (leading) *leading = _cachedLeading;
return _cachedWidth;
}
- (CGRect)bounds {
[self calculateMetrics];
return CGRectMake(0, -_cachedDescent, _cachedWidth, _cachedAscent + _cachedDescent);
}
- (CGFloat)width {
[self calculateMetrics];
return _cachedWidth;
}
- (CGFloat)height {
[self calculateMetrics];
return _cachedAscent + _cachedDescent;
}
- (NSUInteger)numberOfGlyphs {
[self extractGlyphs];
return _glyphCount;
}
- (NSArray<NSNumber *> *)glyphs {
[self extractGlyphs];
NSMutableArray *glyphArray = [NSMutableArray arrayWithCapacity:_glyphCount];
for (CFIndex i = 0; i < _glyphCount; i++) {
[glyphArray addObject:@(_glyphs[i])];
}
return [glyphArray copy];
}
- (NSArray<NSValue *> *)glyphPositions {
[self extractGlyphs];
NSMutableArray *positionArray = [NSMutableArray arrayWithCapacity:_glyphCount];
for (CFIndex i = 0; i < _glyphCount; i++) {
[positionArray addObject:[NSValue valueWithCGPoint:_positions[i]]];
}
return [positionArray copy];
}
- (NSArray<NSNumber *> *)glyphAdvances {
[self extractGlyphs];
NSMutableArray *advanceArray = [NSMutableArray arrayWithCapacity:_glyphCount];
for (CFIndex i = 0; i < _glyphCount; i++) {
[advanceArray addObject:@(_advances[i])];
}
return [advanceArray copy];
}
- (BOOL)isRTL {
return _writingDirection == kCTWritingDirectionRightToLeft;
}
- (BOOL)isWhitespace {
if (!_ctRun || _glyphCount == 0) {
return NO;
}
// Check if all characters in the range are whitespace
// This would need access to the full attributed string
// For now, return NO as a conservative default
return NO;
}
- (BOOL)isNewline {
if (!_ctRun || _glyphCount == 0) {
return NO;
}
// Check if the run contains only newline characters
// This would need access to the full attributed string
return NO;
}
@end
@@ -0,0 +1,239 @@
//
// DTCoreTextLayoutFrame.h
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// DTCoreText is an open-source library for rendering HTML with CoreText.
// This class represents a single frame of laid-out text.
//
#import <Foundation/Foundation.h>
#import <CoreText/CoreText.h>
@class DTCoreTextLayoutLine;
@class DTCoreTextGlyphRun;
@class NSAttributedString;
NS_ASSUME_NONNULL_BEGIN
/**
* DTCoreTextLayoutFrame - Represents a single frame of laid-out text.
*
* This class wraps CTFrame and provides high-level access to the
* layout of text. It contains an array of DTCoreTextLayoutLine objects
* and provides methods for drawing, hit testing, and accessing layout information.
*
* Key features:
* - Wraps CTFrame for high-level access
* - Contains array of DTCoreTextLayoutLine objects
* - Supports drawing to CGContext
* - Provides hit testing for text selection
* - Handles complex text layout with multiple columns
*/
@interface DTCoreTextLayoutFrame : NSObject
#pragma mark - Properties
/** The attributed string that was laid out */
@property (nonatomic, strong, readonly) NSAttributedString *attributedString;
/** The range of the attributed string represented by this frame */
@property (nonatomic, assign, readonly) NSRange range;
/** The bounding rectangle for this frame */
@property (nonatomic, assign, readonly) CGRect frame;
/** The CTFrame reference */
@property (nonatomic, assign, readonly) CTFrameRef ctFrame;
/** Array of DTCoreTextLayoutLine objects */
@property (nonatomic, strong, readonly) NSArray<DTCoreTextLayoutLine *> *lines;
/** Number of lines in this frame */
@property (nonatomic, assign, readonly) NSUInteger numberOfLines;
/** The rendered content height */
@property (nonatomic, assign, readonly) CGFloat contentHeight;
/** The maximum Y coordinate of the content */
@property (nonatomic, assign, readonly) CGFloat maximumY;
/** The minimum Y coordinate of the content */
@property (nonatomic, assign, readonly) CGFloat minimumY;
/** Array of glyph runs in this frame */
@property (nonatomic, strong, readonly) NSArray<DTCoreTextGlyphRun *> *glyphRuns;
/** Whether the frame needs layout */
@property (nonatomic, assign) BOOL needsLayout;
/** The layout size used for this frame */
@property (nonatomic, assign) CGSize layoutSize;
/** The string index where layout ended */
@property (nonatomic, assign, readonly) NSUInteger stringIndex;
/** The visible string range (after truncation) */
@property (nonatomic, assign, readonly) NSRange visibleStringRange;
#pragma mark - Initialization
/**
* Initialize with attributed string, range, and CTFrame.
*
* @param attributedString The attributed string
* @param range The string range
* @param ctFrame The CoreText frame
* @return Initialized layout frame
*/
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString
range:(NSRange)range
ctFrame:(CTFrameRef)ctFrame;
#pragma mark - Line Access
/**
* Returns the array of layout lines.
*
* @return Array of DTCoreTextLayoutLine objects
*/
- (NSArray<DTCoreTextLayoutLine *> *)lines;
/**
* Returns the line at the specified index.
*
* @param index The line index
* @return The layout line at the index, or nil
*/
- (nullable DTCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index;
/**
* Returns the line that contains the given string index.
*
* @param index The string index
* @return The layout line containing the index, or nil
*/
- (nullable DTCoreTextLayoutLine *)lineContainingStringIndex:(NSUInteger)index;
/**
* Returns the index of the line containing the given point.
*
* @param point The point to test
* @return The line index, or NSNotFound
*/
- (NSUInteger)lineIndexContainingPoint:(CGPoint)point;
#pragma mark - Drawing
/**
* Draw the layout frame content into a CGContext.
*
* @param context The CGContext to draw into
*/
- (void)drawInContext:(CGContextRef)context;
/**
* Draw the layout frame with a specific options dictionary.
*
* @param context The CGContext to draw into
* @param options Drawing options
*/
- (void)drawInContext:(CGContextRef)context options:(nullable NSDictionary *)options;
#pragma mark - Hit Testing
/**
* Returns the string index at the given point.
*
* @param point The point to test
* @return The string index, or NSNotFound
*/
- (NSUInteger)stringIndexAtPoint:(CGPoint)point;
/**
* Returns the rect for the given string index.
*
* @param index The string index
* @return The rect for the character
*/
- (CGRect)rectForStringIndex:(NSUInteger)index;
/**
* Returns the cursor position for a given string index.
*
* @param index The string index
* @return The cursor rect
*/
- (CGRect)cursorRectForIndex:(NSUInteger)index;
#pragma mark - Text Geometry
/**
* Returns the baseline origin for a given string index.
*
* @param index The string index
* @return The baseline origin point
*/
- (CGPoint)baselineOriginForStringIndex:(NSUInteger)index;
/**
* Returns the frame for a given string range.
*
* @param range The string range
* @return The bounding rect for the range
*/
- (CGRect)frameForStringRange:(NSRange)range;
/**
* Returns the string range for a given rect.
*
* @param rect The rect to test
* @return The string range within the rect
*/
- (NSRange)stringRangeForRect:(CGRect)rect;
#pragma mark - Line Geometry
/**
* Returns the line origins.
*
* @return Array of CGPoint values wrapped in NSValue
*/
- (NSArray<NSValue *> *)lineOrigins;
/**
* Returns the line frames.
*
* @return Array of CGRect values wrapped in NSValue
*/
- (NSArray<NSValue *> *)lineFrames;
#pragma mark - Truncation
/**
* Returns whether the frame is truncated.
*/
- (BOOL)isTruncated;
/**
* Returns the truncation string range.
*/
- (NSRange)truncatedStringRange;
#pragma mark - Layout Updates
/**
* Invalidates the layout, forcing recalculation on next access.
*/
- (void)invalidateLayout;
/**
* Forces a layout pass.
*/
- (void)layoutIfNeeded;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,777 @@
//
// DTCoreTextLayoutFrame.m
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// This file contains pseudo-code reconstruction of the DTCoreTextLayoutFrame
// class based on ivar analysis, method signatures, and the open-source DTCoreText library.
//
#import "DTCoreTextLayoutFrame.h"
#import "DTCoreTextLayoutLine.h"
#import "DTCoreTextGlyphRun.h"
#import <CoreText/CoreText.h>
#pragma mark - Private Interface
@interface DTCoreTextLayoutFrame () {
// The CoreText frame object
CTFrameRef _ctFrame;
// Cached layout lines
NSArray<DTCoreTextLayoutLine *> *_cachedLines;
// Cached glyph runs
NSArray<DTCoreTextGlyphRun *> *_cachedGlyphRuns;
// Cached line origins
NSArray<NSValue *> *_cachedLineOrigins;
// Whether lines have been extracted
BOOL _linesExtracted;
// Whether glyph runs have been extracted
BOOL _glyphRunsExtracted;
// Internal lock
NSLock *_lock;
// Cached content height
CGFloat _cachedContentHeight;
BOOL _contentHeightCached;
// Visible string range
NSRange _visibleStringRange;
// Whether the frame is truncated
BOOL _isTruncated;
// Truncation range
NSRange _truncatedRange;
}
@end
#pragma mark - DTCoreTextLayoutFrame Implementation
@implementation DTCoreTextLayoutFrame
#pragma mark - Lifecycle
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString
range:(NSRange)range
ctFrame:(CTFrameRef)ctFrame {
self = [super init];
if (self) {
_attributedString = attributedString;
_range = range;
_lock = [[NSLock alloc] init];
// Store the CTFrame
if (ctFrame) {
_ctFrame = (CTFrameRef)CFRetain(ctFrame);
}
// Initialize state
_linesExtracted = NO;
_glyphRunsExtracted = NO;
_contentHeightCached = NO;
_needsLayout = YES;
// Get the frame path bounds
if (_ctFrame) {
CGPathRef path = CTFrameGetPath(_ctFrame);
_frame = CGPathGetPathBoundingBox(path);
}
// Initialize visible range to full range
_visibleStringRange = range;
}
return self;
}
- (void)dealloc {
if (_ctFrame) {
CFRelease(_ctFrame);
_ctFrame = NULL;
}
}
#pragma mark - Line Extraction
/**
* Extracts layout lines from the CTFrame.
*
* This method iterates through the CTFrame's lines and creates
* DTCoreTextLayoutLine wrapper objects with position information.
*
* Algorithm:
* 1. Get array of CTLines from CTFrame
* 2. For each CTLine:
* a. Get its origin from CTFrameGetLineOrigins
* b. Get the string range from CTLineGetStringRange
* c. Create DTCoreTextLayoutLine wrapper
* 3. Cache the results
*/
- (void)extractLines {
if (_linesExtracted || !_ctFrame) {
return;
}
[_lock lock];
// Get the lines from the CTFrame
CFArrayRef ctLines = CTFrameGetLines(_ctFrame);
if (!ctLines) {
_cachedLines = @[];
_linesExtracted = YES;
[_lock unlock];
return;
}
CFIndex lineCount = CFArrayGetCount(ctLines);
if (lineCount == 0) {
_cachedLines = @[];
_linesExtracted = YES;
[_lock unlock];
return;
}
// Get line origins
CGPoint *origins = (CGPoint *)malloc(sizeof(CGPoint) * lineCount);
CTFrameGetLineOrigins(_ctFrame, CFRangeMake(0, 0), origins);
NSMutableArray<DTCoreTextLayoutLine *> *lines = [NSMutableArray arrayWithCapacity:lineCount];
for (CFIndex i = 0; i < lineCount; i++) {
CTLineRef ctLine = CFArrayGetValueAtIndex(ctLines, i);
// Get the string range for this line
CFRange cfRange = CTLineGetStringRange(ctLine);
NSRange range = NSMakeRange(cfRange.location, cfRange.length);
// Create layout line wrapper
DTCoreTextLayoutLine *layoutLine = [[DTCoreTextLayoutLine alloc]
initWithCTLine:ctLine
origin:origins[i]
range:range
index:i];
[lines addObject:layoutLine];
}
free(origins);
_cachedLines = [lines copy];
_cachedLineOrigins = nil; // Invalidate origin cache
_linesExtracted = YES;
[_lock unlock];
}
/**
* Returns the array of layout lines.
* Triggers extraction if not already done.
*/
- (NSArray<DTCoreTextLayoutLine *> *)lines {
[self extractLines];
return _cachedLines;
}
- (NSUInteger)numberOfLines {
return [[self lines] count];
}
- (DTCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index {
NSArray *lines = [self lines];
if (index < [lines count]) {
return lines[index];
}
return nil;
}
/**
* Returns the line that contains the given string index.
*/
- (DTCoreTextLayoutLine *)lineContainingStringIndex:(NSUInteger)index {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
NSRange range = line.stringRange;
if (index >= range.location && index < range.location + range.length) {
return line;
}
}
return nil;
}
/**
* Returns the index of the line containing the given point.
*/
- (NSUInteger)lineIndexContainingPoint:(CGPoint)point {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (NSUInteger i = 0; i < [allLines count]; i++) {
DTCoreTextLayoutLine *line = allLines[i];
// Check if point is within the line's vertical bounds
CGFloat lineTop = line.origin.y + line.ascent;
CGFloat lineBottom = line.origin.y - line.descent;
if (point.y >= lineBottom && point.y <= lineTop) {
return i;
}
}
return NSNotFound;
}
#pragma mark - Glyph Run Extraction
/**
* Extracts glyph runs from all lines.
*
* This method iterates through all lines and extracts their glyph runs,
* creating a flat array of all glyph runs in the frame.
*/
- (void)extractGlyphRuns {
if (_glyphRunsExtracted) {
return;
}
[_lock lock];
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
NSMutableArray<DTCoreTextGlyphRun *> *allRuns = [NSMutableArray array];
for (DTCoreTextLayoutLine *line in allLines) {
NSArray<DTCoreTextGlyphRun *> *lineRuns = [line glyphRuns];
[allRuns addObjectsFromArray:lineRuns];
}
_cachedGlyphRuns = [allRuns copy];
_glyphRunsExtracted = YES;
[_lock unlock];
}
/**
* Returns all glyph runs in the frame.
*/
- (NSArray<DTCoreTextGlyphRun *> *)glyphRuns {
[self extractGlyphRuns];
return _cachedGlyphRuns;
}
#pragma mark - Drawing
/**
* Draws the layout frame content into a CGContext.
*
* This method draws the text content using CTFrameDraw.
*
* @param context The CGContext to draw into
*/
- (void)drawInContext:(CGContextRef)context {
if (!context || !_ctFrame) {
return;
}
[_lock lock];
// Save graphics state
CGContextSaveGState(context);
// CoreText uses bottom-left origin, UIKit uses top-left
// We need to flip the coordinate system
CGContextTranslateCTM(context, 0, _frame.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
// Draw the frame
CTFrameDraw(_ctFrame, context);
// Restore graphics state
CGContextRestoreGState(context);
[_lock unlock];
}
/**
* Draws the layout frame with options.
*
* @param context The CGContext to draw into
* @param options Drawing options dictionary
*/
- (void)drawInContext:(CGContextRef)context options:(NSDictionary *)options {
if (!context || !_ctFrame) {
return;
}
[_lock lock];
// Save graphics state
CGContextSaveGState(context);
// Apply coordinate transformation
CGContextTranslateCTM(context, 0, _frame.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
// Check for custom drawing options
BOOL drawImages = [options[@"drawImages"] boolValue];
BOOL drawLinks = [options[@"drawLinks"] boolValue];
BOOL drawSelection = [options[@"drawSelection"] boolValue];
// Draw the frame
CTFrameDraw(_ctFrame, context);
// Draw images if requested
if (drawImages) {
[self drawImagesInContext:context options:options];
}
// Draw links if requested
if (drawLinks) {
[self drawLinksInContext:context options:options];
}
// Draw selection if requested
if (drawSelection) {
[self drawSelectionInContext:context options:options];
}
// Restore graphics state
CGContextRestoreGState(context);
[_lock unlock];
}
/**
* Draws images in the layout frame.
*/
- (void)drawImagesInContext:(CGContextRef)context options:(NSDictionary *)options {
// Extract image attachments from attributed string
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
NSArray<DTCoreTextGlyphRun *> *runs = [line glyphRuns];
for (DTCoreTextGlyphRun *run in runs) {
NSDictionary *attributes = run.attributes;
// Check for attachment
NSTextAttachment *attachment = attributes[@"NSAttachment"];
if (attachment && attachment.image) {
CGRect runFrame = run.frame;
// Draw the image at the run's position
CGContextDrawImage(context, runFrame, attachment.image.CGImage);
}
}
}
}
/**
* Draws link indicators.
*/
- (void)drawLinksInContext:(CGContextRef)context options:(NSDictionary *)options {
// Draw underlines for links
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
NSArray<DTCoreTextGlyphRun *> *runs = [line glyphRuns];
for (DTCoreTextGlyphRun *run in runs) {
NSDictionary *attributes = run.attributes;
NSURL *linkURL = attributes[@"NSLink"];
if (linkURL) {
// Draw underline for link
CGRect runFrame = run.frame;
CGFloat underlineY = runFrame.origin.y;
CGContextSetStrokeColorWithColor(context, [UIColor blueColor].CGColor);
CGContextSetLineWidth(context, 1.0);
CGContextMoveToPoint(context, runFrame.origin.x, underlineY);
CGContextAddLineToPoint(context, CGRectGetMaxX(runFrame), underlineY);
CGContextStrokePath(context);
}
}
}
}
/**
* Draws selection highlight.
*/
- (void)drawSelectionInContext:(CGContextRef)context options:(NSDictionary *)options {
NSArray *selectedRanges = options[@"selectedRanges"];
UIColor *selectionColor = options[@"selectionColor"] ?: [UIColor colorWithRed:0.0
green:0.47
blue:1.0
alpha:0.2];
for (NSValue *rangeValue in selectedRanges) {
NSRange range = [rangeValue rangeValue];
// Get the rects for this range
NSArray<NSValue *> *rects = [self rectsForRange:range];
CGContextSetFillColorWithColor(context, selectionColor.CGColor);
for (NSValue *rectValue in rects) {
CGRect rect = [rectValue CGRectValue];
CGContextFillRect(context, rect);
}
}
}
/**
* Returns rects for a string range.
*/
- (NSArray<NSValue *> *)rectsForRange:(NSRange)range {
NSMutableArray<NSValue *> *rects = [NSMutableArray array];
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
NSRange lineRange = line.stringRange;
NSRange intersection = NSIntersectionRange(range, lineRange);
if (intersection.length > 0) {
// Get the x positions for the range within this line
CGFloat startX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location, NULL);
CGFloat endX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location + intersection.length, NULL);
CGRect lineRect = CGRectMake(
line.origin.x + startX,
line.origin.y - line.descent,
endX - startX,
line.height
);
[rects addObject:[NSValue valueWithRect:lineRect]];
}
}
return [rects copy];
}
#pragma mark - Hit Testing
/**
* Returns the string index at the given point.
*
* Uses CTLineGetStringIndexForPosition to find which character
* is at the given point.
*
* @param point The point to test
* @return The string index, or NSNotFound
*/
- (NSUInteger)stringIndexAtPoint:(CGPoint)point {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
// Check if the point is within this line's vertical bounds
CGFloat lineTop = line.origin.y + line.ascent;
CGFloat lineBottom = line.origin.y - line.descent;
if (point.y >= lineBottom && point.y <= lineTop) {
// Use CTLine to find the character index
CFIndex index = CTLineGetStringIndexForPosition(line.ctLine, point);
if (index != kCFNotFound) {
return (NSUInteger)index;
}
}
}
return NSNotFound;
}
/**
* Returns the rect for the given string index.
*/
- (CGRect)rectForStringIndex:(NSUInteger)index {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
NSRange range = line.stringRange;
if (index >= range.location && index < range.location + range.length) {
// Get the x position for this character
CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL);
return CGRectMake(line.origin.x + x,
line.origin.y - line.descent,
1,
line.height);
}
}
return CGRectNull;
}
/**
* Returns the cursor position for a given string index.
*/
- (CGRect)cursorRectForIndex:(NSUInteger)index {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
NSRange range = line.stringRange;
if (index >= range.location && index <= range.location + range.length) {
CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL);
return CGRectMake(line.origin.x + x - 1,
line.origin.y - line.descent,
2,
line.height);
}
}
return CGRectNull;
}
#pragma mark - Text Geometry
/**
* Returns the baseline origin for a given string index.
*/
- (CGPoint)baselineOriginForStringIndex:(NSUInteger)index {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
for (DTCoreTextLayoutLine *line in allLines) {
NSRange range = line.stringRange;
if (index >= range.location && index < range.location + range.length) {
CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL);
return CGPointMake(line.origin.x + x, line.origin.y);
}
}
return CGPointZero;
}
/**
* Returns the frame for a given string range.
*/
- (CGRect)frameForStringRange:(NSRange)range {
NSArray<NSValue *> *rects = [self rectsForRange:range];
if ([rects count] == 0) {
return CGRectNull;
}
// Union all rects
CGRect result = CGRectNull;
for (NSValue *rectValue in rects) {
result = CGRectUnion(result, [rectValue CGRectValue]);
}
return result;
}
/**
* Returns the string range for a given rect.
*/
- (NSRange)stringRangeForRect:(CGRect)rect {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
NSUInteger start = NSNotFound;
NSUInteger end = NSNotFound;
for (DTCoreTextLayoutLine *line in allLines) {
CGRect lineRect = CGRectMake(
line.origin.x,
line.origin.y - line.descent,
line.width,
line.height
);
if (CGRectIntersectsRect(rect, lineRect)) {
if (start == NSNotFound) {
start = line.stringRange.location;
}
end = line.stringRange.location + line.stringRange.length;
}
}
if (start != NSNotFound && end != NSNotFound) {
return NSMakeRange(start, end - start);
}
return NSMakeRange(NSNotFound, 0);
}
#pragma mark - Line Geometry
/**
* Returns the line origins.
*/
- (NSArray<NSValue *> *)lineOrigins {
if (_cachedLineOrigins) {
return _cachedLineOrigins;
}
[_lock lock];
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
NSMutableArray<NSValue *> *origins = [NSMutableArray arrayWithCapacity:[allLines count]];
for (DTCoreTextLayoutLine *line in allLines) {
[origins addObject:[NSValue valueWithCGPoint:line.origin]];
}
_cachedLineOrigins = [origins copy];
[_lock unlock];
return _cachedLineOrigins;
}
/**
* Returns the line frames.
*/
- (NSArray<NSValue *> *)lineFrames {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
NSMutableArray<NSValue *> *frames = [NSMutableArray arrayWithCapacity:[allLines count]];
for (DTCoreTextLayoutLine *line in allLines) {
CGRect lineFrame = CGRectMake(
line.origin.x,
line.origin.y - line.descent,
line.width,
line.height
);
[frames addObject:[NSValue valueWithRect:lineFrame]];
}
return [frames copy];
}
#pragma mark - Content Height
/**
* Returns the rendered content height.
*/
- (CGFloat)contentHeight {
[_lock lock];
if (_contentHeightCached) {
[_lock unlock];
return _cachedContentHeight;
}
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
if ([allLines count] == 0) {
_cachedContentHeight = 0;
_contentHeightCached = YES;
[_lock unlock];
return 0;
}
// Find the lowest point of the last line
DTCoreTextLayoutLine *lastLine = [allLines lastObject];
CGFloat lastLineBottom = lastLine.origin.y - lastLine.descent;
// Content height is from top of frame to bottom of last line
_cachedContentHeight = _frame.size.height - lastLineBottom;
_contentHeightCached = YES;
[_lock unlock];
return _cachedContentHeight;
}
- (CGFloat)maximumY {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
if ([allLines count] == 0) {
return _frame.origin.y + _frame.size.height;
}
DTCoreTextLayoutLine *firstLine = [allLines firstObject];
return firstLine.origin.y + firstLine.ascent;
}
- (CGFloat)minimumY {
NSArray<DTCoreTextLayoutLine *> *allLines = [self lines];
if ([allLines count] == 0) {
return _frame.origin.y;
}
DTCoreTextLayoutLine *lastLine = [allLines lastObject];
return lastLine.origin.y - lastLine.descent;
}
#pragma mark - Truncation
/**
* Returns whether the frame is truncated.
*/
- (BOOL)isTruncated {
// Check if the attributed string extends beyond the visible range
NSUInteger stringEnd = _range.location + _range.length;
NSUInteger visibleEnd = _visibleStringRange.location + _visibleStringRange.length;
return visibleEnd < stringEnd;
}
/**
* Returns the truncation string range.
*/
- (NSRange)truncatedStringRange {
if (![self isTruncated]) {
return NSMakeRange(NSNotFound, 0);
}
NSUInteger visibleEnd = _visibleStringRange.location + _visibleStringRange.length;
NSUInteger stringEnd = _range.location + _range.length;
return NSMakeRange(visibleEnd, stringEnd - visibleEnd);
}
#pragma mark - Layout Updates
/**
* Invalidates the layout, forcing recalculation on next access.
*/
- (void)invalidateLayout {
[_lock lock];
_linesExtracted = NO;
_glyphRunsExtracted = NO;
_contentHeightCached = NO;
_cachedLines = nil;
_cachedGlyphRuns = nil;
_cachedLineOrigins = nil;
_needsLayout = YES;
[_lock unlock];
}
/**
* Forces a layout pass.
*/
- (void)layoutIfNeeded {
if (_needsLayout) {
[self extractLines];
_needsLayout = NO;
}
}
- (NSUInteger)stringIndex {
return _range.location + _range.length;
}
@end
@@ -0,0 +1,226 @@
//
// DTCoreTextLayoutLine.h
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// DTCoreText is an open-source library for rendering HTML with CoreText.
// This class represents a single line of laid-out text.
//
#import <Foundation/Foundation.h>
#import <CoreText/CoreText.h>
@class DTCoreTextGlyphRun;
@class DTCoreTextLayoutFrame;
NS_ASSUME_NONNULL_BEGIN
/**
* DTCoreTextLayoutLine - Represents a single line of laid-out text.
*
* This class wraps CTLine and provides high-level access to the
* layout of a single line. It contains an array of DTCoreTextGlyphRun objects
* and provides methods for accessing typographic metrics and hit testing.
*
* Key features:
* - Wraps CTLine for high-level access
* - Contains array of DTCoreTextGlyphRun objects
* - Provides typographic metrics (ascent, descent, leading, width)
* - Supports hit testing within the line
* - Handles baseline offsets for superscript/subscript
*/
@interface DTCoreTextLayoutLine : NSObject
#pragma mark - Properties
/** The CTLine reference */
@property (nonatomic, assign, readonly) CTLineRef ctLine;
/** The origin point of this line in the frame */
@property (nonatomic, assign) CGPoint origin;
/** The range of characters in this line */
@property (nonatomic, assign, readonly) NSRange stringRange;
/** The index of this line in the frame */
@property (nonatomic, assign, readonly) NSUInteger lineIndex;
/** Ascent of the line */
@property (nonatomic, assign, readonly) CGFloat ascent;
/** Descent of the line */
@property (nonatomic, assign, readonly) CGFloat descent;
/** Leading of the line */
@property (nonatomic, assign, readonly) CGFloat leading;
/** Total height of the line (ascent + descent + leading) */
@property (nonatomic, assign, readonly) CGFloat height;
/** Width of the line */
@property (nonatomic, assign, readonly) CGFloat width;
/** Trailing whitespace width */
@property (nonatomic, assign, readonly) CGFloat trailingWhitespaceWidth;
/** Array of glyph runs in this line */
@property (nonatomic, strong, readonly) NSArray<DTCoreTextGlyphRun *> *glyphRuns;
/** The baseline offset (for superscript/subscript) */
@property (nonatomic, assign) CGFloat baselineOffset;
/** Whether this line is the last line in a paragraph */
@property (nonatomic, assign) BOOL isLastLineInParagraph;
/** Whether this line is a line break */
@property (nonatomic, assign) BOOL isLineBreak;
/** The paragraph style for this line */
@property (nonatomic, strong, nullable) NSDictionary *paragraphStyle;
/** Additional metrics for the line */
@property (nonatomic, strong, nullable) NSDictionary *metrics;
#pragma mark - Initialization
/**
* Initialize with a CTLine and origin.
*
* @param ctLine The CoreText line
* @param origin The origin point
* @param range The string range
* @param index The line index
* @return Initialized layout line
*/
- (instancetype)initWithCTLine:(CTLineRef)ctLine
origin:(CGPoint)origin
range:(NSRange)range
index:(NSUInteger)index;
#pragma mark - Glyph Run Access
/**
* Returns the array of glyph runs.
*
* @return Array of DTCoreTextGlyphRun objects
*/
- (NSArray<DTCoreTextGlyphRun *> *)glyphRuns;
/**
* Returns the glyph run at the specified index.
*
* @param index The glyph run index
* @return The glyph run at the index, or nil
*/
- (nullable DTCoreTextGlyphRun *)glyphRunAtIndex:(NSUInteger)index;
/**
* Returns the glyph run containing the given string index.
*
* @param index The string index
* @return The glyph run containing the index, or nil
*/
- (nullable DTCoreTextGlyphRun *)glyphRunContainingStringIndex:(NSUInteger)index;
#pragma mark - Typographic Metrics
/**
* Returns the typographic bounds of the line.
*
* @param ascent Output parameter for ascent
* @param descent Output parameter for descent
* @param leading Output parameter for leading
* @return The width of the line
*/
- (CGFloat)getTypographicBoundsAscent:(CGFloat *)ascent
descent:(CGFloat *)descent
leading:(CGFloat *)leading;
/**
* Returns the bounds of the line (bounding box).
*/
- (CGRect)bounds;
/**
* Returns the frame of the line (position + bounds).
*/
- (CGRect)frame;
#pragma mark - Hit Testing
/**
* Returns the string index at the given point within this line.
*
* @param point The point to test (in the line's coordinate system)
* @return The string index, or NSNotFound
*/
- (NSUInteger)stringIndexAtPoint:(CGPoint)point;
/**
* Returns the offset for a given string index.
*
* @param index The string index
* @return The horizontal offset
*/
- (CGFloat)offsetForStringIndex:(NSUInteger)index;
/**
* Returns the rect for a given string index.
*
* @param index The string index
* @return The rect for the character
*/
- (CGRect)rectForStringIndex:(NSUInteger)index;
#pragma mark - String Operations
/**
* Returns the substring represented by this line.
*/
- (nullable NSString *)substringFromAttributedString:(NSAttributedString *)attributedString;
/**
* Returns the attributes at a given string index.
*/
- (nullable NSDictionary *)attributesAtIndex:(NSUInteger)index
fromAttributedString:(NSAttributedString *)attributedString;
#pragma mark - Line Comparison
/**
* Compares this line to another line for ordering.
*/
- (NSComparisonResult)compareToLine:(DTCoreTextLayoutLine *)otherLine;
/**
* Returns whether this line contains the given string index.
*/
- (BOOL)containsStringIndex:(NSUInteger)index;
/**
* Returns whether this line intersects with the given range.
*/
- (BOOL)intersectsRange:(NSRange)range;
#pragma mark - Drawing
/**
* Draws this line into a CGContext.
*
* @param context The CGContext to draw into
*/
- (void)drawInContext:(CGContextRef)context;
/**
* Draws this line with a specific color.
*
* @param context The CGContext to draw into
* @param color The text color
*/
- (void)drawInContext:(CGContextRef)context withColor:(UIColor *)color;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,489 @@
//
// DTCoreTextLayoutLine.m
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// This file contains pseudo-code reconstruction of the DTCoreTextLayoutLine
// class based on ivar analysis, method signatures, and the open-source DTCoreText library.
//
#import "DTCoreTextLayoutLine.h"
#import "DTCoreTextGlyphRun.h"
#import <CoreText/CoreText.h>
#pragma mark - Private Interface
@interface DTCoreTextLayoutLine () {
// Cached glyph runs
NSArray<DTCoreTextGlyphRun *> *_cachedGlyphRuns;
// Whether glyph runs have been extracted
BOOL _glyphRunsExtracted;
// Cached typographic metrics
CGFloat _cachedAscent;
CGFloat _cachedDescent;
CGFloat _cachedLeading;
CGFloat _cachedWidth;
BOOL _metricsCached;
// Internal lock
NSLock *_lock;
}
@end
#pragma mark - DTCoreTextLayoutLine Implementation
@implementation DTCoreTextLayoutLine
#pragma mark - Lifecycle
- (instancetype)initWithCTLine:(CTLineRef)ctLine
origin:(CGPoint)origin
range:(NSRange)range
index:(NSUInteger)index {
self = [super init];
if (self) {
_lock = [[NSLock alloc] init];
// Store the CTLine with a retain
if (ctLine) {
_ctLine = (CTLineRef)CFRetain(ctLine);
}
_origin = origin;
_stringRange = range;
_lineIndex = index;
// Initialize state
_glyphRunsExtracted = NO;
_metricsCached = NO;
_baselineOffset = 0;
_isLastLineInParagraph = NO;
_isLineBreak = NO;
}
return self;
}
- (void)dealloc {
if (_ctLine) {
CFRelease(_ctLine);
_ctLine = NULL;
}
}
#pragma mark - Glyph Run Extraction
/**
* Extracts glyph runs from the CTLine.
*
* This method iterates through the CTLine's runs and creates
* DTCoreTextGlyphRun wrapper objects.
*
* Algorithm:
* 1. Get array of CTRuns from CTLine
* 2. For each CTRun:
* a. Get its string range
* b. Get glyph positions and advances
* c. Create DTCoreTextGlyphRun wrapper
* 3. Cache the results
*/
- (void)extractGlyphRuns {
if (_glyphRunsExtracted || !_ctLine) {
return;
}
[_lock lock];
// Get the runs from the CTLine
CFArrayRef ctRuns = CTLineGetGlyphRuns(_ctLine);
if (!ctRuns) {
_cachedGlyphRuns = @[];
_glyphRunsExtracted = YES;
[_lock unlock];
return;
}
CFIndex runCount = CFArrayGetCount(ctRuns);
if (runCount == 0) {
_cachedGlyphRuns = @[];
_glyphRunsExtracted = YES;
[_lock unlock];
return;
}
NSMutableArray<DTCoreTextGlyphRun *> *runs = [NSMutableArray arrayWithCapacity:runCount];
// Calculate the baseline x offset for this line
CGFloat lineXOffset = _origin.x;
for (CFIndex i = 0; i < runCount; i++) {
CTRunRef ctRun = CFArrayGetValueAtIndex(ctRuns, i);
// Get the string range for this run
CFRange cfRange = CTRunGetStringRange(ctRun);
NSRange range = NSMakeRange(cfRange.location, cfRange.length);
// Get the run's typographic bounds
CGFloat ascent, descent, leading;
double width = CTRunGetTypographicBounds(ctRun, CFRangeMake(0, 0), &ascent, &descent, &leading);
// Get the positions of the glyphs
CFIndex glyphCount = CTRunGetGlyphCount(ctRun);
CGPoint *positions = (CGPoint *)malloc(sizeof(CGPoint) * glyphCount);
CTRunGetPositions(ctRun, CFRangeMake(0, 0), positions);
// Get the attributes
NSDictionary *attributes = (__bridge NSDictionary *)CTRunGetAttributes(ctRun);
// Calculate the run's frame
CGRect runFrame;
if (glyphCount > 0) {
CGFloat minX = positions[0].x;
CGFloat maxX = positions[glyphCount - 1].x + width / glyphCount; // Approximate
// More accurate: use the actual glyph advances
CGGlyph *glyphs = (CGGlyph *)malloc(sizeof(CGGlyph) * glyphCount);
CTRunGetGlyphs(ctRun, CFRangeMake(0, 0), glyphs);
// Calculate actual bounds using the glyph advances
CGFloat *advances = (CGFloat *)malloc(sizeof(CGFloat) * glyphCount);
CTFontRef font = (__bridge CTFontRef)attributes[(__bridge NSString *)kCTFontAttributeName];
if (font) {
CTFontGetAdvancesForGlyphs(font, kCTFontOrientationHorizontal, glyphs, advances, glyphCount);
}
// Recalculate max X using actual advances
maxX = minX;
for (CFIndex j = 0; j < glyphCount; j++) {
maxX += advances[j];
}
free(glyphs);
free(advances);
runFrame = CGRectMake(
lineXOffset + minX,
_origin.y - descent,
maxX - minX,
ascent + descent
);
} else {
runFrame = CGRectZero;
}
free(positions);
// Create glyph run wrapper
DTCoreTextGlyphRun *glyphRun = [[DTCoreTextGlyphRun alloc]
initWithCTRun:ctRun
frame:runFrame
range:range
attributes:attributes
index:i];
[runs addObject:glyphRun];
}
_cachedGlyphRuns = [runs copy];
_glyphRunsExtracted = YES;
[_lock unlock];
}
/**
* Returns the array of glyph runs.
*/
- (NSArray<DTCoreTextGlyphRun *> *)glyphRuns {
[self extractGlyphRuns];
return _cachedGlyphRuns;
}
- (DTCoreTextGlyphRun *)glyphRunAtIndex:(NSUInteger)index {
NSArray *runs = [self glyphRuns];
if (index < [runs count]) {
return runs[index];
}
return nil;
}
/**
* Returns the glyph run containing the given string index.
*/
- (DTCoreTextGlyphRun *)glyphRunContainingStringIndex:(NSUInteger)index {
NSArray<DTCoreTextGlyphRun *> *allRuns = [self glyphRuns];
for (DTCoreTextGlyphRun *run in allRuns) {
NSRange range = run.stringRange;
if (index >= range.location && index < range.location + range.length) {
return run;
}
}
return nil;
}
#pragma mark - Typographic Metrics
/**
* Calculates and caches typographic metrics.
*/
- (void)calculateMetrics {
if (_metricsCached || !_ctLine) {
return;
}
[_lock lock];
// Get typographic bounds from CTLine
_cachedWidth = CTLineGetTypographicBounds(_ctLine, &_cachedAscent, &_cachedDescent, &_cachedLeading);
// Get trailing whitespace width
_trailingWhitespaceWidth = CTLineGetTrailingWhitespaceWidth(_ctLine);
_metricsCached = YES;
[_lock unlock];
}
- (CGFloat)ascent {
[self calculateMetrics];
return _cachedAscent;
}
- (CGFloat)descent {
[self calculateMetrics];
return _cachedDescent;
}
- (CGFloat)leading {
[self calculateMetrics];
return _cachedLeading;
}
- (CGFloat)height {
return [self ascent] + [self descent] + [self leading];
}
- (CGFloat)width {
[self calculateMetrics];
return _cachedWidth;
}
/**
* Returns the typographic bounds of the line.
*/
- (CGFloat)getTypographicBoundsAscent:(CGFloat *)ascent
descent:(CGFloat *)descent
leading:(CGFloat *)leading {
[self calculateMetrics];
if (ascent) *ascent = _cachedAscent;
if (descent) *descent = _cachedDescent;
if (leading) *leading = _cachedLeading;
return _cachedWidth;
}
/**
* Returns the bounds of the line (bounding box).
*/
- (CGRect)bounds {
[self calculateMetrics];
return CGRectMake(0, -_cachedDescent, _cachedWidth, [self height]);
}
/**
* Returns the frame of the line (position + bounds).
*/
- (CGRect)frame {
return CGRectMake(_origin.x, _origin.y - _cachedDescent, _cachedWidth, [self height]);
}
#pragma mark - Hit Testing
/**
* Returns the string index at the given point within this line.
*
* Uses CTLineGetStringIndexForPosition to find which character
* is at the given point.
*
* @param point The point to test (in the line's coordinate system)
* @return The string index, or NSNotFound
*/
- (NSUInteger)stringIndexAtPoint:(CGPoint)point {
if (!_ctLine) {
return NSNotFound;
}
// Convert point to line-relative coordinates
CGPoint linePoint = CGPointMake(point.x - _origin.x, point.y - _origin.y);
// Use CTLine to find the character index
CFIndex index = CTLineGetStringIndexForPosition(_ctLine, linePoint);
if (index == kCFNotFound) {
return NSNotFound;
}
return (NSUInteger)index;
}
/**
* Returns the offset for a given string index.
*
* @param index The string index
* @return The horizontal offset from the line's origin
*/
- (CGFloat)offsetForStringIndex:(NSUInteger)index {
if (!_ctLine) {
return 0;
}
// CTLineGetOffsetForStringIndex returns the offset from the line's origin
CGFloat offset = CTLineGetOffsetForStringIndex(_ctLine, index, NULL);
return offset;
}
/**
* Returns the rect for a given string index.
*/
- (CGRect)rectForStringIndex:(NSUInteger)index {
CGFloat xOffset = [self offsetForStringIndex:index];
return CGRectMake(
_origin.x + xOffset,
_origin.y - _cachedDescent,
1, // Width of 1 character
[self height]
);
}
#pragma mark - String Operations
/**
* Returns the substring represented by this line.
*/
- (NSString *)substringFromAttributedString:(NSAttributedString *)attributedString {
if (!attributedString || _stringRange.location >= [attributedString length]) {
return nil;
}
// Clamp the range to the string length
NSUInteger maxLength = [attributedString length] - _stringRange.location;
NSUInteger length = MIN(_stringRange.length, maxLength);
return [[attributedString string] substringWithRange:NSMakeRange(_stringRange.location, length)];
}
/**
* Returns the attributes at a given string index.
*/
- (NSDictionary *)attributesAtIndex:(NSUInteger)index
fromAttributedString:(NSAttributedString *)attributedString {
if (!attributedString || index >= [attributedString length]) {
return nil;
}
return [attributedString attributesAtIndex:index effectiveRange:NULL];
}
#pragma mark - Line Comparison
/**
* Compares this line to another line for ordering.
*/
- (NSComparisonResult)compareToLine:(DTCoreTextLayoutLine *)otherLine {
// Compare by line index first
if (_lineIndex < otherLine.lineIndex) {
return NSOrderedAscending;
} else if (_lineIndex > otherLine.lineIndex) {
return NSOrderedDescending;
}
// If same index, compare by string range location
if (_stringRange.location < otherLine.stringRange.location) {
return NSOrderedAscending;
} else if (_stringRange.location > otherLine.stringRange.location) {
return NSOrderedDescending;
}
return NSOrderedSame;
}
/**
* Returns whether this line contains the given string index.
*/
- (BOOL)containsStringIndex:(NSUInteger)index {
return index >= _stringRange.location &&
index < _stringRange.location + _stringRange.length;
}
/**
* Returns whether this line intersects with the given range.
*/
- (BOOL)intersectsRange:(NSRange)range {
NSRange intersection = NSIntersectionRange(_stringRange, range);
return intersection.length > 0;
}
#pragma mark - Drawing
/**
* Draws this line into a CGContext.
*
* @param context The CGContext to draw into
*/
- (void)drawInContext:(CGContextRef)context {
if (!context || !_ctLine) {
return;
}
// Save graphics state
CGContextSaveGState(context);
// Move to the line's origin
CGContextSetTextPosition(context, _origin.x, _origin.y);
// Draw the line
CTLineDraw(_ctLine, context);
// Restore graphics state
CGContextRestoreGState(context);
}
/**
* Draws this line with a specific color.
*
* @param context The CGContext to draw into
* @param color The text color
*/
- (void)drawInContext:(CGContextRef)context withColor:(UIColor *)color {
if (!context || !_ctLine || !color) {
return;
}
// Save graphics state
CGContextSaveGState(context);
// Set the text color
CGContextSetFillColorWithColor(context, color.CGColor);
// Move to the line's origin
CGContextSetTextPosition(context, _origin.x, _origin.y);
// Draw the line
CTLineDraw(_ctLine, context);
// Restore graphics state
CGContextRestoreGState(context);
}
@end
+125
View File
@@ -0,0 +1,125 @@
//
// DTCoreTextLayouter.h
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// DTCoreText is an open-source library for rendering HTML with CoreText.
// WeRead has customized it for their reading engine.
//
#import <Foundation/Foundation.h>
#import <CoreText/CoreText.h>
@class DTCoreTextLayoutFrame;
@class NSAttributedString;
NS_ASSUME_NONNULL_BEGIN
/**
* DTCoreTextLayouter - CoreText typesetter wrapper.
*
* This is the original DTCoreText layouter class, customized by WeRead.
* It wraps CTTypesetter and CTFramesetter to provide high-level
* text layout functionality.
*
* Key features:
* - Creates CTTypesetter from attributed string
* - Manages typesetter lifecycle
* - Produces DTCoreTextLayoutFrame objects
* - Supports caching of layout frames
* - Handles string updates efficiently
*/
@interface DTCoreTextLayouter : NSObject
#pragma mark - Properties
/** The attributed string to be laid out */
@property (nonatomic, strong, nullable) NSAttributedString *attributedString;
/** Cache for layout frames */
@property (nonatomic, strong, readonly) NSCache *layoutFrameCache;
/** Array of created layout frames */
@property (nonatomic, strong, readonly) NSMutableArray<DTCoreTextLayoutFrame *> *layoutFrames;
#pragma mark - Initialization
/**
* Initialize with an attributed string.
*
* @param attributedString The text with styling attributes
* @return Initialized layouter instance
*/
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString;
#pragma mark - Typesetter Management
/**
* Returns the internal CTTypesetter.
* Creates one if it doesn't exist.
*
* @return The CTTypesetter reference
*/
- (CTTypesetterRef)typesetter;
/**
* Invalidates the current typesetter.
* Called when the attributed string changes.
*/
- (void)invalidateTypesetter;
#pragma mark - Layout Frame Creation
/**
* Create a layout frame for a given string range within the specified rect.
*
* @param range The range of the attributed string to lay out
* @param frame The bounding rectangle for the layout
* @return A new DTCoreTextLayoutFrame
*/
- (DTCoreTextLayoutFrame *)layoutFrameWithRange:(NSRange)range
frame:(CGRect)frame;
/**
* Suggest a line break for the given range and width.
*
* @param startIndex The starting index
* @param width The available width
* @return The suggested line break index
*/
- (NSUInteger)suggestLineBreakStartIndex:(NSUInteger)startIndex
width:(CGFloat)width;
/**
* Suggest a fitting string length for pagination.
*
* @param startIndex The starting index
* @param constraints Size constraints
* @return The suggested fitting length
*/
- (NSUInteger)stringIndexFittingLengthForWidth:(CGFloat)width
startIndex:(NSUInteger)startIndex;
#pragma mark - Frame Caching
/**
* Returns a cached layout frame for the given key.
*/
- (nullable DTCoreTextLayoutFrame *)cachedLayoutFrameForKey:(NSString *)key;
/**
* Caches a layout frame with the given key.
*/
- (void)cacheLayoutFrame:(DTCoreTextLayoutFrame *)frame
forKey:(NSString *)key;
/**
* Clears the layout frame cache.
*/
- (void)clearLayoutFrameCache;
@end
NS_ASSUME_NONNULL_END
+280
View File
@@ -0,0 +1,280 @@
//
// DTCoreTextLayouter.m
// WeRead
//
// Reverse-engineered from binary analysis
// Based on DTCoreText open-source library, customized by WeRead
//
// This file contains pseudo-code reconstruction of the DTCoreTextLayouter
// class based on ivar analysis, method signatures, and the open-source DTCoreText library.
//
#import "DTCoreTextLayouter.h"
#import "DTCoreTextLayoutFrame.h"
#import <CoreText/CoreText.h>
#pragma mark - Private Interface
@interface DTCoreTextLayouter () {
// CoreText typesetter - the core text processing engine
CTTypesetterRef _typesetter;
// Track whether the typesetter is valid
BOOL _typesetterValid;
// Internal lock for thread safety
NSLock *_lock;
}
@end
#pragma mark - DTCoreTextLayouter Implementation
@implementation DTCoreTextLayouter
#pragma mark - Lifecycle
- (instancetype)init {
self = [super init];
if (self) {
_layoutFrameCache = [[NSCache alloc] init];
_layoutFrameCache.countLimit = 20; // Cache up to 20 layout frames
_layoutFrames = [NSMutableArray array];
_typesetterValid = NO;
_lock = [[NSLock alloc] init];
}
return self;
}
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString {
self = [self init];
if (self) {
_attributedString = attributedString;
}
return self;
}
- (void)dealloc {
if (_typesetter) {
CFRelease(_typesetter);
_typesetter = NULL;
}
}
#pragma mark - Property Accessors
- (void)setAttributedString:(NSAttributedString *)attributedString {
[_lock lock];
_attributedString = attributedString;
// Invalidate the typesetter when the string changes
_typesetterValid = NO;
// Clear the layout frames array
[_layoutFrames removeAllObjects];
[_lock unlock];
}
#pragma mark - Typesetter Management
/**
* Returns the internal CTTypesetter, creating it if necessary.
*
* CTTypesetter is the low-level CoreText object that performs glyph layout.
* It analyzes the attributed string and prepares it for line-by-line layout.
*
* @return The CTTypesetter reference
*/
- (CTTypesetterRef)typesetter {
[_lock lock];
if (!_typesetterValid || !_typesetter) {
// Release old typesetter if any
if (_typesetter) {
CFRelease(_typesetter);
_typesetter = NULL;
}
if (_attributedString) {
// Create new typesetter from the attributed string
_typesetter = CTTypesetterCreateWithAttributedString(
(__bridge CFAttributedStringRef)_attributedString
);
_typesetterValid = YES;
}
}
CTTypesetterRef result = _typesetter;
[_lock unlock];
return result;
}
/**
* Invalidates the current typesetter.
* Forces recreation on next access.
*/
- (void)invalidateTypesetter {
[_lock lock];
_typesetterValid = NO;
[_lock unlock];
}
#pragma mark - Layout Frame Creation
/**
* Creates a DTCoreTextLayoutFrame for a given string range.
*
* This method creates a layout frame by:
* 1. Getting the typesetter
* 2. Creating a CTFrame for the range within the given rect
* 3. Wrapping it in a DTCoreTextLayoutFrame
*
* @param range Range of the attributed string to lay out
* @param frame Bounding rectangle for the layout
* @return A new DTCoreTextLayoutFrame
*/
- (DTCoreTextLayoutFrame *)layoutFrameWithRange:(NSRange)range
frame:(CGRect)frame {
CTTypesetterRef typesetter = [self typesetter];
if (!typesetter || range.length == 0) {
return nil;
}
[_lock lock];
// Create a CGPath for the frame bounds
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, frame);
// Create CTFrame from the typesetter
CTFrameRef ctFrame = CTTypesetterCreateFrame(
typesetter,
CFRangeMake(range.location, range.length),
path,
NULL
);
CGPathRelease(path);
if (!ctFrame) {
[_lock unlock];
return nil;
}
// Create layout frame wrapper
DTCoreTextLayoutFrame *layoutFrame = [[DTCoreTextLayoutFrame alloc]
initWithAttributedString:_attributedString
range:range
ctFrame:ctFrame];
CFRelease(ctFrame);
[_layoutFrames addObject:layoutFrame];
[_lock unlock];
return layoutFrame;
}
/**
* Suggests a line break for the given start index and width.
*
* Uses CTTypesetterSuggestLineBreak to determine how many characters
* fit within the given width.
*
* @param startIndex The starting index in the string
* @param width The available width
* @return The suggested line break index
*/
- (NSUInteger)suggestLineBreakStartIndex:(NSUInteger)startIndex
width:(CGFloat)width {
CTTypesetterRef typesetter = [self typesetter];
if (!typesetter) {
return 0;
}
// CTTypesetterSuggestLineBreak returns the number of characters
// that fit in the given width starting from startIndex
CFIndex breakIndex = CTTypesetterSuggestLineBreak(
typesetter,
startIndex,
width
);
return (NSUInteger)breakIndex;
}
/**
* Suggests a fitting string length for pagination.
*
* This method simulates pagination to determine how much text
* fits within the given constraints.
*
* @param width The available width
* @param startIndex The starting index
* @return The suggested fitting length
*/
- (NSUInteger)stringIndexFittingLengthForWidth:(CGFloat)width
startIndex:(NSUInteger)startIndex {
CTTypesetterRef typesetter = [self typesetter];
if (!_attributedString || !typesetter) {
return 0;
}
NSUInteger totalLength = [_attributedString length];
NSUInteger currentIndex = startIndex;
NSUInteger totalFitted = 0;
// Simulate line-by-line layout to find total fitting length
while (currentIndex < totalLength) {
CFIndex lineBreak = CTTypesetterSuggestLineBreak(
typesetter,
currentIndex,
width
);
if (lineBreak <= 0) {
break;
}
totalFitted += lineBreak;
currentIndex += lineBreak;
}
return totalFitted;
}
#pragma mark - Frame Caching
/**
* Returns a cached layout frame for the given key.
*/
- (DTCoreTextLayoutFrame *)cachedLayoutFrameForKey:(NSString *)key {
return [_layoutFrameCache objectForKey:key];
}
/**
* Caches a layout frame with the given key.
*/
- (void)cacheLayoutFrame:(DTCoreTextLayoutFrame *)frame
forKey:(NSString *)key {
if (frame && key) {
[_layoutFrameCache setObject:frame forKey:key];
}
}
/**
* Clears the layout frame cache.
*/
- (void)clearLayoutFrameCache {
[_layoutFrameCache removeAllObjects];
}
@end
@@ -0,0 +1,106 @@
//
// DTHTMLAttributedStringBuilder.h
// DTCoreText (WeRead custom fork)
//
// Reverse-engineered from WeChat Reading (微信读书) binary.
// This class converts HTML DOM into NSAttributedString via SAX-style parsing.
//
#import <Foundation/Foundation.h>
@class DTHTMLParserDelegate;
@class DTHTMLElement;
@class DTCSSStylesheet;
@class DTCoreTextFontDescriptor;
@class DTCoreTextParagraphStyle;
@class WRBook;
@class WRChapter;
// ---------------------------------------------------------------------------
// WeRead custom NSAttributedString attribute keys
// ---------------------------------------------------------------------------
// These keys are used in the resulting attributed string to carry page layout
// and presentation metadata that goes beyond standard DTCoreText attributes.
extern NSString *const DTPageBackgroundColorAttribute;
extern NSString *const DTPageBackgroundImageAttribute;
extern NSString *const DTPageBackgroundImagePathAttribute;
extern NSString *const DTPageBreakAfterAttribute;
extern NSString *const DTPageBreakBeforeAttribute;
extern NSString *const DTPageBreakInsideAvoidAttribute;
extern NSString *const DTPageRelateAttribute;
extern NSString *const DTPageSize;
extern NSString *const DTPageFlippingStyle;
extern NSString *const DTHTMLVerticalCenterAttribute;
extern NSString *const DTHTMLTranslateTagAttribute;
extern NSString *const DTHTMLTranslateNoStyleAttribute;
// ---------------------------------------------------------------------------
// DTHTMLAttributedStringBuilder
// ---------------------------------------------------------------------------
@interface DTHTMLAttributedStringBuilder : NSObject
// --- Initializers ---
/**
Designated initializer.
@param htmlData Raw HTML data (UTF-8 encoded).
@param options Dictionary of build options (base URL, CSS stylesheet, etc.).
*/
- (instancetype)initWithHTML:(NSData *)htmlData
options:(NSDictionary *)options;
/**
Convenience initializer that also supplies a CSS stylesheet.
*/
- (instancetype)initWithHTML:(NSData *)htmlData
cssStyleSheet:(DTCSSStylesheet *)styleSheet
options:(NSDictionary *)options;
// --- Building ---
/**
Triggers the full HTML parse → DOM tree → NSAttributedString pipeline.
Must be called before -generatedAttributedString.
*/
- (void)buildString;
/**
Returns the attributed string produced by the most recent -buildString call.
*/
- (NSAttributedString *)generatedAttributedString;
// --- DTHTMLParser delegate (SAX callbacks) ---
- (void)parser:(id)parser
didStartElement:(NSString *)elementName
attributes:(NSDictionary *)attributeDict
position:(NSUInteger)position;
- (void)parser:(id)parser
foundCDATA:(NSData *)CDATABlock;
- (void)parser:(id)parser
foundCharacters:(NSString *)string
position:(NSUInteger)position;
- (void)parserDidEndDocument:(id)parser;
// --- Tag handler registration (internal) ---
- (void)_registerTagStartHandlers;
- (void)_registerTagEndHandlers;
// --- Properties ---
@property (nonatomic, strong, readonly) NSData *htmlData;
@property (nonatomic, strong, readonly) DTCSSStylesheet *cssStyleSheet;
@property (nonatomic, strong, readonly) NSDictionary *options;
@property (nonatomic, strong, readonly) NSAttributedString *generatedAttributedString;
// WeRead-specific: the book / chapter context used during rendering.
@property (nonatomic, strong) WRBook *book;
@property (nonatomic, strong) WRChapter *chapter;
@end
@@ -0,0 +1,643 @@
//
// DTHTMLAttributedStringBuilder.m
// DTCoreText (WeRead custom fork)
//
// Reverse-engineered pseudo-implementation from WeChat Reading binary.
// This file reconstructs the full HTML → NSAttributedString pipeline.
//
// Pipeline overview:
// 1. Parse raw HTML via DTHTMLParser (SAX-style).
// 2. Each SAX event (startElement / endElement / foundCharacters / CDATA)
// builds or mutates DTHTMLElement nodes in a DOM tree.
// 3. After parsing completes, -_buildString walks the DOM tree and calls
// -[DTHTMLElement attributedString] recursively.
// 4. The resulting NSAttributedString is stored in _generatedAttributedString.
//
#import "DTHTMLAttributedStringBuilder.h"
#import "DTHTMLParserDelegate.h"
#import "DTHTMLElement.h"
#import "DTCSSStylesheet.h"
#import "DTCoreTextFontDescriptor.h"
#import "DTCoreTextParagraphStyle.h"
#import "DTHTMLParser.h"
#import "WRBook.h"
#import "WRChapter.h"
// ---------------------------------------------------------------------------
// WeRead custom attribute key definitions
// ---------------------------------------------------------------------------
NSString *const DTPageBackgroundColorAttribute = @"DTPageBackgroundColorAttribute";
NSString *const DTPageBackgroundImageAttribute = @"DTPageBackgroundImageAttribute";
NSString *const DTPageBackgroundImagePathAttribute = @"DTPageBackgroundImagePathAttribute";
NSString *const DTPageBreakAfterAttribute = @"DTPageBreakAfterAttribute";
NSString *const DTPageBreakBeforeAttribute = @"DTPageBreakBeforeAttribute";
NSString *const DTPageBreakInsideAvoidAttribute = @"DTPageBreakInsideAvoidAttribute";
NSString *const DTPageRelateAttribute = @"DTPageRelateAttribute";
NSString *const DTPageSize = @"DTPageSize";
NSString *const DTPageFlippingStyle = @"DTPageFlippingStyle";
NSString *const DTHTMLVerticalCenterAttribute = @"DTHTMLVerticalCenterAttribute";
NSString *const DTHTMLTranslateTagAttribute = @"DTHTMLTranslateTagAttribute";
NSString *const DTHTMLTranslateNoStyleAttribute = @"DTHTMLTranslateNoStyleAttribute";
// ---------------------------------------------------------------------------
#pragma mark - Private interface
// ---------------------------------------------------------------------------
@interface DTHTMLAttributedStringBuilder ()
{
// ---- ivar: the SAX parser delegate that accumulates the DOM tree ----
DTHTMLParserDelegate *_parserDelegate;
// ---- Cached input ----
NSData *_htmlData;
DTCSSStylesheet *_cssStyleSheet;
NSDictionary *_options;
// ---- Result ----
NSAttributedString *_generatedAttributedString;
// ---- WeRead book/chapter context ----
WRBook *_book;
WRChapter *_chapter;
}
@end
// ---------------------------------------------------------------------------
#pragma mark - DTHTMLParserDelegate (internal helper)
// ---------------------------------------------------------------------------
// In the actual binary this is a separate class whose ivars include all the
// mutable state needed during parsing. We define it here to show the fields
// that DTHTMLAttributedStringBuilder delegates to.
@interface DTHTMLParserDelegate : NSObject
{
// Current tag handlers (block-based dispatch tables keyed on tag name)
NSData *_tagStartHandlers; // actually a block map
NSDictionary *_tagEndHandlers;
// CSS stylesheet applied during parsing
DTCSSStylesheet *_cssStyleSheet;
// Base URL for resolving relative links / images
NSURL *_baseURL;
// Default font / paragraph descriptors used when no CSS overrides exist
DTCoreTextFontDescriptor *_defaultFontDescriptor;
DTCoreTextParagraphStyle *_defaultParagraphStyle;
// The root of the DOM tree being built
DTHTMLElement *_rootElement;
// Stack of open elements (for nesting / parent resolution)
NSMutableDictionary *_elementStack; // index → DTHTMLElement
// Accumulated text for the current text run
NSMutableDictionary *_currentTextBuffer;
// "Current" element pointers (set during SAX traversal)
DTHTMLElement *_currentElement;
DTHTMLElement *_parentElement;
DTHTMLElement *_lastInlineElement;
DTHTMLElement *_lastBlockElement;
// WeRead-specific rendering context
WRBook *_book;
WRChapter *_chapter;
// Final output accumulator
NSMutableAttributedString *_outputString;
// Image / view references (for lazy image loading)
UIImageView *_currentImageView;
UIView *_imageContainerView;
UIView *_currentView;
NSString *_currentImageSrc;
UIView *_parentView;
UIView *_rootView;
// Collected tag list for post-processing
NSArray *_tagOrder;
}
// Methods used during SAX parsing
- (void)_registerTagStartHandlers;
- (void)_registerTagEndHandlers;
@end
@implementation DTHTMLParserDelegate
// --------------------------------------------------
# pragma mark Handler registration
// --------------------------------------------------
/**
Registers block handlers for HTML start tags.
Each handler receives the element name, attributes dict, and position,
then creates or configures the appropriate DTHTMLElement subclass.
WeRead registers custom handlers for their proprietary CSS attributes:
- wr-vertical-center-style → DTHTMLVerticalCenterAttribute
- weread-page-relate → DTPageRelateAttribute
- avoidPageBreakInside → DTPageBreakInsideAvoidAttribute
- DTPageBreakAfter / DTPageBreakBefore
- DTPageBackgroundColor / DTPageBackgroundImage
*/
- (void)_registerTagStartHandlers
{
// Pseudo-code: build a dictionary mapping tag names to handler blocks.
//
// _tagStartHandlers = @{
// @"p" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
// @"div" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
// @"img" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
// @"br" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
// @"a" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
// @"h1" : ^(NSString *tag, NSDictionary *attrs, NSUInteger pos) { ... },
// ...
// // WeRead custom tags / attributes handled here
// };
//
// Each handler typically:
// 1. Creates a new DTHTMLElement.
// 2. Sets its tagName, attributes, parent.
// 3. Calls -[DTHTMLElement applyStyleDictionary:isLatinLanguageBook:].
// 4. Pushes the element onto _elementStack.
// 5. Updates _currentElement.
}
/**
Registers block handlers for HTML end tags.
Responsible for:
- Popping the element from the stack.
- Finalizing inline text runs.
- Applying paragraph-level styles.
- Handling DTPageBreakAfter / Before attributes.
*/
- (void)_registerTagEndHandlers
{
// Pseudo-code: analogous to start handlers but for closing tags.
//
// _tagEndHandlers = @{
// @"p" : ^(NSString *tag) { ... },
// @"div" : ^(NSString *tag) { ... },
// ...
// };
//
// Each handler typically:
// 1. Pops the current element from the stack.
// 2. Calls -[DTHTMLElement interpretAttributes] to finalize.
// 3. If element has DTPageBreakAfter, inserts page break marker.
// 4. Sets _currentElement back to the parent.
}
@end
// ---------------------------------------------------------------------------
#pragma mark - DTHTMLAttributedStringBuilder implementation
// ---------------------------------------------------------------------------
@implementation DTHTMLAttributedStringBuilder
// --------------------------------------------------
# pragma mark Initialization
// --------------------------------------------------
- (instancetype)initWithHTML:(NSData *)htmlData
options:(NSDictionary *)options
{
self = [super init];
if (self) {
_htmlData = [htmlData copy];
_options = [options copy] ?: @{};
// Create the parser delegate and configure it.
_parserDelegate = [[DTHTMLParserDelegate alloc] init];
// If a CSS stylesheet is provided in options, install it.
DTCSSStylesheet *sheet = _options[@"DTDefaultCSSStyleSheet"];
if (sheet) {
_cssStyleSheet = sheet;
_parserDelegate->_cssStyleSheet = sheet;
}
// Base URL for resolving <img src>, <a href>, etc.
NSURL *baseURL = _options[@"DTBaseURL"];
if (baseURL) {
_parserDelegate->_baseURL = baseURL;
}
// Default font descriptor (system font fallback).
DTCoreTextFontDescriptor *fontDesc = _options[@"DTDefaultFontDescriptor"];
if (fontDesc) {
_parserDelegate->_defaultFontDescriptor = fontDesc;
}
// Default paragraph style.
DTCoreTextParagraphStyle *paraStyle = _options[@"DTDefaultParagraphStyle"];
if (paraStyle) {
_parserDelegate->_defaultParagraphStyle = paraStyle;
}
// Register the tag handler dispatch tables.
[_parserDelegate _registerTagStartHandlers];
[_parserDelegate _registerTagEndHandlers];
}
return self;
}
- (instancetype)initWithHTML:(NSData *)htmlData
cssStyleSheet:(DTCSSStylesheet *)styleSheet
options:(NSDictionary *)options
{
// Merge stylesheet into options so the designated init picks it up.
NSMutableDictionary *merged = [options mutableCopy] ?: [NSMutableDictionary dictionary];
if (styleSheet) {
merged[@"DTDefaultCSSStyleSheet"] = styleSheet;
}
return [self initWithHTML:htmlData options:merged];
}
// --------------------------------------------------
# pragma mark Build pipeline
// --------------------------------------------------
/**
Main entry point. Kicks off the SAX parser; when parsing completes the
delegate has built a DOM tree of DTHTMLElement nodes. Then -_buildString
converts that tree into the final NSAttributedString.
*/
- (void)buildString
{
// Step 1: Create a SAX parser and point it at our delegate.
DTHTMLParser *parser = [[DTHTMLParser alloc] initWithData:_htmlData];
parser.delegate = _parserDelegate;
// Step 2: Parse. This triggers the delegate callbacks below.
[parser parse];
// Step 3: Convert the DOM tree to an attributed string.
[self _buildString];
}
/**
Walks the DOM tree rooted at _parserDelegate->_rootElement and recursively
calls -[DTHTMLElement attributedString] to produce the final output.
*/
- (void)_buildString
{
DTHTMLElement *root = _parserDelegate->_rootElement;
if (!root) {
_generatedAttributedString = [[NSAttributedString alloc] initWithString:@""];
return;
}
// Recursively convert the DOM tree.
// DTHTMLElement's -attributedString walks children and concatenates.
NSAttributedString *result = [root attributedString];
// If the result is nil (empty document), produce an empty string.
if (!result) {
result = [[NSAttributedString alloc] initWithString:@""];
}
_generatedAttributedString = result;
// Store into the delegate's output for external access if needed.
_parserDelegate->_outputString = [result mutableCopy];
}
// --------------------------------------------------
# pragma mark DTHTMLParser delegate callbacks
// --------------------------------------------------
/**
Called by the SAX parser when an opening HTML tag is encountered.
@param parser The DTHTMLParser instance.
@param elementName Tag name (e.g. "p", "div", "img").
@param attributeDict Parsed attributes from the HTML tag.
@param position Character offset in the original HTML data.
*/
- (void)parser:(id)parser
didStartElement:(NSString *)elementName
attributes:(NSDictionary *)attributeDict
position:(NSUInteger)position
{
// Look up the registered handler block for this tag.
// If found, invoke it. Otherwise, fall through to default handling.
DTHTMLElement *newElement = [[DTHTMLElement alloc] initWithTagName:elementName
attributes:attributeDict];
// Set the parent to the current element on the stack.
DTHTMLElement *parent = _parserDelegate->_currentElement;
newElement.parent = parent;
[parent.children addObject:newElement];
// Push onto the element stack.
_parserDelegate->_currentElement = newElement;
// Apply inline style attribute (style="...") and any CSS rules
// matching this element.
NSDictionary *styleDict = [self _resolveStyleForElement:newElement
attributes:attributeDict];
if (styleDict) {
// Determine if this is a Latin-language book (affects font fallback).
BOOL isLatin = [_parserDelegate->_book isLatinLanguageBook];
[newElement applyStyleDictionary:styleDict isLatinLanguageBook:isLatin];
}
// Handle WeRead-specific custom CSS attributes.
[self _applyWeReadCustomAttributes:newElement fromAttributes:attributeDict];
// Handle special tags.
if ([elementName caseInsensitiveCompare:@"img"] == NSOrderedSame) {
[self _handleImageElement:newElement attributes:attributeDict];
}
else if ([elementName caseInsensitiveCompare:@"br"] == NSOrderedSame) {
[self _handleBRElement:newElement];
}
else if ([elementName caseInsensitiveCompare:@"a"] == NSOrderedSame) {
[self _handleAnchorElement:newElement attributes:attributeDict];
}
// Track last block vs. inline element for layout decisions.
if ([newElement isBlockElement]) {
_parserDelegate->_lastBlockElement = newElement;
} else {
_parserDelegate->_lastInlineElement = newElement;
}
}
/**
Called when character data is found between tags.
@param parser The DTHTMLParser instance.
@param string The character data.
@param position Character offset in the original HTML.
*/
- (void)parser:(id)parser
foundCharacters:(NSString *)string
position:(NSUInteger)position
{
if (!string || string.length == 0) {
return;
}
// Append to the current text buffer.
// The delegate accumulates text until a closing tag flushes it.
DTHTMLElement *current = _parserDelegate->_currentElement;
if (current) {
[current appendText:string];
}
}
/**
Called when a CDATA section is encountered (e.g. inside <script> or <style>).
WeRead uses CDATA in some book content.
@param parser The DTHTMLParser instance.
@param CDATABlock Raw CDATA bytes.
*/
- (void)parser:(id)parser
foundCDATA:(NSData *)CDATABlock
{
// CDATA is typically treated as raw text content.
NSString *text = [[NSString alloc] initWithData:CDATABlock
encoding:NSUTF8StringEncoding];
if (text) {
DTHTMLElement *current = _parserDelegate->_currentElement;
if (current) {
[current appendText:text];
}
}
}
/**
Called when the parser finishes parsing the entire document.
*/
- (void)parserDidEndDocument:(id)parser
{
// All elements have been opened and closed.
// The DOM tree is complete in _parserDelegate->_rootElement.
// Post-processing can happen here if needed.
}
// --------------------------------------------------
# pragma mark Style resolution
// --------------------------------------------------
/**
Resolves the effective style dictionary for an element by merging:
1. CSS stylesheet rules matching this element.
2. Inline style="" attribute.
3. Element-specific default styles.
*/
- (NSDictionary *)_resolveStyleForElement:(DTHTMLElement *)element
attributes:(NSDictionary *)attrs
{
NSMutableDictionary *resolved = [NSMutableDictionary dictionary];
// 1. Apply CSS stylesheet rules (class, id, tag selectors).
if (_cssStyleSheet) {
NSDictionary *cssRules = [_cssStyleSheet stylesForElement:element];
if (cssRules) {
[resolved addEntriesFromDictionary:cssRules];
}
}
// 2. Parse inline style attribute.
NSString *inlineStyle = attrs[@"style"];
if (inlineStyle) {
NSDictionary *inlineDict = [self _parseInlineStyle:inlineStyle];
if (inlineDict) {
[resolved addEntriesFromDictionary:inlineDict];
}
}
return resolved.count > 0 ? resolved : nil;
}
/**
Parses a CSS inline style string (e.g. "color:red;font-size:14px")
into a dictionary.
*/
- (NSDictionary *)_parseInlineStyle:(NSString *)styleString
{
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
NSArray *declarations = [styleString componentsSeparatedByString:@";"];
for (NSString *decl in declarations) {
NSArray *parts = [decl componentsSeparatedByString:@":"];
if (parts.count == 2) {
NSString *key = [parts[0] stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
NSString *val = [parts[1] stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
if (key.length > 0 && val.length > 0) {
dict[key] = val;
}
}
}
return dict;
}
// --------------------------------------------------
# pragma mark WeRead custom attribute handling
// --------------------------------------------------
/**
Applies WeRead-specific CSS attributes to the element.
These control page layout features unique to WeRead's reading engine.
*/
- (void)_applyWeReadCustomAttributes:(DTHTMLElement *)element
fromAttributes:(NSDictionary *)attrs
{
// wr-vertical-center-style: vertically center content within page
NSString *vCenter = attrs[@"wr-vertical-center-style"];
if (vCenter) {
element.verticalCenterStyle = vCenter;
}
// weread-page-relate: marks content as page-related
NSString *pageRelate = attrs[@"weread-page-relate"];
if (pageRelate) {
element.pageRelate = pageRelate;
}
// avoidPageBreakInside: prevent page breaks within this element
NSString *avoidBreak = attrs[@"avoidPageBreakInside"];
if ([avoidBreak boolValue] || [avoidBreak isEqualToString:@"true"]) {
element.shouldAvoidPageBreakInside = YES;
}
// DTPageBreakAfter: force page break after this element
NSString *breakAfter = attrs[@"DTPageBreakAfter"];
if (breakAfter) {
element.pageBreakAfter = YES;
}
// DTPageBreakBefore: force page break before this element
NSString *breakBefore = attrs[@"DTPageBreakBefore"];
if (breakBefore) {
element.pageBreakBefore = YES;
}
// DTPageBackgroundColor: per-page background color
NSString *bgColor = attrs[@"DTPageBackgroundColor"];
if (bgColor) {
element.pageBackgroundColor = [self _colorFromCSSValue:bgColor];
}
// DTPageBackgroundImage: per-page background image
NSString *bgImage = attrs[@"DTPageBackgroundImage"];
if (bgImage) {
element.pageBackgroundImage = bgImage;
}
}
/**
Converts a CSS color value string to UIColor.
*/
- (UIColor *)_colorFromCSSValue:(NSString *)cssValue
{
// Simplified: real implementation handles hex (#RRGGBB), rgb(), named colors.
if ([cssValue hasPrefix:@"#"]) {
NSString *hex = [cssValue substringFromIndex:1];
unsigned int rgb = 0;
[[NSScanner scannerWithString:hex] scanHexInt:&rgb];
return [UIColor colorWithRed:((rgb >> 16) & 0xFF) / 255.0
green:((rgb >> 8) & 0xFF) / 255.0
blue:((rgb >> 0) & 0xFF) / 255.0
alpha:1.0];
}
return nil;
}
// --------------------------------------------------
# pragma mark Special element handlers
// --------------------------------------------------
- (void)_handleImageElement:(DTHTMLElement *)element
attributes:(NSDictionary *)attrs
{
// Resolve image source relative to base URL.
NSString *src = attrs[@"src"];
if (!src) return;
NSURL *baseURL = _parserDelegate->_baseURL;
NSURL *imageURL = [NSURL URLWithString:src relativeToURL:baseURL];
element.imageURL = imageURL;
// Store for lazy loading pipeline.
_parserDelegate->_currentImageSrc = src;
}
- (void)_handleBRElement:(DTHTMLElement *)element
{
// <br> inserts a newline / line break.
element.tagName = @"br";
element.isLineBreak = YES;
}
- (void)_handleAnchorElement:(DTHTMLElement *)element
attributes:(NSDictionary *)attrs
{
// <a href="..."> creates a hyperlink.
NSString *href = attrs[@"href"];
if (href) {
NSURL *baseURL = _parserDelegate->_baseURL;
element.linkURL = [NSURL URLWithString:href relativeToURL:baseURL];
}
}
// --------------------------------------------------
# pragma mark Properties
// --------------------------------------------------
- (NSData *)htmlData
{
return _htmlData;
}
- (DTCSSStylesheet *)cssStyleSheet
{
return _cssStyleSheet;
}
- (NSDictionary *)options
{
return _options;
}
- (NSAttributedString *)generatedAttributedString
{
return _generatedAttributedString;
}
- (WRBook *)book
{
return _parserDelegate->_book;
}
- (void)setBook:(WRBook *)book
{
_parserDelegate->_book = book;
_book = book;
}
- (WRChapter *)chapter
{
return _parserDelegate->_chapter;
}
- (void)setChapter:(WRChapter *)chapter
{
_parserDelegate->_chapter = chapter;
_chapter = chapter;
}
@end
+135
View File
@@ -0,0 +1,135 @@
//
// DTHTMLElement.h
// DTCoreText (WeRead custom fork)
//
// Reverse-engineered from WeChat Reading (微信读书) binary.
// Represents a single HTML element in the DOM tree built during parsing.
// Each node can produce an NSAttributedString via -attributedString.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
@class DTTextAttachment;
@class DTBorderStyle;
@class DTBackgroundImageStyle;
@class DTTableStyle;
@class DTCSSStylesheet;
@class DTCoreTextFontDescriptor;
@class DTCoreTextParagraphStyle;
// ---------------------------------------------------------------------------
// DTHTMLElement
// ---------------------------------------------------------------------------
@interface DTHTMLElement : NSObject
// --- Initialization ---
- (instancetype)initWithTagName:(NSString *)tagName
attributes:(NSDictionary *)attributes;
// --- Tree structure ---
@property (nonatomic, weak) DTHTMLElement *parent;
@property (nonatomic, strong) NSMutableArray *children;
// --- Tag identity ---
@property (nonatomic, copy) NSString *tagName;
@property (nonatomic, copy) NSString *elementId;
@property (nonatomic, strong) NSArray *classNames;
// --- Attributes & style ---
@property (nonatomic, strong) NSDictionary *attributes;
@property (nonatomic, strong) NSDictionary *styleDictionary;
// --- Text content ---
@property (nonatomic, copy) NSString *text;
@property (nonatomic, strong) NSArray *textRuns;
// --- Display properties ---
@property (nonatomic, assign) BOOL isLineBreak;
@property (nonatomic, assign) BOOL isBlockElement;
@property (nonatomic, assign) BOOL shouldAvoidPageBreakInside;
@property (nonatomic, assign) BOOL pageBreakAfter;
@property (nonatomic, assign) BOOL pageBreakBefore;
// --- Font & paragraph ---
@property (nonatomic, strong) DTCoreTextFontDescriptor *fontDescriptor;
@property (nonatomic, strong) DTCoreTextParagraphStyle *paragraphStyle;
// --- Colors ---
@property (nonatomic, strong) UIColor *textColor;
@property (nonatomic, strong) UIColor *backgroundColor;
// --- Links ---
@property (nonatomic, strong) NSURL *linkURL;
// --- Images & attachments ---
@property (nonatomic, strong) NSURL *imageURL;
@property (nonatomic, strong) DTTextAttachment *textAttachment;
// --- WeRead-specific page layout properties ---
@property (nonatomic, copy) NSString *verticalCenterStyle;
@property (nonatomic, copy) NSString *pageRelate;
@property (nonatomic, strong) UIColor *pageBackgroundColor;
@property (nonatomic, copy) NSString *pageBackgroundImage;
// --- Border & background ---
@property (nonatomic, strong) DTBorderStyle *borderStyle;
@property (nonatomic, strong) DTBackgroundImageStyle *backgroundImageStyle;
@property (nonatomic, strong) DTTableStyle *tableStyle;
// --- Additional string fields observed in ivars ---
@property (nonatomic, copy) NSString *cssClass;
@property (nonatomic, copy) NSString *cssId;
@property (nonatomic, copy) NSString *lang;
@property (nonatomic, copy) NSString *direction; // ltr / rtl
@property (nonatomic, copy) NSString *whiteSpace;
@property (nonatomic, copy) NSString *textAlign;
// --- Public methods ---
/**
Applies a CSS style dictionary to this element, resolving font, color,
paragraph style, etc.
@param styleDict The CSS properties to apply.
@param isLatin Whether the content language is Latin-script.
*/
- (void)applyStyleDictionary:(NSDictionary *)styleDict
isLatinLanguageBook:(BOOL)isLatin;
/**
Recursively converts this element and its children into an NSAttributedString.
@return The attributed string representing this subtree.
*/
- (NSAttributedString *)attributedString;
/**
Finalizes attributes after all children have been parsed.
Called when the closing tag is encountered.
*/
- (void)interpretAttributes;
/**
Appends text content to this element (called during SAX foundCharacters:).
*/
- (void)appendText:(NSString *)text;
/**
Returns YES if this element is a void / self-closing element (img, br, hr, etc.)
*/
- (BOOL)isVoidElement;
@end
+917
View File
@@ -0,0 +1,917 @@
//
// DTHTMLElement.m
// DTCoreText (WeRead custom fork)
//
// Reverse-engineered pseudo-implementation from WeChat Reading binary.
// Each DTHTMLElement node represents an HTML tag; it holds resolved style
// properties and can produce an NSAttributedString via -attributedString.
//
// Key responsibilities:
// 1. Store the tag name, attributes, and parent/child tree pointers.
// 2. Apply CSS style dictionaries (from stylesheet or inline style).
// 3. Resolve font descriptors and paragraph styles.
// 4. Convert itself + children into NSAttributedString (recursive).
// 5. Handle WeRead-specific page layout attributes.
//
#import "DTHTMLElement.h"
#import "DTTextAttachment.h"
#import "DTBorderStyle.h"
#import "DTBackgroundImageStyle.h"
#import "DTTableStyle.h"
#import "DTCoreTextFontDescriptor.h"
#import "DTCoreTextParagraphStyle.h"
#import "DTCSSStylesheet.h"
// DTCoreText standard attribute keys (defined elsewhere in DTCoreText)
// extern NSString *const DTTextListsAttribute;
// extern NSString *const DTStrikeOutAttribute;
// extern NSString *const DTUnderlineStyleAttribute;
// extern NSString *const DTLinkAttribute;
// ...
// Void elements: tags that have no closing tag and no children.
static NSSet *_voidElements = nil;
@implementation DTHTMLElement
{
// Mutable text accumulator used during parsing.
NSMutableString *_textBuffer;
}
// --------------------------------------------------
# pragma mark Class initialization
// --------------------------------------------------
+ (void)initialize
{
if (self == [DTHTMLElement class]) {
_voidElements = [NSSet setWithArray:@[
@"area", @"base", @"br", @"col", @"embed", @"hr",
@"img", @"input", @"link", @"meta", @"param",
@"source", @"track", @"wbr"
]];
}
}
// --------------------------------------------------
# pragma mark Initialization
// --------------------------------------------------
- (instancetype)initWithTagName:(NSString *)tagName
attributes:(NSDictionary *)attributes
{
self = [super init];
if (self) {
_tagName = [tagName lowercaseString];
_attributes = [attributes copy] ?: @{};
_children = [NSMutableArray array];
// Parse id and class from attributes.
_elementId = attributes[@"id"];
NSString *classStr = attributes[@"class"];
if (classStr) {
_classNames = [classStr componentsSeparatedByCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
}
// Default display mode: block for most tags, inline for span/em/etc.
_isBlockElement = [self _isDefaultBlockElement:_tagName];
}
return self;
}
- (instancetype)init
{
return [self initWithTagName:nil attributes:nil];
}
// --------------------------------------------------
# pragma mark Default block/inline classification
// --------------------------------------------------
/**
Returns YES for tags that are block-level by default in HTML.
*/
- (BOOL)_isDefaultBlockElement:(NSString *)tag
{
static NSSet *blockTags = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
blockTags = [NSSet setWithArray:@[
@"div", @"p", @"h1", @"h2", @"h3", @"h4", @"h5", @"h6",
@"blockquote", @"ul", @"ol", @"li", @"table", @"tr", @"td",
@"th", @"thead", @"tbody", @"tfoot", @"section", @"article",
@"header", @"footer", @"nav", @"main", @"aside", @"figure",
@"figcaption", @"address", @"pre", @"hr", @"form", @"fieldset",
@"dl", @"dt", @"dd"
]];
});
return [blockTags containsObject:tag];
}
// --------------------------------------------------
# pragma mark Text content management
// --------------------------------------------------
/**
Appends character data to this element's text buffer.
Called by the builder during SAX foundCharacters: events.
*/
- (void)appendText:(NSString *)text
{
if (!text || text.length == 0) return;
if (!_textBuffer) {
_textBuffer = [NSMutableString stringWithString:text];
} else {
[_textBuffer appendString:text];
}
_text = _textBuffer;
}
// --------------------------------------------------
# pragma mark Style application
// --------------------------------------------------
/**
Applies a CSS style dictionary to this element.
This is the core style resolution method. It translates CSS property names
into DTCoreText property objects (font descriptors, paragraph styles, colors).
@param styleDict Dictionary of CSS property → value.
@param isLatin YES if the book language is Latin-script (affects font
fallback and line-height calculations).
*/
- (void)applyStyleDictionary:(NSDictionary *)styleDict
isLatinLanguageBook:(BOOL)isLatin
{
if (!styleDict || styleDict.count == 0) return;
_styleDictionary = styleDict;
// ---- Font properties ----
[self _applyFontProperties:styleDict isLatin:isLatin];
// ---- Text properties ----
[self _applyTextProperties:styleDict];
// ---- Color properties ----
[self _applyColorProperties:styleDict];
// ---- Display / layout properties ----
[self _applyDisplayProperties:styleDict];
// ---- Margin / padding (for paragraph style) ----
[self _applyBoxModelProperties:styleDict];
// ---- WeRead-specific properties ----
[self _applyWeReadProperties:styleDict];
// ---- Border properties ----
[self _applyBorderProperties:styleDict];
// ---- Background properties ----
[self _applyBackgroundProperties:styleDict];
}
// --------------------------------------------------
# pragma mark Font property resolution
// --------------------------------------------------
- (void)_applyFontProperties:(NSDictionary *)dict isLatin:(BOOL)isLatin
{
// font-family
NSString *fontFamily = dict[@"font-family"];
if (fontFamily) {
// Strip quotes, handle generic families (serif, sans-serif, monospace).
fontFamily = [fontFamily stringByTrimmingCharactersInSet:
[NSCharacterSet characterSetWithCharactersInString:@" '\""]];
if (!_fontDescriptor) {
_fontDescriptor = [[DTCoreTextFontDescriptor alloc] init];
}
_fontDescriptor.fontFamily = fontFamily;
}
// font-size
NSString *fontSizeStr = dict[@"font-size"];
if (fontSizeStr) {
CGFloat size = [self _floatFromCSSValue:fontSizeStr];
if (size > 0) {
if (!_fontDescriptor) {
_fontDescriptor = [[DTCoreTextFontDescriptor alloc] init];
}
_fontDescriptor.pointSize = size;
}
}
// font-weight
NSString *fontWeight = dict[@"font-weight"];
if (fontWeight) {
if (!_fontDescriptor) {
_fontDescriptor = [[DTCoreTextFontDescriptor alloc] init];
}
if ([fontWeight isEqualToString:@"bold"] ||
[fontWeight integerValue] >= 700) {
_fontDescriptor.boldTrait = YES;
}
}
// font-style (italic / normal)
NSString *fontStyle = dict[@"font-style"];
if (fontStyle) {
if (!_fontDescriptor) {
_fontDescriptor = [[DTCoreTextFontDescriptor alloc] init];
}
if ([fontStyle isEqualToString:@"italic"] ||
[fontStyle isEqualToString:@"oblique"]) {
_fontDescriptor.italicTrait = YES;
}
}
}
// --------------------------------------------------
# pragma mark Text property resolution
// --------------------------------------------------
- (void)_applyTextProperties:(NSDictionary *)dict
{
// text-decoration
NSString *decoration = dict[@"text-decoration"];
if (decoration) {
// underline, line-through, none
// stored for later attributed string construction
}
// text-align
NSString *align = dict[@"text-align"];
if (align) {
_textAlign = align;
if (!_paragraphStyle) {
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
}
if ([align isEqualToString:@"center"]) {
_paragraphStyle.textAlignment = kCTCenterTextAlignment;
} else if ([align isEqualToString:@"right"]) {
_paragraphStyle.textAlignment = kCTRightTextAlignment;
} else if ([align isEqualToString:@"justify"]) {
_paragraphStyle.textAlignment = kCTJustifiedTextAlignment;
} else {
_paragraphStyle.textAlignment = kCTLeftTextAlignment;
}
}
// text-indent
NSString *indent = dict[@"text-indent"];
if (indent) {
if (!_paragraphStyle) {
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
}
_paragraphStyle.firstLineHeadIndent = [self _floatFromCSSValue:indent];
}
// line-height
NSString *lineHeight = dict[@"line-height"];
if (lineHeight) {
if (!_paragraphStyle) {
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
}
_paragraphStyle.lineHeightMultiple = [self _floatFromCSSValue:lineHeight];
}
// letter-spacing
NSString *letterSpacing = dict[@"letter-spacing"];
if (letterSpacing) {
// Applied as kern in the attributed string.
}
// white-space
NSString *ws = dict[@"white-space"];
if (ws) {
_whiteSpace = ws;
}
}
// --------------------------------------------------
# pragma mark Color property resolution
// --------------------------------------------------
- (void)_applyColorProperties:(NSDictionary *)dict
{
// color
NSString *colorStr = dict[@"color"];
if (colorStr) {
_textColor = [self _colorFromCSSValue:colorStr];
}
// background-color
NSString *bgColorStr = dict[@"background-color"];
if (bgColorStr) {
_backgroundColor = [self _colorFromCSSValue:bgColorStr];
}
}
// --------------------------------------------------
# pragma mark Display / layout properties
// --------------------------------------------------
- (void)_applyDisplayProperties:(NSDictionary *)dict
{
NSString *display = dict[@"display"];
if (display) {
if ([display isEqualToString:@"block"] ||
[display isEqualToString:@"flex"] ||
[display isEqualToString:@"grid"]) {
_isBlockElement = YES;
} else if ([display isEqualToString:@"inline"] ||
[display isEqualToString:@"inline-block"]) {
_isBlockElement = NO;
} else if ([display isEqualToString:@"none"]) {
// Element should be hidden; mark for skipping.
}
}
// vertical-align
NSString *vAlign = dict[@"vertical-align"];
if (vAlign) {
// sub, super, top, middle, bottom, etc.
}
}
// --------------------------------------------------
# pragma mark Box model (margin / padding)
// --------------------------------------------------
- (void)_applyBoxModelProperties:(NSDictionary *)dict
{
if (!_paragraphStyle) {
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
}
// margin-top
NSString *marginTop = dict[@"margin-top"];
if (marginTop) {
_paragraphStyle.paragraphSpacingBefore = [self _floatFromCSSValue:marginTop];
}
// margin-bottom
NSString *marginBottom = dict[@"margin-bottom"];
if (marginBottom) {
_paragraphStyle.paragraphSpacing = [self _floatFromCSSValue:marginBottom];
}
// padding-left
NSString *paddingLeft = dict[@"padding-left"];
if (paddingLeft) {
_paragraphStyle.headIndent = [self _floatFromCSSValue:paddingLeft];
}
}
// --------------------------------------------------
# pragma mark WeRead-specific CSS properties
// --------------------------------------------------
/**
Handles WeRead's proprietary CSS attributes that control page-level layout.
These are used by WeRead's paging engine (not standard web rendering).
*/
- (void)_applyWeReadProperties:(NSDictionary *)dict
{
// wr-vertical-center-style: vertically center content within a page.
NSString *vCenter = dict[@"wr-vertical-center-style"];
if (vCenter) {
_verticalCenterStyle = vCenter;
}
// weread-page-relate: marks element as related to page-level layout.
NSString *pageRelate = dict[@"weread-page-relate"];
if (pageRelate) {
_pageRelate = pageRelate;
}
// avoidPageBreakInside: prevent page breaks within this element.
NSString *avoidBreak = dict[@"avoidPageBreakInside"];
if (avoidBreak) {
_shouldAvoidPageBreakInside = YES;
}
// DTPageBreakAfter: force a page break after this element.
NSString *breakAfter = dict[@"DTPageBreakAfter"];
if (breakAfter && ([breakAfter boolValue] ||
[breakAfter isEqualToString:@"always"])) {
_pageBreakAfter = YES;
}
// DTPageBreakBefore: force a page break before this element.
NSString *breakBefore = dict[@"DTPageBreakBefore"];
if (breakBefore && ([breakBefore boolValue] ||
[breakBefore isEqualToString:@"always"])) {
_pageBreakBefore = YES;
}
// DTPageBackgroundColor: per-page background color (for styled pages).
NSString *bgColor = dict[@"DTPageBackgroundColor"];
if (bgColor) {
_pageBackgroundColor = [self _colorFromCSSValue:bgColor];
}
// DTPageBackgroundImage: per-page background image URL/path.
NSString *bgImage = dict[@"DTPageBackgroundImage"];
if (bgImage) {
_pageBackgroundImage = bgImage;
}
}
// --------------------------------------------------
# pragma mark Border properties
// --------------------------------------------------
- (void)_applyBorderProperties:(NSDictionary *)dict
{
// border-width, border-style, border-color
NSString *borderWidth = dict[@"border-width"];
NSString *borderStyle = dict[@"border-style"];
NSString *borderColor = dict[@"border-color"];
if (borderWidth || borderStyle || borderColor) {
if (!_borderStyle) {
_borderStyle = [[DTBorderStyle alloc] init];
}
if (borderWidth) {
_borderStyle.borderWidth = [self _floatFromCSSValue:borderWidth];
}
if (borderColor) {
_borderStyle.borderColor = [self _colorFromCSSValue:borderColor];
}
}
}
// --------------------------------------------------
# pragma mark Background properties
// --------------------------------------------------
- (void)_applyBackgroundProperties:(NSDictionary *)dict
{
NSString *bgImage = dict[@"background-image"];
if (bgImage && [bgImage hasPrefix:@"url("]) {
// Extract URL from url('...')
NSRange start = [bgImage rangeOfString:@"'"];
NSRange end = [bgImage rangeOfString:@"'" options:NSBackwardsSearch];
if (start.location != NSNotFound && end.location != NSNotFound) {
NSString *urlStr = [bgImage substringWithRange:
NSMakeRange(start.location + 1,
end.location - start.location - 1)];
if (!_backgroundImageStyle) {
_backgroundImageStyle = [[DTBackgroundImageStyle alloc] init];
}
_backgroundImageStyle.imageURL = [NSURL URLWithString:urlStr];
}
}
}
// --------------------------------------------------
# pragma mark Interpret attributes (finalization)
// --------------------------------------------------
/**
Called after the closing tag is encountered.
Finalizes computed properties that depend on children.
*/
- (void)interpretAttributes
{
// For block elements, ensure a paragraph style exists.
if (_isBlockElement && !_paragraphStyle) {
_paragraphStyle = [[DTCoreTextParagraphStyle alloc] init];
}
// Resolve font descriptor from tag name if not set by CSS.
if (!_fontDescriptor) {
_fontDescriptor = [self _defaultFontDescriptorForTag:_tagName];
}
// Process children (recursive interpret).
for (DTHTMLElement *child in _children) {
[child interpretAttributes];
}
}
/**
Returns a default font descriptor based on the HTML tag name.
*/
- (DTCoreTextFontDescriptor *)_defaultFontDescriptorForTag:(NSString *)tag
{
DTCoreTextFontDescriptor *desc = [[DTCoreTextFontDescriptor alloc] init];
if ([tag isEqualToString:@"b"] || [tag isEqualToString:@"strong"]) {
desc.boldTrait = YES;
}
else if ([tag isEqualToString:@"i"] || [tag isEqualToString:@"em"]) {
desc.italicTrait = YES;
}
else if ([tag isEqualToString:@"big"]) {
desc.pointSize = 18.0;
}
else if ([tag isEqualToString:@"small"]) {
desc.pointSize = 10.0;
}
else if ([tag isEqualToString:@"h1"]) {
desc.pointSize = 24.0;
desc.boldTrait = YES;
}
else if ([tag isEqualToString:@"h2"]) {
desc.pointSize = 20.0;
desc.boldTrait = YES;
}
else if ([tag isEqualToString:@"h3"]) {
desc.pointSize = 16.0;
desc.boldTrait = YES;
}
else if ([tag isEqualToString:@"h4"]) {
desc.pointSize = 14.0;
desc.boldTrait = YES;
}
else if ([tag isEqualToString:@"code"] ||
[tag isEqualToString:@"tt"] ||
[tag isEqualToString:@"pre"]) {
desc.monospaceFamily = YES;
}
return desc;
}
// --------------------------------------------------
# pragma mark Attributed string generation
// --------------------------------------------------
/**
Recursively converts this element and all its children into an
NSAttributedString. This is the core rendering method.
Algorithm:
1. Create a mutable attributed string from this element's text content.
2. Apply font, color, paragraph style, and link attributes.
3. For each child, call -attributedString recursively and append.
4. Handle special elements (img, br, table, etc.).
5. Return the assembled string.
*/
- (NSAttributedString *)attributedString
{
NSMutableAttributedString *output = [[NSMutableAttributedString alloc] init];
// Step 1: Handle void elements first.
if ([self isVoidElement]) {
return [self _attributedStringForVoidElement];
}
// Step 2: Emit page break before marker if needed.
if (_pageBreakBefore) {
NSDictionary *breakAttrs = @{
DTPageBreakBeforeAttribute: @YES
};
NSAttributedString *breakStr = [[NSAttributedString alloc]
initWithString:@"" // LINE SEPARATOR as page break marker
attributes:breakAttrs];
[output appendAttributedString:breakStr];
}
// Step 3: Process text content.
if (_text && _text.length > 0) {
NSAttributedString *textStr = [self _attributedStringForText:_text];
[output appendAttributedString:textStr];
}
// Step 4: Process children recursively.
for (DTHTMLElement *child in _children) {
NSAttributedString *childStr = [child attributedString];
if (childStr) {
[output appendAttributedString:childStr];
}
}
// Step 5: Wrap in paragraph style if this is a block element.
if (_isBlockElement && output.length > 0) {
[self _applyParagraphStyleToString:output];
}
// Step 6: Emit page break after marker if needed.
if (_pageBreakAfter) {
NSDictionary *breakAttrs = @{
DTPageBreakAfterAttribute: @YES
};
NSAttributedString *breakStr = [[NSAttributedString alloc]
initWithString:@""
attributes:breakAttrs];
[output appendAttributedString:breakStr];
}
// Step 7: Apply WeRead page-level attributes.
[self _applyWeReadPageAttributesToString:output];
return output;
}
// --------------------------------------------------
# pragma mark Text → NSAttributedString
// --------------------------------------------------
/**
Creates an NSAttributedString from text with the element's resolved styles.
*/
- (NSAttributedString *)_attributedStringForText:(NSString *)text
{
NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
// Font
CTFontRef font = [_fontDescriptor newMatchingFont];
if (font) {
attrs[(id)kCTFontAttributeName] = (__bridge id)font;
CFRelease(font);
}
// Text color
if (_textColor) {
attrs[(id)kCTForegroundColorAttributeName] = (__bridge id)_textColor.CGColor;
}
// Background color (highlight)
if (_backgroundColor) {
attrs[@"DTBackgroundColor"] = _backgroundColor;
}
// Link
if (_linkURL) {
attrs[@"DTLink"] = _linkURL;
}
// Kern (letter-spacing)
// if (_letterSpacing) { attrs[(id)kCTKernAttributeName] = ...; }
return [[NSAttributedString alloc] initWithString:text attributes:attrs];
}
// --------------------------------------------------
# pragma mark Void element handling
// --------------------------------------------------
- (BOOL)isVoidElement
{
return [_voidElements containsObject:_tagName];
}
/**
Produces the attributed string for void elements (br, img, hr, etc.)
*/
- (NSAttributedString *)_attributedStringForVoidElement
{
if ([_tagName isEqualToString:@"br"]) {
// Line break: insert newline with current paragraph style.
NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
if (_paragraphStyle) {
attrs[@"DTParagraphStyle"] = _paragraphStyle;
}
return [[NSAttributedString alloc] initWithString:@"\n"
attributes:attrs];
}
else if ([_tagName isEqualToString:@"img"]) {
// Image: create a text attachment and wrap in attributed string.
return [self _attributedStringForImage];
}
else if ([_tagName isEqualToString:@"hr"]) {
// Horizontal rule: treated as a paragraph separator.
return [[NSAttributedString alloc] initWithString:@"\n"];
}
return [[NSAttributedString alloc] initWithString:@""];
}
/**
Creates an NSAttributedString containing an image attachment.
*/
- (NSAttributedString *)_attributedStringForImage
{
if (!_textAttachment) {
_textAttachment = [[DTTextAttachment alloc] init];
_textAttachment.contentURL = _imageURL;
}
// The attachment is represented by the Unicode object replacement character.
unichar objectChar = 0xFFFC;
NSString *objectStr = [NSString stringWithCharacters:&objectChar length:1];
NSMutableDictionary *attrs = [NSMutableDictionary dictionary];
attrs[@"DTTextAttachment"] = _textAttachment;
if (_linkURL) {
attrs[@"DTLink"] = _linkURL;
}
return [[NSAttributedString alloc] initWithString:objectStr
attributes:attrs];
}
// --------------------------------------------------
# pragma mark Paragraph style application
// --------------------------------------------------
/**
Applies the element's paragraph style to the entire range of the string.
*/
- (void)_applyParagraphStyleToString:(NSMutableAttributedString *)str
{
if (!_paragraphStyle || str.length == 0) return;
CTParagraphStyleRef ctStyle = [_paragraphStyle createCTParagraphStyle];
if (ctStyle) {
[str addAttribute:(id)kCTParagraphStyleAttributeName
value:(__bridge id)ctStyle
range:NSMakeRange(0, str.length)];
CFRelease(ctStyle);
}
}
// --------------------------------------------------
# pragma mark WeRead page attribute application
// --------------------------------------------------
/**
Applies WeRead-specific page layout attributes to the string.
These attributes are consumed by WeRead's paging engine to control
page breaks, backgrounds, and vertical centering.
*/
- (void)_applyWeReadPageAttributesToString:(NSMutableAttributedString *)str
{
if (str.length == 0) return;
NSRange fullRange = NSMakeRange(0, str.length);
// Vertical center style
if (_verticalCenterStyle) {
[str addAttribute:DTHTMLVerticalCenterAttribute
value:_verticalCenterStyle
range:fullRange];
}
// Page relate
if (_pageRelate) {
[str addAttribute:DTPageRelateAttribute
value:_pageRelate
range:fullRange];
}
// Avoid page break inside
if (_shouldAvoidPageBreakInside) {
[str addAttribute:DTPageBreakInsideAvoidAttribute
value:@YES
range:fullRange];
}
// Page background color
if (_pageBackgroundColor) {
[str addAttribute:DTPageBackgroundColorAttribute
value:_pageBackgroundColor
range:fullRange];
}
// Page background image
if (_pageBackgroundImage) {
[str addAttribute:DTPageBackgroundImageAttribute
value:_pageBackgroundImage
range:fullRange];
}
}
// --------------------------------------------------
# pragma mark CSS value parsing helpers
// --------------------------------------------------
/**
Converts a CSS length value string (e.g. "14px", "1.2em", "100%")
into a CGFloat in points.
*/
- (CGFloat)_floatFromCSSValue:(NSString *)value
{
if (!value || value.length == 0) return 0.0;
// Strip whitespace.
value = [value stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
// Handle special values.
if ([value isEqualToString:@"inherit"] ||
[value isEqualToString:@"auto"]) {
return 0.0;
}
// Extract numeric part.
NSString *numericPart = value;
NSString *unit = @"";
// Check for known units.
NSArray *units = @[@"px", @"em", @"rem", @"pt", @"%", @"ex"];
for (NSString *u in units) {
if ([value hasSuffix:u]) {
numericPart = [value substringToIndex:value.length - u.length];
unit = u;
break;
}
}
CGFloat floatValue = [numericPart floatValue];
// Convert to points (simplified; real implementation handles em/% relative
// to parent).
if ([unit isEqualToString:@"em"] || [unit isEqualToString:@"rem"]) {
// Assume 1em = parent font size (default 16px).
floatValue *= 16.0;
} else if ([unit isEqualToString:@"pt"]) {
// 1pt = 1pt (no conversion needed on iOS).
} else if ([unit isEqualToString:@"%"]) {
// Percentage: caller must interpret relative to container.
}
// "px" → points (1:1 on non-retina, but iOS uses points natively).
return floatValue;
}
/**
Converts a CSS color value string to UIColor.
Supports:
- #RRGGBB hex notation
- #RGB shorthand
- rgb(r,g,b) functional notation
- Named colors (red, blue, etc.)
*/
- (UIColor *)_colorFromCSSValue:(NSString *)cssValue
{
if (!cssValue || cssValue.length == 0) return nil;
cssValue = [cssValue stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
// Hex colors
if ([cssValue hasPrefix:@"#"]) {
NSString *hex = [cssValue substringFromIndex:1];
// Expand shorthand #RGB → #RRGGBB
if (hex.length == 3) {
hex = [NSString stringWithFormat:@"%C%C%C%C%C%C",
[hex characterAtIndex:0], [hex characterAtIndex:0],
[hex characterAtIndex:1], [hex characterAtIndex:1],
[hex characterAtIndex:2], [hex characterAtIndex:2]];
}
if (hex.length == 6) {
unsigned int rgb = 0;
[[NSScanner scannerWithString:hex] scanHexInt:&rgb];
return [UIColor colorWithRed:((rgb >> 16) & 0xFF) / 255.0
green:((rgb >> 8) & 0xFF) / 255.0
blue:((rgb >> 0) & 0xFF) / 255.0
alpha:1.0];
}
}
// rgb(r,g,b) notation
if ([cssValue hasPrefix:@"rgb("] || [cssValue hasPrefix:@"rgba("]) {
NSString *inner = cssValue;
inner = [inner stringByReplacingOccurrencesOfString:@"rgb(" withString:@""];
inner = [inner stringByReplacingOccurrencesOfString:@"rgba(" withString:@""];
inner = [inner stringByReplacingOccurrencesOfString:@")" withString:@""];
NSArray *components = [inner componentsSeparatedByString:@","];
if (components.count >= 3) {
CGFloat r = [components[0] floatValue] / 255.0;
CGFloat g = [components[1] floatValue] / 255.0;
CGFloat b = [components[2] floatValue] / 255.0;
CGFloat a = components.count >= 4 ? [components[3] floatValue] : 1.0;
return [UIColor colorWithRed:r green:g blue:b alpha:a];
}
}
// Named colors
static NSDictionary *namedColors = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
namedColors = @{
@"black" : [UIColor blackColor],
@"white" : [UIColor whiteColor],
@"red" : [UIColor redColor],
@"green" : [UIColor greenColor],
@"blue" : [UIColor blueColor],
@"yellow" : [UIColor yellowColor],
@"gray" : [UIColor grayColor],
@"grey" : [UIColor grayColor],
@"cyan" : [UIColor cyanColor],
@"magenta" : [UIColor magentaColor],
@"orange" : [UIColor orangeColor],
@"purple" : [UIColor purpleColor],
@"clear" : [UIColor clearColor],
};
});
UIColor *named = namedColors[cssValue.lowercaseString];
if (named) return named;
return nil;
}
@end
+111
View File
@@ -0,0 +1,111 @@
//
// WRBookmark.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Bookmark model. Stores bookId, chapterUid, and position information.
// Supports highlights, underlines, and page marks with associated text.
// Synced with server via WRBookNetwork.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@class WRBook;
@class WRMPReview;
// ---------------------------------------------------------------------------
// Bookmark types
// ---------------------------------------------------------------------------
typedef NS_ENUM(NSInteger, WRBookmarkType) {
WRBookmarkTypeHighlight = 0, // Yellow/blue/etc highlight
WRBookmarkTypeUnderline = 1, // Underline annotation
WRBookmarkTypeMark = 2, // Page bookmark (dog-ear)
WRBookmarkTypeNote = 3, // Written note
WRBookmarkTypePencil = 4, // Apple Pencil drawing
};
// ---------------------------------------------------------------------------
// WRBookmark
// ---------------------------------------------------------------------------
@interface WRBookmark : NSObject
// --- Ivars (from binary analysis) ---
// NSString (many): bookId, chapterUid, markText, colorStyle, reviewId,
// anchorId, rangeKey, noteContent, etc.
// WRBook: associated book model
// WRMPReview: associated review/comment model
// NSArray: range info, selected text fragments
// NSDictionary: extra metadata
// NSMutableSet: tags
// NSDictionary: sync metadata (syncKey, serverVersion)
@property (nonatomic, copy) NSString *bookmarkId; // unique bookmark ID
@property (nonatomic, copy) NSString *bookId; // book identifier
@property (nonatomic, copy) NSString *chapterUid; // chapter UID
@property (nonatomic, assign) NSInteger chapterOffset; // character offset within chapter
@property (nonatomic, assign) NSInteger chapterIndex; // chapter index in spine
@property (nonatomic, copy) NSString *markText; // highlighted/marked text
@property (nonatomic, copy, nullable) NSString *noteContent; // user note text
@property (nonatomic, assign) WRBookmarkType type; // bookmark type
@property (nonatomic, copy, nullable) NSString *colorStyle; // highlight color (e.g., "yellow", "blue", "red", "green")
@property (nonatomic, assign) NSInteger startPos; // start position (global)
@property (nonatomic, assign) NSInteger endPos; // end position (global)
@property (nonatomic, assign) NSInteger rangeLength; // length of the range
@property (nonatomic, copy, nullable) NSString *anchorId; // DOM anchor ID
@property (nonatomic, copy, nullable) NSString *rangeKey; // range key for sync
@property (nonatomic, strong, nullable) WRBook *book; // associated book
@property (nonatomic, strong, nullable) WRMPReview *review; // associated review
@property (nonatomic, strong, nullable) NSArray *rangeInfo; // detailed range info
@property (nonatomic, strong, nullable) NSDictionary *extraMetadata; // additional data
@property (nonatomic, strong, nullable) NSMutableSet<NSString *> *tags;
@property (nonatomic, assign) NSTimeInterval createTime; // creation timestamp
@property (nonatomic, assign) NSTimeInterval updateTime; // last update timestamp
@property (nonatomic, assign) BOOL isSynced; // synced with server
@property (nonatomic, copy, nullable) NSString *syncKey; // sync key for incremental updates
#pragma mark - Factory Methods
+ (instancetype)bookmarkWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
offset:(NSInteger)offset
text:(NSString *)text
type:(WRBookmarkType)type;
+ (instancetype)highlightWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
startPos:(NSInteger)startPos
endPos:(NSInteger)endPos
text:(NSString *)text
colorStyle:(NSString *)colorStyle;
#pragma mark - Serialization
- (NSDictionary *)toDictionary;
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict;
/// Convert to JSON data for network sync.
- (nullable NSData *)toJSONData;
/// Create from JSON data received from server.
+ (nullable instancetype)fromJSONData:(NSData *)data;
#pragma mark - Display
/// Return a display-friendly summary string.
- (NSString *)displaySummary;
/// Return the color as a UIColor.
- (UIColor *)highlightUIColor;
@end
NS_ASSUME_NONNULL_END
+219
View File
@@ -0,0 +1,219 @@
//
// WRBookmark.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// Detailed pseudo-code based on binary analysis (many NSString ivars,
// WRBook, WRMPReview, NSArray, NSDictionary, NSMutableSet) and
// contextual knowledge of WeRead's bookmark/annotation system.
//
#import "WRBookmark.h"
// ---------------------------------------------------------------------------
// Color mapping
// ---------------------------------------------------------------------------
static NSDictionary<NSString *, UIColor *> *sColorMap = nil;
@implementation WRBookmark
#pragma mark - Class Initialization
+ (void)initialize
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sColorMap = @{
@"yellow" : [UIColor colorWithRed:1.0 green:0.9 blue:0.3 alpha:0.4],
@"blue" : [UIColor colorWithRed:0.3 green:0.6 blue:1.0 alpha:0.4],
@"red" : [UIColor colorWithRed:1.0 green:0.3 blue:0.3 alpha:0.4],
@"green" : [UIColor colorWithRed:0.3 green:0.9 blue:0.4 alpha:0.4],
@"purple" : [UIColor colorWithRed:0.7 green:0.3 blue:0.9 alpha:0.4],
};
});
}
#pragma mark - Factory Methods
+ (instancetype)bookmarkWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
offset:(NSInteger)offset
text:(NSString *)text
type:(WRBookmarkType)type
{
WRBookmark *bm = [[WRBookmark alloc] init];
bm.bookId = bookId;
bm.chapterUid = chapterUid;
bm.chapterOffset = offset;
bm.markText = text;
bm.type = type;
bm.createTime = [[NSDate date] timeIntervalSince1970];
bm.updateTime = bm.createTime;
bm.isSynced = NO;
// Generate a unique bookmark ID
bm.bookmarkId = [[NSUUID UUID] UUIDString];
return bm;
}
+ (instancetype)highlightWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
startPos:(NSInteger)startPos
endPos:(NSInteger)endPos
text:(NSString *)text
colorStyle:(NSString *)colorStyle
{
WRBookmark *bm = [self bookmarkWithBookId:bookId
chapterUid:chapterUid
offset:startPos
text:text
type:WRBookmarkTypeHighlight];
bm.startPos = startPos;
bm.endPos = endPos;
bm.rangeLength = endPos - startPos;
bm.colorStyle = colorStyle ?: @"yellow";
return bm;
}
#pragma mark - Serialization
- (NSDictionary *)toDictionary
{
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
if (_bookmarkId) dict[@"bookmarkId"] = _bookmarkId;
if (_bookId) dict[@"bookId"] = _bookId;
if (_chapterUid) dict[@"chapterUid"] = _chapterUid;
dict[@"chapterOffset"] = @(_chapterOffset);
dict[@"chapterIndex"] = @(_chapterIndex);
if (_markText) dict[@"markText"] = _markText;
if (_noteContent) dict[@"noteContent"] = _noteContent;
dict[@"type"] = @(_type);
if (_colorStyle) dict[@"colorStyle"] = _colorStyle;
dict[@"startPos"] = @(_startPos);
dict[@"endPos"] = @(_endPos);
dict[@"rangeLength"] = @(_rangeLength);
if (_anchorId) dict[@"anchorId"] = _anchorId;
if (_rangeKey) dict[@"rangeKey"] = _rangeKey;
dict[@"createTime"] = @(_createTime);
dict[@"updateTime"] = @(_updateTime);
dict[@"isSynced"] = @(_isSynced);
if (_syncKey) dict[@"syncKey"] = _syncKey;
if (_rangeInfo) dict[@"rangeInfo"] = _rangeInfo;
if (_extraMetadata) dict[@"extraMetadata"] = _extraMetadata;
if (_tags.count > 0) {
dict[@"tags"] = [_tags allObjects];
}
return [dict copy];
}
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict
{
if (!dict) return nil;
WRBookmark *bm = [[WRBookmark alloc] init];
bm.bookmarkId = dict[@"bookmarkId"];
bm.bookId = dict[@"bookId"];
bm.chapterUid = dict[@"chapterUid"];
bm.chapterOffset = [dict[@"chapterOffset"] integerValue];
bm.chapterIndex = [dict[@"chapterIndex"] integerValue];
bm.markText = dict[@"markText"];
bm.noteContent = dict[@"noteContent"];
bm.type = [dict[@"type"] integerValue];
bm.colorStyle = dict[@"colorStyle"];
bm.startPos = [dict[@"startPos"] integerValue];
bm.endPos = [dict[@"endPos"] integerValue];
bm.rangeLength = [dict[@"rangeLength"] integerValue];
bm.anchorId = dict[@"anchorId"];
bm.rangeKey = dict[@"rangeKey"];
bm.createTime = [dict[@"createTime"] doubleValue];
bm.updateTime = [dict[@"updateTime"] doubleValue];
bm.isSynced = [dict[@"isSynced"] boolValue];
bm.syncKey = dict[@"syncKey"];
bm.rangeInfo = dict[@"rangeInfo"];
bm.extraMetadata = dict[@"extraMetadata"];
NSArray *tags = dict[@"tags"];
if (tags) {
bm.tags = [NSMutableSet setWithArray:tags];
}
return bm;
}
- (nullable NSData *)toJSONData
{
NSDictionary *dict = [self toDictionary];
return [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil];
}
+ (nullable instancetype)fromJSONData:(NSData *)data
{
if (!data) return nil;
NSError *error = nil;
NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data
options:0
error:&error];
if (!dict || ![dict isKindOfClass:[NSDictionary class]]) return nil;
return [self fromDictionary:dict];
}
#pragma mark - Display
- (NSString *)displaySummary
{
switch (_type) {
case WRBookmarkTypeHighlight:
return [NSString stringWithFormat:@"[Highlight] %@", _markText ?: @""];
case WRBookmarkTypeUnderline:
return [NSString stringWithFormat:@"[Underline] %@", _markText ?: @""];
case WRBookmarkTypeMark:
return [NSString stringWithFormat:@"[Bookmark] Chapter %@", _chapterUid ?: @""];
case WRBookmarkTypeNote:
return [NSString stringWithFormat:@"[Note] %@", _noteContent ?: _markText ?: @""];
case WRBookmarkTypePencil:
return @"[Pencil Note]";
default:
return _markText ?: @"";
}
}
- (UIColor *)highlightUIColor
{
if (!_colorStyle) {
return sColorMap[@"yellow"] ?: [UIColor yellowColor];
}
return sColorMap[_colorStyle] ?: [UIColor yellowColor];
}
#pragma mark - NSObject
- (NSString *)description
{
return [NSString stringWithFormat:@"<WRBookmark: %@ type=%ld book=%@ ch=%@ '%@'>",
_bookmarkId, (long)_type, _bookId, _chapterUid,
[_markText substringToIndex:MIN(30, _markText.length)]];
}
- (BOOL)isEqual:(id)object
{
if (self == object) return YES;
if (![object isKindOfClass:[WRBookmark class]]) return NO;
WRBookmark *other = (WRBookmark *)object;
return [self.bookmarkId isEqualToString:other.bookmarkId];
}
- (NSUInteger)hash
{
return self.bookmarkId.hash;
}
@end
+183
View File
@@ -0,0 +1,183 @@
//
// WRChapterData.h
// WeRead (微信读书)
//
// Reverse-engineered header reconstruction.
// Chapter data model. Stores the typeset NSAttributedString.
// Manages highlights, underlines, reviews/annotations.
// Uses WRCoreTextLayouter for layout computation.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
@class WRCoreTextLayouter;
NS_ASSUME_NONNULL_BEGIN
// ============================================================================
#pragma mark - Highlight / Underline Style Constants
// ============================================================================
/// Style of underline drawn for highlights and annotations.
typedef NS_ENUM(NSInteger, WRUnderlineStyle) {
WRUnderlineStyleNone = 0,
WRUnderlineStyleSolid = 1,
WRUnderlineStyleDashed = 2,
WRUnderlineStyleWavy = 3,
};
/// Type of review / annotation.
typedef NS_ENUM(NSInteger, WRReviewType) {
WRReviewTypeHighlight = 0, // Color highlight
WRReviewTypeUnderline = 1, // Underline only
WRReviewTypeNote = 2, // Text note attached to range
};
// ============================================================================
#pragma mark - WRChapterData
// ============================================================================
@interface WRChapterData : NSObject
// ---- Core content ----
/// The fully typeset attributed string for this chapter, with all fonts,
/// colors, paragraph styles, and inline image attachments applied.
@property (nonatomic, strong, nullable) NSMutableAttributedString *typesetAttributedString;
/// The layouter that computes line breaks and page breaks for this chapter.
@property (nonatomic, strong, nullable) WRCoreTextLayouter *layouter;
// ---- Page ranges ----
/// Array of NSValue-wrapped NSRange values, one per page.
/// Each range is a character range within typesetAttributedString.
@property (nonatomic, strong, nullable) NSArray<NSValue *> *pageRanges;
// ---- Highlights and annotations ----
/// Array of highlight dictionaries. Each entry contains:
/// @"range" : NSValue wrapping NSRange
/// @"key" : NSString (unique highlight ID)
/// @"itemId" : NSString (item identifier, e.g., bookmark ID)
/// @"color" : UIColor
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *highlights;
/// Array of underline / review dictionaries. Each entry contains:
/// @"range" : NSValue wrapping NSRange
/// @"itemId" : NSString
/// @"type" : @(WRReviewType)
/// @"style" : @(WRUnderlineStyle)
/// @"color" : UIColor
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *underlines;
/// Temporary review highlight (not yet saved), used during review creation.
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *tempReviewHighlights;
/// Set of bookmarked page indices (NSSet of NSNumber).
@property (nonatomic, strong, nullable) NSSet<NSNumber *> *bookmarkedPages;
/// Raw chapter source text (before typesetting).
@property (nonatomic, strong, nullable) NSAttributedString *sourceAttributedString;
// ---- Chapter metadata ----
@property (nonatomic, copy, nullable) NSString *chapterId;
@property (nonatomic, copy, nullable) NSString *chapterTitle;
@property (nonatomic, assign) NSUInteger chapterIndex;
// ---- Content insets for the reading area ----
@property (nonatomic, assign) UIEdgeInsets contentInsets;
// ---- Outline / TOC ----
/// Array of outline entry dictionaries generated from headings in the chapter.
/// Each entry: @"title", @"level", @"range" (NSValue wrapping NSRange).
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *outlineContents;
// ---- Free trial cutoff ----
/// The string location (character index) at which the free trial ends.
/// NSNotFound if the chapter is fully accessible.
@property (nonatomic, assign) NSUInteger freeTrialCutOffLocation;
// ============================================================================
#pragma mark - Class Methods
// ============================================================================
/// Adds an underline decoration to the given attributed string at the specified
/// range, with the given style, color, and associated item ID.
+ (void)addUnderLineToAttributedString:(NSMutableAttributedString *)attributedString
range:(NSRange)range
itemId:(NSString *)itemId
style:(WRUnderlineStyle)style
color:(UIColor *)color;
/// Calculates the free trial cutoff string location within the attributed
/// string for the given book. Returns the character index where content
/// should be truncated for non-paying users.
+ (NSUInteger)freeTrialChapterCutOffStringLocaionWithAttributedString:(NSAttributedString *)attributedString
book:(id)book;
// ============================================================================
#pragma mark - Instance Methods — Highlights & Underlines
// ============================================================================
/// Adds an auto-read underline (visual indicator for auto-scroll mode).
- (void)addAutoReadUnderLineInRange:(NSRange)range
style:(WRUnderlineStyle)style
color:(UIColor *)color;
/// Adds a highlight annotation.
- (void)addHighlightInRange:(NSRange)range
key:(NSString *)key
itemId:(NSString *)itemId
color:(UIColor *)color;
/// Adds a review underline (e.g., from a friend's review).
- (void)addReviewUnderlineInRange:(NSRange)range
itemId:(NSString *)itemId
type:(WRReviewType)type;
/// Adds a temporary review highlight (not persisted until confirmed).
- (void)addTempReviewHighlightInRange:(NSRange)range
itemId:(NSString *)itemId;
/// Adds a temporary review highlight with a custom color.
- (void)addTempReviewHighlightInRange:(NSRange)range
itemId:(NSString *)itemId
color:(UIColor *)color;
/// Removes a review underline by range and type.
- (void)deleteReviewUnderlineInRange:(NSRange)range
type:(WRReviewType)type;
// ============================================================================
#pragma mark - Instance Methods — Page Queries
// ============================================================================
/// Returns the character range within typesetAttributedString for the given
/// page index (0-based). Uses the pageRanges array computed during layout.
- (NSRange)rangeOfPage:(NSUInteger)pageIndex;
// ============================================================================
#pragma mark - Instance Methods — Outline
// ============================================================================
/// Scans the attributed string for heading styles and builds the
/// outlineContents array.
- (void)generateOutlineContents;
// ============================================================================
#pragma mark - Instance Methods — Free Trial
// ============================================================================
/// Returns the real (post-typeset) string location for the free trial cutoff.
- (NSUInteger)freeTrialChapterCutOffRealStringLocation;
/// Sets the free trial cutoff string location.
- (void)markFreeTrialChapterCutOffStringLocation:(NSUInteger)location;
@end
NS_ASSUME_NONNULL_END
+442
View File
@@ -0,0 +1,442 @@
//
// WRChapterData.m
// WeRead (微信读书)
//
// Detailed pseudo-code reconstruction of the chapter data model.
// Stores the typeset NSAttributedString, manages highlights, underlines,
// reviews, page ranges, and outline generation.
//
#import "WRChapterData.h"
#import "WRCoreTextLayouter.h"
// ============================================================================
#pragma mark - Constants
// ============================================================================
static NSString *const kHighlightRangeKey = @"range";
static NSString *const kHighlightKeyKey = @"key";
static NSString *const kHighlightItemIdKey = @"itemId";
static NSString *const kHighlightColorKey = @"color";
static NSString *const kUnderlineStyleKey = @"style";
static NSString *const kUnderlineTypeKey = @"type";
// Custom attribute name used in the attributed string to mark underlines.
static NSString *const kWRUnderlineAttributeName =
@"com.weread.underline";
// Custom attribute name for highlight color.
static NSString *const kWRHighlightAttributeName =
@"com.weread.highlight";
// ============================================================================
#pragma mark - WRChapterData ()
// ============================================================================
@interface WRChapterData ()
/// Internal mutable copy of highlights for mutation.
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *mutableHighlights;
/// Internal mutable copy of underlines.
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *mutableUnderlines;
/// Internal mutable copy of temp review highlights.
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *mutableTempReviewHighlights;
@end
// ============================================================================
#pragma mark - WRChapterData Implementation
// ============================================================================
@implementation WRChapterData
// ============================================================================
#pragma mark - Initialization
// ============================================================================
- (instancetype)init {
self = [super init];
if (self) {
_mutableHighlights = [NSMutableArray array];
_mutableUnderlines = [NSMutableArray array];
_mutableTempReviewHighlights = [NSMutableArray array];
_bookmarkedPages = [NSSet set];
_freeTrialCutOffLocation = NSNotFound;
_contentInsets = UIEdgeInsetsMake(20.0, 16.0, 20.0, 16.0);
}
return self;
}
// ============================================================================
#pragma mark - Class Methods
// ============================================================================
///
/// Adds an underline decoration to an NSMutableAttributedString at the
/// specified range. The underline is stored as a custom attribute so the
/// drawing code can render it with the correct style and color.
///
/// @param attributedString The mutable attributed string to modify.
/// @param range The character range to underline.
/// @param itemId Identifier for the item (e.g., bookmark or review ID).
/// @param style The underline style (solid, dashed, wavy).
/// @param color The underline color.
///
+ (void)addUnderLineToAttributedString:(NSMutableAttributedString *)attributedString
range:(NSRange)range
itemId:(NSString *)itemId
style:(WRUnderlineStyle)style
color:(UIColor *)color {
if (!attributedString || range.location == NSNotFound) return;
if (NSMaxRange(range) > attributedString.length) return;
// Build the underline descriptor dictionary.
NSDictionary *underlineInfo = @{
kHighlightItemIdKey : itemId ?: @"",
kUnderlineStyleKey : @(style),
kHighlightColorKey : color ?: [UIColor blackColor],
};
// Apply as a custom attribute. The rendering code in WRCoreTextLayoutFrame
// will read this attribute and draw the underline during -drawInContext:.
[attributedString addAttribute:kWRUnderlineAttributeName
value:underlineInfo
range:range];
}
///
/// Calculates the free trial cutoff location within the attributed string.
/// The book object provides trial chapter limits; this method finds the
/// corresponding character position in the typeset string.
///
/// @param attributedString The typeset attributed string.
/// @param book The book model object (provides trial info).
/// @return The character index at which to cut off, or NSNotFound if fully accessible.
///
+ (NSUInteger)freeTrialChapterCutOffStringLocaionWithAttributedString:(NSAttributedString *)attributedString
book:(id)book {
if (!attributedString || !book) return NSNotFound;
// In the real implementation, this queries the book model for:
// - The number of free trial characters / chapters allowed.
// - Whether this specific chapter falls within the trial range.
// It then maps that to a character index in the attributed string.
//
// Typical logic:
// 1. Ask `book` for the trial character count or chapter index limit.
// 2. If this chapter is entirely within the trial, return NSNotFound (no cutoff).
// 3. If this chapter is entirely beyond the trial, return 0 (show nothing).
// 4. If the cutoff falls within this chapter, calculate the offset.
// Placeholder: assume the book responds to -freeTrialCharacterLimit.
if ([book respondsToSelector:NSSelectorFromString(@"freeTrialCharacterLimit")]) {
NSUInteger limit = [[book valueForKey:@"freeTrialCharacterLimit"] unsignedIntegerValue];
NSUInteger totalLength = attributedString.length;
if (limit >= totalLength) {
// Entire chapter is accessible.
return NSNotFound;
} else if (limit == 0) {
// No access.
return 0;
} else {
return limit;
}
}
return NSNotFound;
}
// ============================================================================
#pragma mark - Highlight & Underline Management
// ============================================================================
/// Adds an auto-read underline. This is a visual indicator showing which
/// text is being auto-scrolled through.
- (void)addAutoReadUnderLineInRange:(NSRange)range
style:(WRUnderlineStyle)style
color:(UIColor *)color {
if (range.location == NSNotFound) return;
// Apply the underline attribute to the typeset string.
[WRChapterData addUnderLineToAttributedString:self.typesetAttributedString
range:range
itemId:@"autoRead"
style:style
color:color];
}
/// Adds a persistent highlight annotation.
- (void)addHighlightInRange:(NSRange)range
key:(NSString *)key
itemId:(NSString *)itemId
color:(UIColor *)color {
if (range.location == NSNotFound) return;
NSDictionary *entry = @{
kHighlightRangeKey : [NSValue valueWithRange:range],
kHighlightKeyKey : key ?: @"",
kHighlightItemIdKey : itemId ?: @"",
kHighlightColorKey : color ?: [UIColor yellowColor],
};
[_mutableHighlights addObject:entry];
_highlights = [_mutableHighlights copy];
// Also apply the highlight as a custom attribute on the attributed string
// so the CoreText drawing code can render the background color.
[self.typesetAttributedString addAttribute:kWRHighlightAttributeName
value:@{kHighlightColorKey: (color ?: [UIColor yellowColor])}
range:range];
}
/// Adds a review underline (from a friend's annotation or review).
- (void)addReviewUnderlineInRange:(NSRange)range
itemId:(NSString *)itemId
type:(WRReviewType)type {
if (range.location == NSNotFound) return;
NSDictionary *entry = @{
kHighlightRangeKey : [NSValue valueWithRange:range],
kHighlightItemIdKey : itemId ?: @"",
kUnderlineTypeKey : @(type),
};
[_mutableUnderlines addObject:entry];
_underlines = [_mutableUnderlines copy];
// Apply underline attribute for rendering.
WRUnderlineStyle style = (type == WRReviewTypeHighlight)
? WRUnderlineStyleNone
: WRUnderlineStyleSolid;
UIColor *color = (type == WRReviewTypeHighlight)
? [UIColor colorWithRed:0.2 green:0.6 blue:1.0 alpha:0.3]
: [UIColor colorWithRed:1.0 green:0.4 blue:0.4 alpha:0.8];
if (style != WRUnderlineStyleNone) {
[WRChapterData addUnderLineToAttributedString:self.typesetAttributedString
range:range
itemId:itemId
style:style
color:color];
}
}
/// Adds a temporary review highlight (used during the review creation flow
/// before the user confirms and persists it).
- (void)addTempReviewHighlightInRange:(NSRange)range
itemId:(NSString *)itemId {
[self addTempReviewHighlightInRange:range
itemId:itemId
color:[UIColor colorWithRed:0.2
green:0.6
blue:1.0
alpha:0.3]];
}
/// Adds a temporary review highlight with a custom color.
- (void)addTempReviewHighlightInRange:(NSRange)range
itemId:(NSString *)itemId
color:(UIColor *)color {
if (range.location == NSNotFound) return;
NSDictionary *entry = @{
kHighlightRangeKey : [NSValue valueWithRange:range],
kHighlightItemIdKey : itemId ?: @"",
kHighlightColorKey : color ?: [UIColor yellowColor],
};
[_mutableTempReviewHighlights addObject:entry];
_tempReviewHighlights = [_mutableTempReviewHighlights copy];
// Apply as a temporary attribute (not persisted).
[self.typesetAttributedString addAttribute:kWRHighlightAttributeName
value:@{kHighlightColorKey: (color ?: [UIColor yellowColor]),
@"temporary": @YES}
range:range];
}
/// Removes a review underline from both the internal array and the
/// attributed string attributes.
- (void)deleteReviewUnderlineInRange:(NSRange)range
type:(WRReviewType)type {
if (range.location == NSNotFound) return;
// Remove matching entries from the mutable array.
NSMutableArray *toRemove = [NSMutableArray array];
for (NSDictionary *entry in _mutableUnderlines) {
NSRange entryRange = [entry[kHighlightRangeKey] rangeValue];
WRReviewType entryType = [entry[kUnderlineTypeKey] integerValue];
if (NSEqualRanges(entryRange, range) && entryType == type) {
[toRemove addObject:entry];
}
}
[_mutableUnderlines removeObjectsInArray:toRemove];
_underlines = [_mutableUnderlines copy];
// Remove the custom underline/highlight attributes from the string.
[self.typesetAttributedString removeAttribute:kWRUnderlineAttributeName range:range];
[self.typesetAttributedString removeAttribute:kWRHighlightAttributeName range:range];
}
// ============================================================================
#pragma mark - Page Range Queries
// ============================================================================
/// Returns the character range for the given page index.
/// The pageRanges array is computed during layout by WRCoreTextLayouter
/// and stored as an array of NSValue-wrapped NSRange objects.
- (NSRange)rangeOfPage:(NSUInteger)pageIndex {
if (pageIndex >= self.pageRanges.count) {
return NSMakeRange(NSNotFound, 0);
}
return [self.pageRanges[pageIndex] rangeValue];
}
// ============================================================================
#pragma mark - Outline Generation
// ============================================================================
/// Scans the typeset attributed string for heading-level paragraph styles
/// and builds an array of outline entries. Each entry is a dictionary with:
/// @"title" : NSString (the heading text)
/// @"level" : NSNumber (1 for H1, 2 for H2, etc.)
/// @"range" : NSValue wrapping the NSRange in the attributed string.
- (void)generateOutlineContents {
NSMutableArray<NSDictionary *> *outline = [NSMutableArray array];
NSAttributedString *str = self.typesetAttributedString;
if (!str || str.length == 0) {
self.outlineContents = @[];
return;
}
// Walk the attributed string by paragraph.
NSString *plainText = [str string];
NSUInteger length = plainText.length;
NSUInteger searchLoc = 0;
while (searchLoc < length) {
// Find the paragraph range.
NSRange paraRange = [plainText rangeOfString:@"\n"
options:0
range:NSMakeRange(searchLoc, length - searchLoc)];
if (paraRange.location == NSNotFound) {
paraRange = NSMakeRange(searchLoc, length - searchLoc);
} else {
paraRange = NSMakeRange(searchLoc, paraRange.location - searchLoc + 1);
}
// Check if this paragraph has a heading font attribute.
if (paraRange.length > 0) {
NSDictionary *attrs = [str attributesAtIndex:paraRange.location
effectiveRange:NULL];
UIFont *font = attrs[NSFontAttributeName];
// Heuristic: heading fonts are typically larger than body text.
// In WeRead, headings may use a custom attribute or a specific
// font descriptor. We check for font size > bodySize + 4.
CGFloat bodySize = 16.0; // Typical body font size.
if (font && font.pointSize > bodySize + 4.0) {
NSString *title = [plainText substringWithRange:
NSMakeRange(paraRange.location,
paraRange.length > 1 ? paraRange.length - 1 : paraRange.length)];
title = [title stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (title.length > 0) {
NSInteger level = 1;
if (font.pointSize < bodySize + 8.0) level = 2;
if (font.pointSize < bodySize + 6.0) level = 3;
[outline addObject:@{
@"title": title,
@"level": @(level),
@"range": [NSValue valueWithRange:paraRange],
}];
}
}
}
searchLoc = NSMaxRange(paraRange);
if (searchLoc >= length) break;
}
self.outlineContents = [outline copy];
}
// ============================================================================
#pragma mark - Free Trial Cutoff
// ============================================================================
/// Returns the real string location accounting for typesetting differences.
/// The raw cutoff location from the server may differ from the position in
/// the typeset attributed string due to inserted image attachments, etc.
- (NSUInteger)freeTrialChapterCutOffRealStringLocation {
if (self.freeTrialCutOffLocation == NSNotFound) {
return NSNotFound;
}
// In the real implementation, this maps from the source string index
// to the typeset string index, accounting for:
// - Image attachment characters () inserted during typesetting.
// - Font substitution changes in string length (rare).
//
// Simple approach: walk both strings in parallel, counting the offset.
NSUInteger sourceLoc = self.freeTrialCutOffLocation;
NSAttributedString *source = self.sourceAttributedString;
NSMutableAttributedString *typeset = self.typesetAttributedString;
if (!source || !typeset) return sourceLoc;
// If the source and typeset strings have the same length, no mapping needed.
if (source.length == typeset.length) return sourceLoc;
// Otherwise, use a character-by-character mapping.
// This is a simplified version; the real code may use a precomputed map.
NSUInteger typesetLoc = 0;
NSUInteger sourceIdx = 0;
NSString *sourcePlain = [source string];
NSString *typesetPlain = [typeset string];
while (sourceIdx < sourceLoc && typesetLoc < typesetPlain.length) {
// Skip image attachment characters in the typeset string.
unichar tc = [typesetPlain characterAtIndex:typesetLoc];
if (tc == 0xFFFC) { // NSAttachmentCharacter
typesetLoc++;
continue;
}
sourceIdx++;
typesetLoc++;
}
return typesetLoc;
}
/// Sets the free trial cutoff location from the source string index.
- (void)markFreeTrialChapterCutOffStringLocation:(NSUInteger)location {
self.freeTrialCutOffLocation = location;
}
// ============================================================================
#pragma mark - Property Accessors (Ivar-backed from binary)
// ============================================================================
// The binary shows these ivar types:
// NSMutableAttributedString -> _typesetAttributedString
// NSArray -> _pageRanges
// NSArray -> _highlights
// NSArray -> _underlines
// NSArray -> _tempReviewHighlights
// NSArray -> _outlineContents
// NSArray -> _imageAttachments (used by layout for inline images)
// WRCoreTextLayouter -> _layouter
// NSSet -> _bookmarkedPages
// NSAttributedString -> _sourceAttributedString
// These are standard @property synthesized accessors; the binary confirms
// the backing ivar names and types match.
@end
@@ -0,0 +1,82 @@
//
// WRChapterPageCount.h
// WeRead (微信读书)
//
// Reverse-engineered header reconstruction.
// Pagination calculator. Manages NSRange for each page within a chapter.
// Computes page breaks based on the typeset attributed string and the
// available drawing area.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
// ============================================================================
#pragma mark - WRChapterPageCount
// ============================================================================
@interface WRChapterPageCount : NSObject
// ---- Ivars from binary: NSString, NSString, NSString, NSArray ----
/// The book identifier this page count data belongs to.
@property (nonatomic, copy, nullable) NSString *bookId;
/// The chapter identifier.
@property (nonatomic, copy, nullable) NSString *chapterId;
/// A cache key string combining book and chapter info for disk caching.
@property (nonatomic, copy, nullable) NSString *cacheKey;
/// Array of NSValue-wrapped NSRange values, one per page.
/// Each range is a character range within the chapter's attributed string.
@property (nonatomic, strong, nullable) NSArray<NSValue *> *pageRanges;
/// Total number of pages in this chapter.
@property (nonatomic, assign, readonly) NSUInteger totalPages;
// ============================================================================
#pragma mark - Class Methods
// ============================================================================
/// Generates a cache key string for storing/retrieving pagination data
/// for the given book. The key encodes the book ID and current typesetter
/// settings (font size, line spacing, etc.) so pagination is invalidated
/// when settings change.
///
/// @param bookId The book identifier.
/// @return A unique cache key string, e.g., @"weread_pagcount_{bookId}_{fontSize}_{lineSpacing}".
+ (NSString *)currentCacheKeyWithBookId:(NSString *)bookId;
/// Calculates the page ranges from a page info dictionary.
/// The page info dictionary typically comes from the server or from local
/// layout computation and contains raw range data.
///
/// @param pageInfo Dictionary with pagination data (e.g., @"ranges" key containing
/// an array of {location, length} dictionaries).
/// @return An array of NSValue-wrapped NSRange values.
+ (NSArray<NSValue *> *)rangeValueWithPageInfo:(NSDictionary *)pageInfo;
// ============================================================================
#pragma mark - Instance Methods
// ============================================================================
/// Returns the character range for the specified page index.
- (NSRange)rangeForPageAtIndex:(NSUInteger)pageIndex;
/// Returns the page index that contains the given character index.
- (NSUInteger)pageIndexForCharacterIndex:(NSUInteger)charIndex;
/// Recalculates page ranges for the given attributed string and drawing area.
///
/// @param attributedString The typeset chapter content.
/// @param drawingSize The available drawing area size (points).
/// @param margins Content insets / margins.
- (void)recalculatePageRangesForAttributedString:(NSAttributedString *)attributedString
drawingSize:(CGSize)drawingSize
margins:(UIEdgeInsets)margins;
@end
NS_ASSUME_NONNULL_END
+308
View File
@@ -0,0 +1,308 @@
//
// WRChapterPageCount.m
// WeRead (微信读书)
//
// Detailed pseudo-code reconstruction of the pagination calculator.
// Computes page breaks by simulating CoreText line layout and fitting
// lines into the available vertical space.
//
#import "WRChapterPageCount.h"
#import <CoreText/CoreText.h>
// ============================================================================
#pragma mark - Constants
// ============================================================================
/// Prefix for pagination cache keys.
static NSString *const kPageCountCachePrefix = @"weread_pagcount";
/// Separator used in cache key components.
static NSString *const kCacheKeySeparator = @"_";
// ============================================================================
#pragma mark - WRChapterPageCount ()
// ============================================================================
@interface WRChapterPageCount ()
/// Precomputed page ranges (cached after calculation).
@property (nonatomic, strong) NSMutableArray<NSValue *> *mutablePageRanges;
@end
// ============================================================================
#pragma mark - WRChapterPageCount Implementation
// ============================================================================
@implementation WRChapterPageCount
// ============================================================================
#pragma mark - Initialization
// ============================================================================
- (instancetype)init {
self = [super init];
if (self) {
_mutablePageRanges = [NSMutableArray array];
}
return self;
}
// ============================================================================
#pragma mark - Class Methods
// ============================================================================
///
/// Generates a cache key that encodes the book ID together with the current
/// typesetter configuration (font size, line spacing, margins, page size).
/// When any of these change, the cache key changes, invalidating old data.
///
/// Typical format:
/// weread_pagcount_{bookId}_{fontSize}_{lineSpacing}_{pageWidth}_{pageHeight}
///
+ (NSString *)currentCacheKeyWithBookId:(NSString *)bookId {
if (!bookId) return kPageCountCachePrefix;
// Read current typesetter settings from NSUserDefaults or a global config.
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
CGFloat fontSize = [defaults floatForKey:@"WRTypesetterFontSize"] ?: 18.0;
CGFloat lineSpacing = [defaults floatForKey:@"WRTypesetterLineSpacing"] ?: 1.5;
CGFloat pageWidth = [defaults floatForKey:@"WRTypesetterPageWidth"] ?: 375.0;
CGFloat pageHeight = [defaults floatForKey:@"WRTypesetterPageHeight"] ?: 667.0;
// Build the key. Use integer representations to avoid locale issues.
NSString *key = [NSString stringWithFormat:@"%@%@%@%@%.0f%@%.1f%@%.0f%@%.0f",
kPageCountCachePrefix,
kCacheKeySeparator,
bookId,
kCacheKeySeparator,
fontSize * 10, // e.g., 180 for 18.0pt
kCacheKeySeparator,
lineSpacing * 10, // e.g., 15 for 1.5
kCacheKeySeparator,
pageWidth,
kCacheKeySeparator,
pageHeight];
return key;
}
///
/// Converts a page info dictionary (from server or local computation)
/// into an array of NSValue-wrapped NSRange objects.
///
/// Expected pageInfo format:
/// {
/// @"ranges": @[
/// @{@"location": @0, @"length": @500},
/// @{@"location": @500, @"length": @480},
/// ...
/// ]
/// }
///
/// Or alternatively, an array of two-element arrays:
/// {
/// @"ranges": @[@[@0, @500], @[@500, @480], ...]
/// }
///
+ (NSArray<NSValue *> *)rangeValueWithPageInfo:(NSDictionary *)pageInfo {
NSMutableArray<NSValue *> *result = [NSMutableArray array];
if (!pageInfo) return [result copy];
NSArray *ranges = pageInfo[@"ranges"];
if (!ranges || ![ranges isKindOfClass:[NSArray class]]) return [result copy];
for (id entry in ranges) {
NSRange range = NSMakeRange(NSNotFound, 0);
if ([entry isKindOfClass:[NSDictionary class]]) {
// Dictionary format: {location, length}
NSDictionary *dict = (NSDictionary *)entry;
NSUInteger loc = [dict[@"location"] unsignedIntegerValue];
NSUInteger len = [dict[@"length"] unsignedIntegerValue];
range = NSMakeRange(loc, len);
} else if ([entry isKindOfClass:[NSArray class]]) {
// Array format: [location, length]
NSArray *arr = (NSArray *)entry;
if (arr.count >= 2) {
NSUInteger loc = [arr[0] unsignedIntegerValue];
NSUInteger len = [arr[1] unsignedIntegerValue];
range = NSMakeRange(loc, len);
}
} else if ([entry isKindOfClass:[NSValue class]]) {
// Already an NSValue wrapping NSRange.
range = [(NSValue *)entry rangeValue];
}
if (range.location != NSNotFound) {
[result addObject:[NSValue valueWithRange:range]];
}
}
return [result copy];
}
// ============================================================================
#pragma mark - Instance Methods
// ============================================================================
/// Returns the character range for the page at the given index.
- (NSRange)rangeForPageAtIndex:(NSUInteger)pageIndex {
NSArray<NSValue *> *ranges = self.pageRanges ?: self.mutablePageRanges;
if (pageIndex >= ranges.count) {
return NSMakeRange(NSNotFound, 0);
}
return [ranges[pageIndex] rangeValue];
}
/// Binary-search-style lookup: finds the page index containing the given
/// character index. Pages are contiguous, so we can use binary search.
- (NSUInteger)pageIndexForCharacterIndex:(NSUInteger)charIndex {
NSArray<NSValue *> *ranges = self.pageRanges ?: self.mutablePageRanges;
if (ranges.count == 0) return 0;
NSUInteger lo = 0;
NSUInteger hi = ranges.count - 1;
while (lo <= hi) {
NSUInteger mid = lo + (hi - lo) / 2;
NSRange midRange = [ranges[mid] rangeValue];
if (charIndex >= midRange.location &&
charIndex < NSMaxRange(midRange)) {
return mid;
} else if (charIndex < midRange.location) {
if (mid == 0) break;
hi = mid - 1;
} else {
lo = mid + 1;
}
}
// If not found, return the last page (clamp).
return ranges.count - 1;
}
///
/// Recalculates page ranges by simulating CoreText typesetting.
/// This is the core pagination algorithm:
///
/// 1. Create a CTFramesetter from the attributed string.
/// 2. For each page, create a CTFrame with the available height.
/// 3. Count how many lines fit in the frame.
/// 4. Sum the character counts of those lines to get the page range.
/// 5. Advance the start position and repeat.
///
- (void)recalculatePageRangesForAttributedString:(NSAttributedString *)attributedString
drawingSize:(CGSize)drawingSize
margins:(UIEdgeInsets)margins {
[self.mutablePageRanges removeAllObjects];
if (!attributedString || attributedString.length == 0) {
self.pageRanges = @[];
return;
}
// ---- Step 1: Create the CTFramesetter ----
CTFramesetterRef framesetter =
CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)attributedString);
if (!framesetter) {
self.pageRanges = @[];
return;
}
// ---- Step 2: Compute the usable drawing area ----
CGFloat usableWidth = drawingSize.width - margins.left - margins.right;
CGFloat usableHeight = drawingSize.height - margins.top - margins.bottom;
if (usableWidth <= 0 || usableHeight <= 0) {
CFRelease(framesetter);
self.pageRanges = @[];
return;
}
// ---- Step 3: Paginate ----
NSUInteger totalLength = attributedString.length;
NSUInteger currentLocation = 0;
while (currentLocation < totalLength) {
// Create a path for this page's drawing area.
CGRect pathRect = CGRectMake(margins.left, margins.bottom,
usableWidth, usableHeight);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, pathRect);
// Create a frame for the remaining text.
// The frame will typeset as much text as fits in the path.
CFRange frameRange = CFRangeMake((CFIndex)currentLocation, 0); // 0 = until end
CTFrameRef frame = CTFramesetterCreateFrame(framesetter,
frameRange,
path, NULL);
CGPathRelease(path);
if (!frame) break;
// Get the lines that fit in this page.
NSArray *lines = (__bridge_transfer NSArray *)CTFrameGetLines(frame);
if (lines.count == 0) {
CFRelease(frame);
break;
}
// Get the line origins to determine which lines are fully within bounds.
CGPoint *origins = (CGPoint *)calloc(lines.count, sizeof(CGPoint));
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), origins);
NSUInteger pageCharCount = 0;
for (NSUInteger i = 0; i < lines.count; i++) {
CTLineRef line = (__bridge CTLineRef)lines[i];
CFRange lineRange = CTLineGetStringRange(line);
// Check if this line's origin is within the usable area.
// CoreText origins are from the bottom of the frame.
CGFloat lineY = origins[i].y;
CGFloat lineHeight = 0.0;
CGFloat descent = 0.0;
CTLineGetTypographicBounds(line, &lineHeight, &descent, NULL);
// If the line's top is above the top of the frame, it doesn't fit.
if (lineY - lineHeight > usableHeight) {
break; // This line doesn't fit; stop here.
}
pageCharCount += (NSUInteger)lineRange.length;
}
free(origins);
CFRelease(frame);
// If no characters fit (shouldn't happen normally), advance by 1 to avoid infinite loop.
if (pageCharCount == 0) {
pageCharCount = 1;
}
// Record this page's range.
NSRange pageRange = NSMakeRange(currentLocation, pageCharCount);
[self.mutablePageRanges addObject:[NSValue valueWithRange:pageRange]];
currentLocation += pageCharCount;
}
CFRelease(framesetter);
self.pageRanges = [self.mutablePageRanges copy];
}
// ============================================================================
#pragma mark - Computed Properties
// ============================================================================
- (NSUInteger)totalPages {
return self.pageRanges.count;
}
@end
@@ -0,0 +1,349 @@
//
// WRCoreTextLayoutFrame.h
// WeRead
//
// Reverse-engineered from binary analysis
// Single page layout frame for WeRead's reading engine
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <CoreText/CoreText.h>
@class WRBookCoverView;
@class WRDiscover;
NS_ASSUME_NONNULL_BEGIN
#pragma mark - Layout Line
/**
* Represents a single line of laid-out text.
* Wrapper around CTLine with additional metadata.
*/
@interface WRCoreTextLayoutLine : NSObject
/** The CTLine this wraps */
@property (nonatomic, assign, readonly) CTLineRef ctLine;
/** Origin point of this line in the frame */
@property (nonatomic, assign) CGPoint origin;
/** The range of characters in this line */
@property (nonatomic, assign) NSRange stringRange;
/** Ascent of the line */
@property (nonatomic, assign, readonly) CGFloat ascent;
/** Desent of the line */
@property (nonatomic, assign, readonly) CGFloat descent;
/** Leading of the line */
@property (nonatomic, assign, readonly) CGFloat leading;
/** Total height of the line (ascent + descent + leading) */
@property (nonatomic, assign, readonly) CGFloat height;
/** Width of the line */
@property (nonatomic, assign, readonly) CGFloat width;
/** Whether this line is the last line in a paragraph */
@property (nonatomic, assign) BOOL isLastLineInParagraph;
@end
#pragma mark - WRCoreTextLayoutFrame
/**
* WRCoreTextLayoutFrame - Manages a single page of text layout.
*
* This class wraps CTFrame and provides high-level access to the
* layout of a single page. It handles:
* - Text rendering to CGContext
* - Image placement within text
* - avoidPageBreakInside CSS property
* - Render height calculation
* - Line-by-line access for hit testing and selection
*
* Architecture:
* - Contains CTFrame internally
* - Manages array of WRCoreTextLayoutLine objects
* - Supports direct drawing to CGContext
* - Handles image attachments inline
* - Integrates with RACSubject for reactive updates
*/
@interface WRCoreTextLayoutFrame : NSObject
#pragma mark - Core Layout Data
/** The attributed string that was laid out */
@property (nonatomic, strong, nullable) NSAttributedString *attributedString;
/** The range of the attributed string represented by this frame */
@property (nonatomic, assign) NSRange stringRange;
/** Array of WRCoreTextLayoutLine objects for this frame */
@property (nonatomic, strong, readonly) NSArray<WRCoreTextLayoutLine *> *layoutLines;
/** The bounding rectangle for this frame */
@property (nonatomic, assign) CGRect frame;
/** The CTFrame reference (accessor for internal use) */
@property (nonatomic, assign, readonly) CTFrameRef ctFrame;
#pragma mark - Layout Properties
/** Whether to avoid page breaks inside certain elements */
@property (nonatomic, assign) BOOL avoidPageBreakInside;
/** The rendered content height (may differ from frame height) */
@property (nonatomic, assign, readonly) CGFloat renderedContentHeight;
/** Padding around the content area */
@property (nonatomic, assign) UIEdgeInsets contentInsets;
/** Number of columns in this frame */
@property (nonatomic, assign) NSUInteger numberOfColumns;
/** Gap between columns */
@property (nonatomic, assign) CGFloat columnGap;
#pragma mark - Content Arrays
/** Array of attachment objects (images, etc.) */
@property (nonatomic, strong, nullable) NSArray *attachments;
/** Array of strikethrough ranges */
@property (nonatomic, strong, nullable) NSArray *strikethroughRanges;
/** Array of underline ranges */
@property (nonatomic, strong, nullable) NSArray *underlineRanges;
/** Array of highlight ranges */
@property (nonatomic, strong, nullable) NSArray *highlightRanges;
/** Set of selected line indices */
@property (nonatomic, strong, nullable) NSMutableSet *selectedLineIndices;
/** Current selection string */
@property (nonatomic, copy, nullable) NSString *selectionString;
/** Array of link ranges for tap handling */
@property (nonatomic, strong, nullable) NSArray *linkRanges;
/** Search result string */
@property (nonatomic, copy, nullable) NSString *searchResultString;
/** Array of search result ranges */
@property (nonatomic, strong, nullable) NSArray *searchResultRanges;
#pragma mark - Reactive Components
/** Subject for layout change notifications */
@property (nonatomic, strong, nullable) RACSubject *layoutChangeSubject;
#pragma mark - UI Elements
/** Long press gesture recognizer for text selection */
@property (nonatomic, strong, nullable) UILongPressGestureRecognizer *longPressRecognizer;
/** Book cover view (for cover pages) */
@property (nonatomic, strong, nullable) WRBookCoverView *bookCoverView;
/** Layer for custom drawing */
@property (nonatomic, strong, nullable) CALayer *customDrawingLayer;
/** Discover view (for social features) */
@property (nonatomic, strong, nullable) WRDiscover *discoverView;
/** Additional layout data */
@property (nonatomic, strong, nullable) NSArray *additionalLayoutData;
/** Frame identifier */
@property (nonatomic, copy, nullable) NSString *frameId;
/** Content identifier */
@property (nonatomic, copy, nullable) NSString *contentId;
/** Mutable dictionary for custom attributes */
@property (nonatomic, strong, nullable) NSMutableDictionary *customAttributes;
/** Mutable array for tracking visible elements */
@property (nonatomic, strong, nullable) NSMutableArray *visibleElements;
/** Mutable array for tracking accessibility elements */
@property (nonatomic, strong, nullable) NSMutableArray *accessibilityElements;
/** Mutable dictionary for caching computed values */
@property (nonatomic, strong, nullable) NSMutableDictionary *computedValueCache;
#pragma mark - Initialization
/**
* Initialize with a CTFrame and string range.
*
* @param ctFrame The CoreText frame
* @param range The string range
* @return Initialized layout frame
*/
- (instancetype)initWithCTFrame:(CTFrameRef)ctFrame
range:(NSRange)range;
/**
* Set the CTFrame and range (used by WRCoreTextLayouter).
*/
- (void)setCTFrame:(CTFrameRef)ctFrame range:(NSRange)range;
#pragma mark - Line Access
/**
* Returns the array of layout lines.
* This triggers line extraction from the CTFrame if not already done.
*
* @return Array of WRCoreTextLayoutLine objects
*/
- (NSArray<WRCoreTextLayoutLine *> *)lines;
/**
* Returns the number of lines in this frame.
*/
- (NSUInteger)lineCount;
/**
* Returns the layout line at the specified index.
*/
- (nullable WRCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index;
#pragma mark - Height Calculation
/**
* Returns the rendered content height.
*
* This calculates the actual height of the rendered content,
* which may be less than the frame height if there's unused space.
*
* @return The height of the rendered content in points
*/
- (CGFloat)getRenderHeight;
/**
* Returns the height of the last line's descent.
* Used for precise baseline alignment.
*/
- (CGFloat)lastLineDescent;
#pragma mark - Drawing
/**
* Draw the layout frame content into a CGContext.
*
* This is the primary rendering method. It draws:
* 1. Text content using CTFrameDraw
* 2. Image attachments at their calculated positions
* 3. Decorative elements (strikethrough, underline, etc.)
*
* @param context The CGContext to draw into
* @param image Optional image to draw (for cover pages)
* @param size The size of the drawing area
* @param rect The rectangle to draw within
* @param position The position offset for the content
*/
- (void)drawInContext:(CGContextRef)context
image:(nullable UIImage *)image
size:(CGSize)size
inRect:(CGRect)rect
position:(CGPoint)position;
/**
* Draw only the text content (no images).
*/
- (void)drawTextInContext:(CGContextRef)context
inRect:(CGRect)rect;
/**
* Draw image attachments.
*/
- (void)drawAttachmentsInContext:(CGContextRef)context
inRect:(CGRect)rect;
#pragma mark - Page Break Avoidance
/**
* Removes the last lines if needed to avoid page breaks inside certain elements.
*
* This implements the CSS 'avoid-page-break-inside' property. When an element
* (like a table, code block, or list) cannot fit entirely on the current page,
* this method removes lines from the end until the element can fit.
*
* @return YES if lines were removed, NO otherwise
*/
- (BOOL)avoidPageBreakInsideByRemovingLastLinesIfNeeded;
/**
* Check if a range of text has the avoid-page-break-inside property.
*/
- (BOOL)shouldAvoidPageBreakInRange:(NSRange)range;
#pragma mark - Hit Testing
/**
* Returns the character index at a given point.
*
* @param point The point to test (in the frame's coordinate system)
* @return The character index, or NSNotFound if no character at that point
*/
- (NSUInteger)characterIndexAtPoint:(CGPoint)point;
/**
* Returns the line index at a given point.
*
* @param point The point to test
* @return The line index, or NSNotFound
*/
- (NSUInteger)lineIndexAtPoint:(CGPoint)point;
/**
* Returns the rect for a character at the given index.
*/
- (CGRect)rectForCharacterAtIndex:(NSUInteger)index;
#pragma mark - Selection
/**
* Returns the selected text string.
*/
- (nullable NSString *)selectedText;
/**
* Returns the ranges of selected text.
*/
- (NSArray<NSValue *> *)selectedRanges;
/**
* Selects text in the given range.
*/
- (void)selectTextInRange:(NSRange)range;
/**
* Clears the current selection.
*/
- (void)clearSelection;
#pragma mark - Search
/**
* Highlights search results within this frame.
*
* @param searchString The string to highlight
* @return The number of matches found
*/
- (NSUInteger)highlightSearchResults:(NSString *)searchString;
/**
* Clears all search result highlights.
*/
- (void)clearSearchHighlights;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,985 @@
//
// WRCoreTextLayoutFrame.m
// WeRead
//
// Reverse-engineered from binary analysis
// Single page layout frame implementation
//
// This file contains pseudo-code reconstruction of the WRCoreTextLayoutFrame
// class based on ivar analysis, method signatures, and behavioral context.
//
#import "WRCoreTextLayoutFrame.h"
#import <CoreText/CoreText.h>
// Forward declarations for WeRead-specific classes
@class WRBookCoverView;
@class WRDiscover;
@class RACSubject;
#pragma mark - Internal Constants
// Threshold for avoid-page-break-inside calculation
static const CGFloat kPageBreakAvoidanceThreshold = 20.0;
// Maximum lines to remove for page break avoidance
static const NSUInteger kMaxLinesToRemove = 3;
#pragma mark - WRCoreTextLayoutLine Implementation
@implementation WRCoreTextLayoutLine
- (instancetype)initWithCTLine:(CTLineRef)ctLine origin:(CGPoint)origin range:(NSRange)range {
self = [super init];
if (self) {
_ctLine = (CTLineRef)CFRetain(ctLine);
_origin = origin;
_stringRange = range;
// Calculate typographic metrics
CGFloat ascent, descent, leading;
CTLineGetTypographicBounds(_ctLine, &ascent, &descent, &leading);
_ascent = ascent;
_descent = descent;
_leading = leading;
_height = ascent + descent + leading;
_width = CTLineGetTypographicBounds(_ctLine, NULL, NULL, NULL);
}
return self;
}
- (void)dealloc {
if (_ctLine) {
CFRelease(_ctLine);
_ctLine = NULL;
}
}
- (BOOL)isLastLineInParagraph {
// A line is the last in a paragraph if it ends with a newline
// or if the next line starts a new paragraph
CFRange cfRange = CTLineGetStringRange(_ctLine);
if (cfRange.length == 0) return NO;
NSUInteger endIndex = cfRange.location + cfRange.length - 1;
// This would need access to the full attributed string to determine
// For now, we use the stored property
return _isLastLineInParagraph;
}
@end
#pragma mark - Private Interface
@interface WRCoreTextLayoutFrame () {
// The CoreText frame object
CTFrameRef _ctFrame;
// Cached layout lines
NSArray<WRCoreTextLayoutLine *> *_cachedLines;
// Whether lines have been extracted
BOOL _linesExtracted;
// Render height cache
CGFloat _cachedRenderHeight;
BOOL _renderHeightCached;
// Internal lock for thread safety
NSLock *_frameLock;
}
@end
#pragma mark - WRCoreTextLayoutFrame Implementation
@implementation WRCoreTextLayoutFrame
#pragma mark - Lifecycle
- (instancetype)init {
self = [super init];
if (self) {
[self commonInit];
}
return self;
}
- (instancetype)initWithCTFrame:(CTFrameRef)ctFrame range:(NSRange)range {
self = [super init];
if (self) {
[self commonInit];
[self setCTFrame:ctFrame range:range];
}
return self;
}
/**
* Common initialization - sets up internal state.
*/
- (void)commonInit {
_frameLock = [[NSLock alloc] init];
_cachedLines = @[];
_linesExtracted = NO;
_renderHeightCached = NO;
_cachedRenderHeight = 0;
_customAttributes = [NSMutableDictionary dictionary];
_visibleElements = [NSMutableArray array];
_accessibilityElements = [NSMutableArray array];
_computedValueCache = [NSMutableDictionary dictionary];
_selectedLineIndices = [NSMutableSet set];
_avoidPageBreakInside = NO;
_numberOfColumns = 1;
_columnGap = 20.0;
_contentInsets = UIEdgeInsetsZero;
}
- (void)dealloc {
if (_ctFrame) {
CFRelease(_ctFrame);
_ctFrame = NULL;
}
}
#pragma mark - Property Accessors
- (void)setCTFrame:(CTFrameRef)ctFrame range:(NSRange)range {
[_frameLock lock];
if (_ctFrame) {
CFRelease(_ctFrame);
}
_ctFrame = ctFrame ? (CTFrameRef)CFRetain(ctFrame) : NULL;
_stringRange = range;
// Invalidate cached data
_linesExtracted = NO;
_renderHeightCached = NO;
_cachedLines = @[];
[_frameLock unlock];
}
- (CTFrameRef)ctFrame {
return _ctFrame;
}
- (void)setAvoidPageBreakInside:(BOOL)avoidPageBreakInside {
_avoidPageBreakInside = avoidPageBreakInside;
if (avoidPageBreakInside) {
// When enabled, we need to check and potentially remove lines
[self avoidPageBreakInsideByRemovingLastLinesIfNeeded];
}
}
#pragma mark - Line Extraction
/**
* Extracts layout lines from the CTFrame.
*
* This method iterates through the CTFrame's lines and creates
* WRCoreTextLayoutLine wrapper objects with position information.
*
* Algorithm:
* 1. Get array of CTLines from CTFrame
* 2. For each CTLine:
* a. Get its origin from CTFrameGetLineOrigins
* b. Get the string range from CTLineGetStringRange
* c. Create WRCoreTextLayoutLine wrapper
* d. Determine if it's the last line in a paragraph
* 3. Cache the results
*/
- (void)extractLines {
if (_linesExtracted || !_ctFrame) {
return;
}
[_frameLock lock];
// Get the lines from the CTFrame
CFArrayRef ctLines = CTFrameGetLines(_ctFrame);
if (!ctLines) {
[_frameLock unlock];
return;
}
CFIndex lineCount = CFArrayGetCount(ctLines);
if (lineCount == 0) {
_cachedLines = @[];
_linesExtracted = YES;
[_frameLock unlock];
return;
}
// Get line origins
CGPoint *origins = (CGPoint *)malloc(sizeof(CGPoint) * lineCount);
CTFrameGetLineOrigins(_ctFrame, CFRangeMake(0, 0), origins);
NSMutableArray<WRCoreTextLayoutLine *> *lines = [NSMutableArray arrayWithCapacity:lineCount];
for (CFIndex i = 0; i < lineCount; i++) {
CTLineRef ctLine = CFArrayGetValueAtIndex(ctLines, i);
// Get the string range for this line
CFRange cfRange = CTLineGetStringRange(ctLine);
NSRange range = NSMakeRange(cfRange.location, cfRange.length);
// Create layout line wrapper
WRCoreTextLayoutLine *layoutLine = [[WRCoreTextLayoutLine alloc]
initWithCTLine:ctLine
origin:origins[i]
range:range];
// Determine if this is the last line in a paragraph
// Check if the next line starts a new paragraph or if this is the last line
if (i == lineCount - 1) {
layoutLine.isLastLineInParagraph = YES;
} else {
// Check if the character after this line is a newline
NSUInteger endOfLine = range.location + range.length;
if (endOfLine < [_attributedString length]) {
unichar nextChar = [[_attributedString string] characterAtIndex:endOfLine];
layoutLine.isLastLineInParagraph = (nextChar == '\n' || nextChar == '\r');
}
}
[lines addObject:layoutLine];
}
free(origins);
_cachedLines = [lines copy];
_linesExtracted = YES;
[_frameLock unlock];
}
/**
* Returns the array of layout lines.
* Triggers extraction if not already done.
*/
- (NSArray<WRCoreTextLayoutLine *> *)lines {
[self extractLines];
return _cachedLines;
}
- (NSUInteger)lineCount {
return [[self lines] count];
}
- (WRCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index {
NSArray *lines = [self lines];
if (index < [lines count]) {
return lines[index];
}
return nil;
}
#pragma mark - Height Calculation
/**
* Calculates the rendered content height.
*
* This method determines the actual height of the rendered content
* by examining the position of the last line. The rendered height
* may be less than the frame height if there's unused space at the bottom.
*
* Algorithm:
* 1. Get all layout lines
* 2. Find the lowest line (by origin.y - descent)
* 3. Return that position as the rendered height
*
* @return The rendered content height in points
*/
- (CGFloat)getRenderHeight {
[_frameLock lock];
if (_renderHeightCached) {
[_frameLock unlock];
return _cachedRenderHeight;
}
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
if ([lines count] == 0) {
_cachedRenderHeight = 0;
_renderHeightCached = YES;
[_frameLock unlock];
return 0;
}
// Find the lowest point of the last line
// The last line's bottom edge (origin.y - descent) gives us the content height
WRCoreTextLayoutLine *lastLine = [lines lastObject];
// In CoreText, the coordinate system is flipped (origin at bottom-left)
// The line's origin.y is the baseline position
// To get the bottom of the line, we subtract the descent
CGFloat lastLineBottom = lastLine.origin.y - lastLine.descent;
// The rendered height is from the top of the frame to the bottom of the last line
// Since CoreText uses bottom-left origin, we need to convert
CGFloat frameHeight = _frame.size.height;
_cachedRenderHeight = frameHeight - lastLineBottom;
// Add content insets
_cachedRenderHeight += _contentInsets.top + _contentInsets.bottom;
_renderHeightCached = YES;
[_frameLock unlock];
return _cachedRenderHeight;
}
/**
* Returns the descent of the last line.
* Used for precise baseline alignment at the bottom of the page.
*/
- (CGFloat)lastLineDescent {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
if ([lines count] == 0) {
return 0;
}
WRCoreTextLayoutLine *lastLine = [lines lastObject];
return lastLine.descent;
}
#pragma mark - Drawing
/**
* Draws the layout frame content into a CGContext.
*
* This is the primary rendering method for the reading view.
* It performs the following steps:
*
* 1. Save the graphics state
* 2. Flip the coordinate system (UIKit vs CoreText)
* 3. Apply position offset
* 4. Draw text using CTFrameDraw
* 5. Draw image attachments at their positions
* 6. Draw decorative elements (strikethrough, underline, highlights)
* 7. Restore the graphics state
*
* @param context The CGContext to draw into
* @param image Optional image (used for cover pages)
* @param size The size of the drawing area
* @param rect The rectangle to draw within
* @param position The position offset for multi-page layouts
*/
- (void)drawInContext:(CGContextRef)context
image:(UIImage *)image
size:(CGSize)size
inRect:(CGRect)rect
position:(CGPoint)position {
if (!context || !_ctFrame) {
return;
}
[_frameLock lock];
// Save graphics state before drawing
CGContextSaveGState(context);
// CoreText uses a bottom-left origin, UIKit uses top-left
// We need to flip the coordinate system
CGContextTranslateCTM(context, 0, rect.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
// Apply the position offset (for multi-column or multi-page layouts)
CGContextTranslateCTM(context, position.x, -position.y);
// Apply content insets
CGContextTranslateCTM(context, _contentInsets.left, _contentInsets.bottom);
// Draw the optional image (for cover pages or special layouts)
if (image) {
[self drawCoverImage:image inContext:context rect:rect];
}
// Draw the main text content
CTFrameDraw(_ctFrame, context);
// Draw image attachments
[self drawAttachmentsInContext:context inRect:rect];
// Draw decorative elements
[self drawDecorativeElementsInContext:context inRect:rect];
// Restore graphics state
CGContextRestoreGState(context);
[_frameLock unlock];
}
/**
* Draws only the text content without images.
*/
- (void)drawTextInContext:(CGContextRef)context inRect:(CGRect)rect {
if (!context || !_ctFrame) {
return;
}
[_frameLock lock];
CGContextSaveGState(context);
// Flip coordinate system
CGContextTranslateCTM(context, 0, rect.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
// Apply content insets
CGContextTranslateCTM(context, _contentInsets.left, _contentInsets.bottom);
// Draw just the text
CTFrameDraw(_ctFrame, context);
CGContextRestoreGState(context);
[_frameLock unlock];
}
/**
* Draws image attachments at their calculated positions.
*
* This method iterates through the attachment array and draws each
* image at the position determined by the layout engine.
*
* The attachment positions are calculated during layout and stored
* in the attachments array. Each attachment has:
* - A position (origin point)
* - A size
* - An image reference
*/
- (void)drawAttachmentsInContext:(CGContextRef)context inRect:(CGRect)rect {
if (!_attachments || [_attachments count] == 0) {
return;
}
for (NSDictionary *attachment in _attachments) {
UIImage *image = attachment[@"image"];
NSValue *positionValue = attachment[@"position"];
NSValue *sizeValue = attachment[@"size"];
if (!image || !positionValue || !sizeValue) {
continue;
}
CGPoint imagePosition = [positionValue CGPointValue];
CGSize imageSize = [sizeValue CGSizeValue];
CGRect imageRect = CGRectMake(imagePosition.x,
imagePosition.y,
imageSize.width,
imageSize.height);
// Draw the image
CGContextDrawImage(context, imageRect, image.CGImage);
}
}
/**
* Draws decorative elements like strikethrough and underline.
*/
- (void)drawDecorativeElementsInContext:(CGContextRef)context inRect:(CGRect)rect {
// Draw strikethrough lines
if (_strikethroughRanges) {
[self drawStrikethroughInContext:context];
}
// Draw underlines
if (_underlineRanges) {
[self drawUnderlineInContext:context];
}
// Draw highlights
if (_highlightRanges) {
[self drawHighlightsInContext:context];
}
}
/**
* Draws strikethrough lines for the specified ranges.
*/
- (void)drawStrikethroughInContext:(CGContextRef)context {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (NSValue *rangeValue in _strikethroughRanges) {
NSRange range = [rangeValue rangeValue];
// Find lines that intersect with this range
for (WRCoreTextLayoutLine *line in lines) {
NSRange lineRange = line.stringRange;
NSRange intersection = NSIntersectionRange(range, lineRange);
if (intersection.length > 0) {
// Get the x positions for the strikethrough
CGFloat startX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location, NULL);
CGFloat endX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location + intersection.length, NULL);
// Strikethrough is at the middle of the line
CGFloat y = line.origin.y + (line.ascent * 0.3);
CGContextSetStrokeColorWithColor(context, [UIColor redColor].CGColor);
CGContextSetLineWidth(context, 1.0);
CGContextMoveToPoint(context, line.origin.x + startX, y);
CGContextAddLineToPoint(context, line.origin.x + endX, y);
CGContextStrokePath(context);
}
}
}
}
/**
* Draws underlines for the specified ranges.
*/
- (void)drawUnderlineInContext:(CGContextRef)context {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (NSDictionary *underlineData in _underlineRanges) {
NSRange range = [underlineData[@"range"] rangeValue];
UIColor *color = underlineData[@"color"] ?: [UIColor blueColor];
for (WRCoreTextLayoutLine *line in lines) {
NSRange lineRange = line.stringRange;
NSRange intersection = NSIntersectionRange(range, lineRange);
if (intersection.length > 0) {
CGFloat startX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location, NULL);
CGFloat endX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location + intersection.length, NULL);
// Underline is below the baseline
CGFloat y = line.origin.y - line.descent;
CGContextSetStrokeColorWithColor(context, color.CGColor);
CGContextSetLineWidth(context, 1.0);
CGContextMoveToPoint(context, line.origin.x + startX, y);
CGContextAddLineToPoint(context, line.origin.x + endX, y);
CGContextStrokePath(context);
}
}
}
}
/**
* Draws highlight backgrounds for the specified ranges.
*/
- (void)drawHighlightsInContext:(CGContextRef)context {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (NSDictionary *highlightData in _highlightRanges) {
NSRange range = [highlightData[@"range"] rangeValue];
UIColor *color = highlightData[@"color"] ?: [UIColor yellowColor];
for (WRCoreTextLayoutLine *line in lines) {
NSRange lineRange = line.stringRange;
NSRange intersection = NSIntersectionRange(range, lineRange);
if (intersection.length > 0) {
CGFloat startX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location, NULL);
CGFloat endX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location + intersection.length, NULL);
// Highlight rect from descent to ascent
CGFloat y = line.origin.y - line.descent;
CGFloat height = line.ascent + line.descent;
CGRect highlightRect = CGRectMake(
line.origin.x + startX,
y,
endX - startX,
height
);
CGContextSetFillColorWithColor(context, [color colorWithAlphaComponent:0.3].CGColor);
CGContextFillRect(context, highlightRect);
}
}
}
}
/**
* Draws a cover image for special pages.
*/
- (void)drawCoverImage:(UIImage *)image inContext:(CGContextRef)context rect:(CGRect)rect {
if (!image) return;
// Scale the image to fit the frame while maintaining aspect ratio
CGSize imageSize = image.size;
CGSize frameSize = rect.size;
CGFloat widthRatio = frameSize.width / imageSize.width;
CGFloat heightRatio = frameSize.height / imageSize.height;
CGFloat scale = MIN(widthRatio, heightRatio);
CGSize scaledSize = CGSizeMake(imageSize.width * scale, imageSize.height * scale);
// Center the image in the frame
CGFloat x = (frameSize.width - scaledSize.width) / 2;
CGFloat y = (frameSize.height - scaledSize.height) / 2;
CGRect imageRect = CGRectMake(x, y, scaledSize.width, scaledSize.height);
CGContextDrawImage(context, imageRect, image.CGImage);
}
#pragma mark - Page Break Avoidance
/**
* Implements the CSS 'avoid-page-break-inside' property.
*
* When an element (like a table, code block, or list) has this property,
* we need to ensure it doesn't break across pages. If it would break,
* we remove the last few lines to make room for the element on the next page.
*
* Algorithm:
* 1. Check if avoidPageBreakInside is enabled
* 2. Find elements with this property in the current frame
* 3. For each element, check if it would break
* 4. If it would break, remove lines from the end until the element fits
* 5. Return whether any lines were removed
*
* @return YES if lines were removed to avoid page break
*/
- (BOOL)avoidPageBreakInsideByRemovingLastLinesIfNeeded {
if (!_avoidPageBreakInside) {
return NO;
}
[_frameLock lock];
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
if ([lines count] == 0) {
[_frameLock unlock];
return NO;
}
// Check if any line has the avoid-page-break-inside attribute
// This would be set in the attributed string attributes
BOOL needsRemoval = NO;
NSUInteger linesToRemove = 0;
// Iterate from the end to find lines that shouldn't be broken
for (NSInteger i = [lines count] - 1; i >= 0; i--) {
WRCoreTextLayoutLine *line = lines[i];
NSRange range = line.stringRange;
// Check if this line is part of an element with avoid-page-break-inside
if ([self shouldAvoidPageBreakInRange:range]) {
// Check if the element starts before this line
// If so, we need to remove this line and possibly more
needsRemoval = YES;
linesToRemove++;
// Limit the number of lines we remove
if (linesToRemove >= kMaxLinesToRemove) {
break;
}
} else if (needsRemoval) {
// We've found a line that doesn't need avoidance, stop
break;
}
}
if (needsRemoval && linesToRemove > 0) {
// Remove the last N lines
NSMutableArray *mutableLines = [_cachedLines mutableCopy];
[mutableLines removeObjectsInRange:
NSMakeRange([mutableLines count] - linesToRemove, linesToRemove)];
_cachedLines = [mutableLines copy];
// Update the CTFrame to reflect the removal
// This requires recreating the frame with a shorter range
[self rebuildFrameWithoutLastLines:linesToRemove];
[_frameLock unlock];
return YES;
}
[_frameLock unlock];
return NO;
}
/**
* Checks if a text range has the avoid-page-break-inside property.
*/
- (BOOL)shouldAvoidPageBreakInRange:(NSRange)range {
if (!_attributedString || range.location >= [_attributedString length]) {
return NO;
}
// Check the attributes at the start of the range
NSDictionary *attrs = [_attributedString attributesAtIndex:range.location
effectiveRange:NULL];
// Check for our custom avoid-page-break-inside attribute
NSNumber *avoidBreak = attrs[@"WRAvoidPageBreakInside"];
if ([avoidBreak boolValue]) {
return YES;
}
// Also check for block-level elements that shouldn't break
NSString *blockType = attrs[@"WRBlockType"];
if ([blockType isEqualToString:@"table"] ||
[blockType isEqualToString:@"code"] ||
[blockType isEqualToString:@"list"] ||
[blockType isEqualToString:@"blockquote"]) {
return YES;
}
return NO;
}
/**
* Rebuilds the CTFrame without the last N lines.
* This is used after avoidPageBreakInside removes lines.
*/
- (void)rebuildFrameWithoutLastLines:(NSUInteger)count {
if (!_ctFrame || count == 0) {
return;
}
// Get the current lines
NSArray<WRCoreTextLayoutLine *> *lines = _cachedLines;
if ([lines count] == 0) {
return;
}
// Calculate the new end range
WRCoreTextLayoutLine *lastLine = [lines lastObject];
NSUInteger newEnd = lastLine.stringRange.location + lastLine.stringRange.length;
// Update the string range
_stringRange = NSMakeRange(_stringRange.location, newEnd - _stringRange.location);
// Note: In a real implementation, we would need to recreate the CTFrame
// with the updated range. This requires access to the CTTypesetter.
// For this reconstruction, we just update the range metadata.
}
#pragma mark - Hit Testing
/**
* Returns the character index at a given point.
*
* Uses CTLineGetStringIndexForPosition to find which character
* is at the given point. This is used for text selection and
* tap handling.
*
* @param point The point to test (in the frame's coordinate system)
* @return The character index, or NSNotFound
*/
- (NSUInteger)characterIndexAtPoint:(CGPoint)point {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (WRCoreTextLayoutLine *line in lines) {
// Check if the point is within this line's vertical bounds
CGFloat lineTop = line.origin.y + line.ascent;
CGFloat lineBottom = line.origin.y - line.descent;
if (point.y >= lineBottom && point.y <= lineTop) {
// Use CTLine to find the character index
CFIndex index = CTLineGetStringIndexForPosition(line.ctLine, point);
if (index != kCFNotFound) {
return (NSUInteger)index;
}
}
}
return NSNotFound;
}
/**
* Returns the line index at a given point.
*/
- (NSUInteger)lineIndexAtPoint:(CGPoint)point {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (NSUInteger i = 0; i < [lines count]; i++) {
WRCoreTextLayoutLine *line = lines[i];
CGFloat lineTop = line.origin.y + line.ascent;
CGFloat lineBottom = line.origin.y - line.descent;
if (point.y >= lineBottom && point.y <= lineTop) {
return i;
}
}
return NSNotFound;
}
/**
* Returns the rect for a character at the given index.
*/
- (CGRect)rectForCharacterAtIndex:(NSUInteger)index {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (WRCoreTextLayoutLine *line in lines) {
NSRange range = line.stringRange;
if (index >= range.location && index < range.location + range.length) {
// Get the x position for this character
CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL);
return CGRectMake(line.origin.x + x,
line.origin.y - line.descent,
1, // Width of 1 character
line.height);
}
}
return CGRectNull;
}
#pragma mark - Selection
/**
* Returns the currently selected text.
*/
- (NSString *)selectedText {
NSArray<NSValue *> *ranges = [self selectedRanges];
if ([ranges count] == 0) {
return nil;
}
NSMutableString *selectedText = [NSMutableString string];
for (NSValue *rangeValue in ranges) {
NSRange range = [rangeValue rangeValue];
NSString *substring = [_attributedString.string substringWithRange:range];
[selectedText appendString:substring];
}
return [selectedText copy];
}
/**
* Returns the ranges of selected text.
*/
- (NSArray<NSValue *> *)selectedRanges {
NSMutableArray<NSValue *> *ranges = [NSMutableArray array];
// Build ranges from selected line indices
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (NSUInteger i = 0; i < [lines count]; i++) {
if ([_selectedLineIndices containsObject:@(i)]) {
WRCoreTextLayoutLine *line = lines[i];
[ranges addObject:[NSValue valueWithRange:line.stringRange]];
}
}
return [ranges copy];
}
/**
* Selects text in the given range.
*/
- (void)selectTextInRange:(NSRange)range {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (NSUInteger i = 0; i < [lines count]; i++) {
WRCoreTextLayoutLine *line = lines[i];
NSRange intersection = NSIntersectionRange(range, line.stringRange);
if (intersection.length > 0) {
[_selectedLineIndices addObject:@(i)];
}
}
// Notify via RACSubject
[_layoutChangeSubject sendNext:@{
@"type": @"selection",
@"range": [NSValue valueWithRange:range]
}];
}
/**
* Clears the current selection.
*/
- (void)clearSelection {
[_selectedLineIndices removeAllObjects];
[_layoutChangeSubject sendNext:@{
@"type": @"selectionCleared"
}];
}
#pragma mark - Search
/**
* Highlights search results within this frame.
*
* @param searchString The string to search for
* @return The number of matches found
*/
- (NSUInteger)highlightSearchResults:(NSString *)searchString {
if (!searchString || !_attributedString) {
return 0;
}
NSMutableArray<NSValue *> *results = [NSMutableArray array];
NSString *fullText = _attributedString.string;
// Use NSString's rangeOfString for searching
NSRange searchRange = NSMakeRange(0, [fullText length]);
while (searchRange.location < [fullText length]) {
NSRange foundRange = [fullText rangeOfString:searchString
options:NSCaseInsensitiveSearch
range:searchRange];
if (foundRange.location == NSNotFound) {
break;
}
[results addObject:[NSValue valueWithRange:foundRange]];
// Move search range forward
searchRange.location = foundRange.location + foundRange.length;
searchRange.length = [fullText length] - searchRange.location;
}
// Store results
_searchResultString = searchString;
_searchResultRanges = [results copy];
// Create highlight ranges
NSMutableArray *highlights = [NSMutableArray array];
UIColor *highlightColor = [UIColor yellowColor];
for (NSValue *rangeValue in results) {
[highlights addObject:@{
@"range": rangeValue,
@"color": highlightColor
}];
}
_highlightRanges = [highlights copy];
return [results count];
}
/**
* Clears all search result highlights.
*/
- (void)clearSearchHighlights {
_searchResultString = nil;
_searchResultRanges = nil;
_highlightRanges = nil;
}
@end
+291
View File
@@ -0,0 +1,291 @@
//
// WRCoreTextLayouter.h
// WeRead
//
// Reverse-engineered from binary analysis
// CoreText typesetter wrapper for WeRead's reading engine
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <CoreText/CoreText.h>
@class WRCoreTextLayoutFrame;
@class WRMarkContentChapter;
@class QMUITextField;
@class WRButton;
NS_ASSUME_NONNULL_BEGIN
#pragma mark - Layout Configuration
/**
* Configuration options for text layout.
* Controls how the typesetter processes attributed strings.
*/
@interface WRCoreTextLayoutConfig : NSObject
@property (nonatomic, assign) CGFloat frameWidth;
@property (nonatomic, assign) CGFloat frameHeight;
@property (nonatomic, assign) UIEdgeInsets edgeInsets;
@property (nonatomic, assign) NSUInteger numberOfColumns;
@property (nonatomic, assign) CGFloat columnGap;
@property (nonatomic, assign) BOOL avoidOrphans;
@property (nonatomic, assign) BOOL avoidWidows;
@property (nonatomic, assign) BOOL hyphenation;
@end
#pragma mark - WRCoreTextLayouter
/**
* WRCoreTextLayouter - CoreText typesetter wrapper for WeRead.
*
* This class wraps CTTypesetter and CTFramesetter to provide high-level
* text layout functionality. It takes an NSAttributedString and produces
* WRCoreTextLayoutFrame objects representing individual pages.
*
* Architecture:
* - Creates CTTypesetter from attributed string
* - Manages typesetter lifecycle and caching
* - Produces layout frames for pagination
* - Handles image resizing and page backgrounds
* - Integrates with WeRead's theme system
*/
@interface WRCoreTextLayouter : NSObject
#pragma mark - Text Content
/** The original plain text string */
@property (nonatomic, strong, nullable) NSString *plainText;
/** The attributed string to be laid out */
@property (nonatomic, strong, nullable) NSAttributedString *attributedString;
/** A copy of the attributed string for internal modifications */
@property (nonatomic, strong, nullable) NSAttributedString *internalAttributedString;
#pragma mark - UI Components (likely from parent view hierarchy)
/** Reference to the containing view controller */
@property (nonatomic, weak, nullable) UIViewController *viewController;
/** Reference to the scroll view for pagination */
@property (nonatomic, weak, nullable) UIScrollView *scrollView;
/** Search text field (for find-in-book functionality) */
@property (nonatomic, strong, nullable) QMUITextField *searchField;
/** Another text field (possibly for page jump) */
@property (nonatomic, strong, nullable) QMUITextField *pageInputField;
/** Button for actions */
@property (nonatomic, strong, nullable) WRButton *actionButton;
/** Layer for decorations */
@property (nonatomic, strong, nullable) CALayer *decorationLayer;
/** Layer for shadow/overlay effects */
@property (nonatomic, strong, nullable) CALayer *shadowLayer;
/** Title label */
@property (nonatomic, strong, nullable) UILabel *titleLabel;
/** Subtitle/info label */
@property (nonatomic, strong, nullable) UILabel *infoLabel;
#pragma mark - Theme and Appearance
/** Background color for the text rendering area */
@property (nonatomic, strong, nullable) UIColor *backgroundColor;
/** Text color */
@property (nonatomic, strong, nullable) UIColor *textColor;
/** Primary font for body text */
@property (nonatomic, strong, nullable) UIFont *bodyFont;
/** Secondary font (for headings, emphasis) */
@property (nonatomic, strong, nullable) UIFont *headingFont;
/** Container view for rendered content */
@property (nonatomic, weak, nullable) UIView *containerView;
#pragma mark - Content Metadata
/** Chapter identifier */
@property (nonatomic, copy, nullable) NSString *chapterId;
/** Book identifier */
@property (nonatomic, copy, nullable) NSString *bookId;
/** Section identifier within chapter */
@property (nonatomic, copy, nullable) NSString *sectionId;
/** Page identifier for current page */
@property (nonatomic, copy, nullable) NSString *pageId;
/** Content source identifier */
@property (nonatomic, copy, nullable) NSString *sourceId;
/** Unique layout identifier */
@property (nonatomic, copy, nullable) NSString *layoutId;
/** Rendering mode identifier */
@property (nonatomic, copy, nullable) NSString *renderMode;
/** Theme identifier */
@property (nonatomic, copy, nullable) NSString *themeId;
/** Additional layout attributes dictionary */
@property (nonatomic, strong, nullable) NSDictionary *layoutAttributes;
/** Array of embedded attachments (images, etc.) */
@property (nonatomic, strong, nullable) NSArray *attachments;
/** Reference to the chapter content model */
@property (nonatomic, strong, nullable) WRMarkContentChapter *chapter;
/** Current pagination cursor string */
@property (nonatomic, copy, nullable) NSString *paginationCursor;
/** Mutable array of layout frames (pages) */
@property (nonatomic, strong, nullable) NSMutableArray<WRCoreTextLayoutFrame *> *layoutFrames;
#pragma mark - Initialization
/**
* Initialize with an attributed string for layout.
*
* @param attributedString The text with styling attributes
* @return Initialized layouter instance
*/
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString;
/**
* Initialize with attributed string and layout configuration.
*
* @param attributedString The text with styling attributes
* @param config Layout configuration options
* @return Initialized layouter instance
*/
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString
config:(WRCoreTextLayoutConfig *)config;
#pragma mark - Typesetter Management
/**
* Create or recreate the internal CTTypesetter.
* Called when the attributed string changes.
*/
- (void)createTypesetter;
/**
* Create or recreate the internal CTFramesetter.
* Called when frame-based layout is needed.
*/
- (void)createFramesetter;
/**
* Invalidate the current typesetter, forcing recreation on next use.
*/
- (void)invalidateTypesetter;
#pragma mark - Layout Frame Creation
/**
* Create a layout frame for a given string range within the specified rect.
*
* @param range The range of the attributed string to lay out
* @param frame The bounding rectangle for the layout
* @return A new WRCoreTextLayoutFrame for the given range
*/
- (WRCoreTextLayoutFrame *)layoutFrameWithRange:(NSRange)range
frame:(CGRect)frame;
/**
* Create layout frames for the entire attributed string.
* Breaks content into pages based on the frame size.
*
* @param pageSize The size of each page
* @return Array of WRCoreTextLayoutFrame objects
*/
- (NSArray<WRCoreTextLayoutFrame *> *)layoutFramesForPageSize:(CGSize)pageSize;
/**
* Create a single layout frame for the given range.
*
* @param range The range of text to lay out
* @param rect The bounding rectangle
* @param columns Number of columns
* @return A layout frame or nil if range is invalid
*/
- (nullable WRCoreTextLayoutFrame *)layoutFrameForRange:(NSRange)range
rect:(CGRect)rect
columns:(NSUInteger)columns;
#pragma mark - Page Background Images
/**
* Get the page background image for a given text range.
*
* @param range The range of text on the page
* @param themeBgColor The current theme's background color
* @return A UIImage for the page background, or nil
*/
- (nullable UIImage *)pageBackgroundImageAtRange:(NSRange)range
themeBgColor:(UIColor *)themeBgColor;
#pragma mark - Image Utilities
/**
* Resize an image for display within the layout.
*
* @param imagePath Path or URL string for the image
* @param rect The target rectangle for the image
* @param position The position within the layout
* @param sizePattern The size pattern to apply
* @param darkMode Whether dark mode is active
* @param themeBgColor The theme background color for blending
* @return A resized UIImage
*/
- (UIImage *)resizedImageForImagePath:(NSString *)imagePath
rect:(CGRect)rect
position:(NSUInteger)position
sizePattern:(NSString *)sizePattern
darkMode:(BOOL)darkMode
themeBgColor:(UIColor *)themeBgColor;
#pragma mark - Pagination
/**
* Calculate the number of pages for the given page size.
*
* @param pageSize The size of each page
* @return The total number of pages
*/
- (NSUInteger)numberOfPagesForPageSize:(CGSize)pageSize;
/**
* Get the text range for a specific page.
*
* @param pageIndex The page index (0-based)
* @param pageSize The size of each page
* @return The NSRange of text on the specified page
*/
- (NSRange)rangeForPageAtIndex:(NSUInteger)pageIndex
pageSize:(CGSize)pageSize;
#pragma mark - Suggested Line Heights
/**
* Get suggested line fragment heights for the current content.
* Used for precise pagination calculations.
*
* @return Array of NSNumber values representing line heights
*/
- (NSArray<NSNumber *> *)suggestedLineFragHeights;
@end
NS_ASSUME_NONNULL_END
+886
View File
@@ -0,0 +1,886 @@
//
// WRCoreTextLayouter.m
// WeRead
//
// Reverse-engineered from binary analysis
// CoreText typesetter wrapper implementation
//
// This file contains pseudo-code reconstruction of the WRCoreTextLayouter
// class based on ivar analysis, method signatures, and behavioral context.
//
#import "WRCoreTextLayouter.h"
#import "WRCoreTextLayoutFrame.h"
#import "WRMarkContentChapter.h"
#import <CoreText/CoreText.h>
// Forward declarations for internal C types (not in public headers)
typedef struct _CTTypesetter *CTTypesetterRef;
typedef struct _CTFramesetter *CTFramesetterRef;
#pragma mark - Internal Constants
// Size patterns for image resizing (from analysis of resizedImageForImagePath:)
static NSString * const kSizePatternFull = @"full";
static NSString * const kSizePatternHalf = @"half";
static NSString * const kSizePatternThird = @"third";
static NSString * const kSizePatternQuarter = @"quarter";
// Default layout configuration values
static const CGFloat kDefaultFrameWidth = 320.0;
static const CGFloat kDefaultFrameHeight = 480.0;
static const CGFloat kDefaultColumnGap = 20.0;
#pragma mark - WRCoreTextLayoutConfig Implementation
@implementation WRCoreTextLayoutConfig
- (instancetype)init {
self = [super init];
if (self) {
_frameWidth = kDefaultFrameWidth;
_frameHeight = kDefaultFrameHeight;
_edgeInsets = UIEdgeInsetsMake(10, 15, 10, 15);
_numberOfColumns = 1;
_columnGap = kDefaultColumnGap;
_avoidOrphans = YES;
_avoidWidows = YES;
_hyphenation = YES;
}
return self;
}
@end
#pragma mark - Private Interface
@interface WRCoreTextLayouter () {
// CoreText typesetter - the core text processing engine
// This is a C object that performs the actual glyph layout
CTTypesetterRef _typesetter;
// CoreText framesetter - creates frames from the typesetter
// Used when frame-based layout is needed (with paths)
CTFramesetterRef _framesetter;
// Track whether the typesetter needs recreation
BOOL _typesetterDirty;
// Track whether the framesetter needs recreation
BOOL _framesetterDirty;
// Internal lock for thread safety
NSLock *_layoutLock;
// Cache for image resizing operations
NSCache *_imageCache;
// Current layout configuration
WRCoreTextLayoutConfig *_config;
}
@end
#pragma mark - WRCoreTextLayouter Implementation
@implementation WRCoreTextLayouter
#pragma mark - Lifecycle
- (instancetype)init {
self = [super init];
if (self) {
[self commonInit];
}
return self;
}
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString {
self = [super init];
if (self) {
[self commonInit];
_attributedString = [attributedString copy];
_internalAttributedString = [attributedString copy];
_typesetterDirty = YES;
}
return self;
}
- (instancetype)initWithAttributedString:(NSAttributedString *)attributedString
config:(WRCoreTextLayoutConfig *)config {
self = [super init];
if (self) {
[self commonInit];
_attributedString = [attributedString copy];
_internalAttributedString = [attributedString copy];
_config = config;
_typesetterDirty = YES;
}
return self;
}
/**
* Common initialization - sets up internal state.
* Called by all init methods.
*/
- (void)commonInit {
_layoutLock = [[NSLock alloc] init];
_imageCache = [[NSCache alloc] init];
_imageCache.countLimit = 50; // Cache up to 50 resized images
_layoutFrames = [NSMutableArray array];
_typesetterDirty = YES;
_framesetterDirty = YES;
}
- (void)dealloc {
// Release CoreText objects - these are C objects, not ObjC
if (_typesetter) {
CFRelease(_typesetter);
_typesetter = NULL;
}
if (_framesetter) {
CFRelease(_framesetter);
_framesetter = NULL;
}
}
#pragma mark - Property Accessors
- (void)setAttributedString:(NSAttributedString *)attributedString {
// When the attributed string changes, mark typesetter as dirty
_attributedString = [attributedString copy];
_internalAttributedString = [attributedString copy];
_typesetterDirty = YES;
_framesetterDirty = YES;
// Clear existing layout frames as they're now invalid
[_layoutFrames removeAllObjects];
// Extract plain text for convenience
_plainText = [_attributedString string];
}
- (void)setPlainText:(NSString *)plainText {
_plainText = [plainText copy];
// Note: This doesn't update the attributed string - it's read-only metadata
}
#pragma mark - Typesetter Management
/**
* Creates the CTTypesetter from the current attributed string.
*
* CTTypesetter is the low-level CoreText object that performs glyph layout.
* It analyzes the attributed string and prepares it for line-by-line layout.
*
* Algorithm:
* 1. Validate we have an attributed string
* 2. Release existing typesetter if any
* 3. Create new CTTypesetter with the attributed string
* 4. Mark as clean
*/
- (void)createTypesetter {
[_layoutLock lock];
if (!_typesetterDirty && _typesetter) {
[_layoutLock unlock];
return; // Already up to date
}
// Release old typesetter
if (_typesetter) {
CFRelease(_typesetter);
_typesetter = NULL;
}
// Need attributed string to create typesetter
if (!_internalAttributedString) {
[_layoutLock unlock];
return;
}
// Create the CTTypesetter
// CTTypesetterCreateWithAttributedString analyzes the string and
// prepares internal data structures for efficient line breaking
_typesetter = CTTypesetterCreateWithAttributedString(
(__bridge CFAttributedStringRef)_internalAttributedString
);
_typesetterDirty = NO;
[_layoutLock unlock];
}
/**
* Creates the CTFramesetter from the current attributed string.
*
* CTFramesetter is a higher-level API that creates CTFrame objects.
* It internally manages its own CTTypesetter.
*/
- (void)createFramesetter {
[_layoutLock lock];
if (!_framesetterDirty && _framesetter) {
[_layoutLock unlock];
return;
}
if (_framesetter) {
CFRelease(_framesetter);
_framesetter = NULL;
}
if (!_internalAttributedString) {
[_layoutLock unlock];
return;
}
// CTFramesetterCreateWithAttributedString creates a framesetter
// that can produce CTFrame objects for arbitrary paths
_framesetter = CTFramesetterCreateWithAttributedString(
(__bridge CFAttributedStringRef)_internalAttributedString
);
_framesetterDirty = NO;
[_layoutLock unlock];
}
- (void)invalidateTypesetter {
[_layoutLock lock];
_typesetterDirty = YES;
_framesetterDirty = YES;
[_layoutLock unlock];
}
#pragma mark - Layout Frame Creation
/**
* Creates a WRCoreTextLayoutFrame for a given string range.
*
* This is the primary layout method. It:
* 1. Ensures the typesetter is ready
* 2. Creates a CTTypesetter layout for the range
* 3. Wraps it in a WRCoreTextLayoutFrame
*
* @param range Range of the attributed string to lay out
* @param frame Bounding rectangle for the layout
* @return A new WRCoreTextLayoutFrame
*/
- (WRCoreTextLayoutFrame *)layoutFrameWithRange:(NSRange)range
frame:(CGRect)frame {
[self createTypesetter];
if (!_typesetter) {
return nil;
}
[_layoutLock lock];
// Create a CGPath for the frame bounds
// The path defines the region where text will be laid out
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, frame);
// Create CTFrame from the typesetter
// CTTypesetterCreateFrame creates a frame for the given range within the path
CTFrameRef ctFrame = CTTypesetterCreateFrame(
_typesetter,
CFRangeMake(range.location, range.length),
path,
NULL // No frame attributes
);
CGPathRelease(path);
if (!ctFrame) {
[_layoutLock unlock];
return nil;
}
// Create our wrapper layout frame
WRCoreTextLayoutFrame *layoutFrame = [[WRCoreTextLayoutFrame alloc] init];
layoutFrame.attributedString = _internalAttributedString;
// The layout frame takes ownership of the CTFrame
[layoutFrame setCTFrame:ctFrame range:range];
CFRelease(ctFrame);
[_layoutFrames addObject:layoutFrame];
[_layoutLock unlock];
return layoutFrame;
}
/**
* Creates layout frames for the entire attributed string.
* This is the pagination method - it breaks content into pages.
*
* Algorithm:
* 1. Get the total string length
* 2. Start from index 0
* 3. For each page:
* a. Use CTTypesetterSuggestLineBreak to find how much fits
* b. Create a layout frame for that range
* c. Advance the cursor
* 4. Repeat until all text is laid out
*
* @param pageSize Size of each page
* @return Array of WRCoreTextLayoutFrame objects
*/
- (NSArray<WRCoreTextLayoutFrame *> *)layoutFramesForPageSize:(CGSize)pageSize {
[self createTypesetter];
if (!_typesetter || !_internalAttributedString) {
return @[];
}
[_layoutLock lock];
NSMutableArray<WRCoreTextLayoutFrame *> *frames = [NSMutableArray array];
NSUInteger totalLength = [_internalAttributedString length];
NSUInteger currentIndex = 0;
// Calculate the usable width (accounting for margins)
UIEdgeInsets insets = _config ? _config.edgeInsets : UIEdgeInsetsMake(10, 15, 10, 15);
CGFloat usableWidth = pageSize.width - insets.left - insets.right;
while (currentIndex < totalLength) {
// CTTypesetterSuggestLineBreak suggests how many characters fit in the width
// This is the core pagination algorithm
CFIndex lineBreakIndex = CTTypesetterSuggestLineBreak(
_typesetter,
currentIndex,
usableWidth
);
if (lineBreakIndex <= 0) {
// Safety: avoid infinite loop
break;
}
NSRange pageRange = NSMakeRange(currentIndex, lineBreakIndex);
CGRect pageRect = CGRectMake(0, 0, pageSize.width, pageSize.height);
// Create layout frame for this page
WRCoreTextLayoutFrame *frame = [self layoutFrameWithRange:pageRange
frame:pageRect];
if (frame) {
[frames addObject:frame];
}
currentIndex += lineBreakIndex;
}
[_layoutLock unlock];
return [frames copy];
}
- (WRCoreTextLayoutFrame *)layoutFrameForRange:(NSRange)range
rect:(CGRect)rect
columns:(NSUInteger)columns {
if (range.length == 0) {
return nil;
}
// For multi-column layout, we'd need to create a more complex path
if (columns > 1) {
// Create column-based path
CGMutablePathRef path = CGPathCreateMutable();
CGFloat columnWidth = (rect.size.width - (columns - 1) * _config.columnGap) / columns;
for (NSUInteger i = 0; i < columns; i++) {
CGFloat x = rect.origin.x + i * (columnWidth + _config.columnGap);
CGRect columnRect = CGRectMake(x, rect.origin.y, columnWidth, rect.size.height);
CGPathAddRect(path, NULL, columnRect);
}
[self createFramesetter];
if (!_framesetter) {
CGPathRelease(path);
return nil;
}
[_layoutLock lock];
CTFrameRef ctFrame = CTFramesetterCreateFrame(
_framesetter,
CFRangeMake(range.location, range.length),
path,
NULL
);
CGPathRelease(path);
if (!ctFrame) {
[_layoutLock unlock];
return nil;
}
WRCoreTextLayoutFrame *layoutFrame = [[WRCoreTextLayoutFrame alloc] init];
layoutFrame.attributedString = _internalAttributedString;
[layoutFrame setCTFrame:ctFrame range:range];
CFRelease(ctFrame);
[_layoutLock unlock];
return layoutFrame;
}
// Single column - use the simpler typesetter path
return [self layoutFrameWithRange:range frame:rect];
}
#pragma mark - Page Background Images
/**
* Generates a page background image for a given text range.
*
* This method creates a background image that can include:
* - Theme-specific background textures
* - Decorative elements (borders, patterns)
* - Chapter-specific backgrounds (e.g., for chapter openings)
*
* The themeBgColor is used to tint the background to match the current theme.
*
* @param range The text range on the page
* @param themeBgColor The theme's background color
* @return A UIImage for the page background
*/
- (UIImage *)pageBackgroundImageAtRange:(NSRange)range
themeBgColor:(UIColor *)themeBgColor {
// Check cache first
NSString *cacheKey = [NSString stringWithFormat:@"bg_%lu_%lu_%@",
(unsigned long)range.location,
(unsigned long)range.length,
themeBgColor];
UIImage *cachedImage = [_imageCache objectForKey:cacheKey];
if (cachedImage) {
return cachedImage;
}
// Get the text in this range to check for special content
NSString *pageText = [_internalAttributedString.string substringWithRange:range];
// Determine the background type based on content
// Chapter openings might get special treatment
BOOL isChapterStart = (range.location == 0);
BOOL hasChapterTitle = [pageText containsString:@""] ||
[pageText containsString:@"Chapter"];
CGSize imageSize = _config ? CGSizeMake(_config.frameWidth, _config.frameHeight)
: CGSizeMake(320, 480);
UIGraphicsBeginImageContextWithOptions(imageSize, YES, [UIScreen mainScreen].scale);
CGContextRef context = UIGraphicsGetCurrentContext();
// Fill with theme background color
[themeBgColor setFill];
CGContextFillRect(context, CGRectMake(0, 0, imageSize.width, imageSize.height));
if (isChapterStart || hasChapterTitle) {
// Special background for chapter start pages
// Could include decorative borders, ornamental elements
[self drawChapterStartDecorationInContext:context size:imageSize color:themeBgColor];
} else {
// Regular page background
// Could include subtle patterns, margins, page numbers area
[self drawRegularPageDecorationInContext:context size:imageSize color:themeBgColor];
}
UIImage *result = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
// Cache the result
if (result) {
[_imageCache setObject:result forKey:cacheKey];
}
return result;
}
/**
* Draws decorative elements for chapter start pages.
* This includes ornamental borders and chapter-specific decorations.
*/
- (void)drawChapterStartDecorationInContext:(CGContextRef)context
size:(CGSize)size
color:(UIColor *)color {
// Draw a decorative border
CGRect borderRect = CGRectInset(CGRectMake(0, 0, size.width, size.height), 15, 20);
CGContextSetStrokeColorWithColor(context, [color colorWithAlphaComponent:0.3].CGColor);
CGContextSetLineWidth(context, 1.0);
CGContextStrokeRect(context, borderRect);
// Could add more ornamental elements:
// - Corner decorations
// - Header/footer ornaments
// - Drop cap indicators
}
/**
* Draws standard page decorations (margins, guides).
*/
- (void)drawRegularPageDecorationInContext:(CGContextRef)context
size:(CGSize)size
color:(UIColor *)color {
// Draw subtle margin guides
CGFloat leftMargin = _config ? _config.edgeInsets.left : 15;
CGFloat rightMargin = _config ? _config.edgeInsets.right : 15;
CGContextSetStrokeColorWithColor(context, [color colorWithAlphaComponent:0.1].CGColor);
CGContextSetLineWidth(context, 0.5);
// Left margin line
CGContextMoveToPoint(context, leftMargin, 0);
CGContextAddLineToPoint(context, leftMargin, size.height);
CGContextStrokePath(context);
// Right margin line
CGContextMoveToPoint(context, size.width - rightMargin, 0);
CGContextAddLineToPoint(context, size.width - rightMargin, size.height);
CGContextStrokePath(context);
}
#pragma mark - Image Utilities
/**
* Resizes an image for display within the text layout.
*
* This method handles various image sizing scenarios:
* - Full-width images
* - Half-width images (side by side)
* - Thumbnail-sized images
* - Dark mode adaptations
*
* The sizePattern parameter controls how the image is scaled:
* - "full": Scale to fill the available width
* - "half": Scale to half width
* - "third": Scale to one-third width
* - "quarter": Scale to one-quarter width
*
* @param imagePath Path to the image file or URL string
* @param rect Target rectangle for positioning
* @param position Position index (for multi-image layouts)
* @param sizePattern Size pattern string
* @param darkMode Whether to apply dark mode adjustments
* @param themeBgColor Theme background color for blending
* @return Resized UIImage
*/
- (UIImage *)resizedImageForImagePath:(NSString *)imagePath
rect:(CGRect)rect
position:(NSUInteger)position
sizePattern:(NSString *)sizePattern
darkMode:(BOOL)darkMode
themeBgColor:(UIColor *)themeBgColor {
// Generate cache key
NSString *cacheKey = [NSString stringWithFormat:@"img_%@_%@_%lu_%d",
imagePath, sizePattern, (unsigned long)position, darkMode];
UIImage *cachedImage = [_imageCache objectForKey:cacheKey];
if (cachedImage) {
return cachedImage;
}
// Load the original image
UIImage *originalImage = nil;
// Handle different image path formats
if ([imagePath hasPrefix:@"http://"] || [imagePath hasPrefix:@"https://"]) {
// Remote image - would need async loading
// For now, return placeholder
originalImage = [UIImage imageNamed:@"placeholder_book_image"];
} else if ([imagePath hasPrefix:@"/"]) {
// Absolute file path
originalImage = [UIImage imageWithContentsOfFile:imagePath];
} else {
// Bundle resource
originalImage = [UIImage imageNamed:imagePath];
}
if (!originalImage) {
// Return a placeholder image
return [self placeholderImageForSize:rect.size];
}
// Calculate target size based on size pattern
CGSize targetSize = [self targetSizeForPattern:sizePattern
rect:rect
imageSize:originalImage.size];
// Handle dark mode
if (darkMode) {
originalImage = [self darkModeAdjustedImage:originalImage
withBgColor:themeBgColor];
}
// Resize the image
UIImage *resizedImage = [self resizeImage:originalImage toSize:targetSize];
// Cache the result
if (resizedImage) {
[_imageCache setObject:resizedImage forKey:cacheKey];
}
return resizedImage;
}
/**
* Calculates the target size based on a size pattern string.
*/
- (CGSize)targetSizeForPattern:(NSString *)pattern
rect:(CGRect)rect
imageSize:(CGSize)imageSize {
CGFloat availableWidth = rect.size.width;
if ([pattern isEqualToString:kSizePatternFull]) {
// Full width - maintain aspect ratio
CGFloat scale = availableWidth / imageSize.width;
return CGSizeMake(availableWidth, imageSize.height * scale);
} else if ([pattern isEqualToString:kSizePatternHalf]) {
// Half width
CGFloat halfWidth = availableWidth / 2.0;
CGFloat scale = halfWidth / imageSize.width;
return CGSizeMake(halfWidth, imageSize.height * scale);
} else if ([pattern isEqualToString:kSizePatternThird]) {
// One-third width
CGFloat thirdWidth = availableWidth / 3.0;
CGFloat scale = thirdWidth / imageSize.width;
return CGSizeMake(thirdWidth, imageSize.height * scale);
} else if ([pattern isEqualToString:kSizePatternQuarter]) {
// One-quarter width
CGFloat quarterWidth = availableWidth / 4.0;
CGFloat scale = quarterWidth / imageSize.width;
return CGSizeMake(quarterWidth, imageSize.height * scale);
}
// Default: fit within the rect while maintaining aspect ratio
return [self fitSize:imageSize inSize:rect.size];
}
/**
* Resizes an image to the target size using high-quality interpolation.
*/
- (UIImage *)resizeImage:(UIImage *)image toSize:(CGSize)size {
UIGraphicsBeginImageContextWithOptions(size, YES, [UIScreen mainScreen].scale);
[image drawInRect:CGRectMake(0, 0, size.width, size.height)];
UIImage *result = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return result;
}
/**
* Adjusts an image for dark mode by blending with the background color.
*/
- (UIImage *)darkModeAdjustedImage:(UIImage *)image withBgColor:(UIColor *)bgColor {
CGSize size = image.size;
UIGraphicsBeginImageContextWithOptions(size, YES, [UIScreen mainScreen].scale);
// Draw background
[bgColor setFill];
CGContextFillRect(UIGraphicsGetCurrentContext(), CGRectMake(0, 0, size.width, size.height));
// Draw image with reduced opacity for dark mode blending
[image drawInRect:CGRectMake(0, 0, size.width, size.height)
blendMode:kCGBlendModeNormal
alpha:0.85];
UIImage *result = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return result;
}
/**
* Calculates the size that fits within a container while maintaining aspect ratio.
*/
- (CGSize)fitSize:(CGSize)imageSize inSize:(CGSize)containerSize {
CGFloat widthRatio = containerSize.width / imageSize.width;
CGFloat heightRatio = containerSize.height / imageSize.height;
CGFloat scale = MIN(widthRatio, heightRatio);
return CGSizeMake(imageSize.width * scale, imageSize.height * scale);
}
/**
* Creates a placeholder image for missing images.
*/
- (UIImage *)placeholderImageForSize:(CGSize)size {
UIGraphicsBeginImageContextWithOptions(size, YES, [UIScreen mainScreen].scale);
// Light gray background
[[UIColor colorWithWhite:0.9 alpha:1.0] setFill];
CGContextFillRect(UIGraphicsGetCurrentContext(), CGRectMake(0, 0, size.width, size.height));
// Draw a simple icon indicator
[[UIColor colorWithWhite:0.7 alpha:1.0] setFill];
CGFloat iconSize = MIN(size.width, size.height) * 0.3;
CGFloat x = (size.width - iconSize) / 2;
CGFloat y = (size.height - iconSize) / 2;
CGContextFillRect(UIGraphicsGetCurrentContext(), CGRectMake(x, y, iconSize, iconSize));
UIImage *result = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return result;
}
#pragma mark - Pagination Helpers
/**
* Calculates the total number of pages for a given page size.
*
* Uses CTTypesetterSuggestLineBreak to simulate pagination
* without actually creating layout frame objects.
*/
- (NSUInteger)numberOfPagesForPageSize:(CGSize)pageSize {
[self createTypesetter];
if (!_typesetter || !_internalAttributedString) {
return 0;
}
[_layoutLock lock];
NSUInteger pageCount = 0;
NSUInteger totalLength = [_internalAttributedString length];
NSUInteger currentIndex = 0;
UIEdgeInsets insets = _config ? _config.edgeInsets : UIEdgeInsetsMake(10, 15, 10, 15);
CGFloat usableWidth = pageSize.width - insets.left - insets.right;
while (currentIndex < totalLength) {
CFIndex lineBreakIndex = CTTypesetterSuggestLineBreak(
_typesetter,
currentIndex,
usableWidth
);
if (lineBreakIndex <= 0) {
break;
}
pageCount++;
currentIndex += lineBreakIndex;
}
[_layoutLock unlock];
return pageCount;
}
/**
* Returns the text range for a specific page index.
*/
- (NSRange)rangeForPageAtIndex:(NSUInteger)pageIndex
pageSize:(CGSize)pageSize {
[self createTypesetter];
if (!_typesetter || !_internalAttributedString) {
return NSMakeRange(0, 0);
}
[_layoutLock lock];
NSUInteger totalLength = [_internalAttributedString length];
NSUInteger currentIndex = 0;
NSUInteger currentPage = 0;
UIEdgeInsets insets = _config ? _config.edgeInsets : UIEdgeInsetsMake(10, 15, 10, 15);
CGFloat usableWidth = pageSize.width - insets.left - insets.right;
while (currentIndex < totalLength && currentPage <= pageIndex) {
CFIndex lineBreakIndex = CTTypesetterSuggestLineBreak(
_typesetter,
currentIndex,
usableWidth
);
if (lineBreakIndex <= 0) {
break;
}
if (currentPage == pageIndex) {
[_layoutLock unlock];
return NSMakeRange(currentIndex, lineBreakIndex);
}
currentIndex += lineBreakIndex;
currentPage++;
}
[_layoutLock unlock];
return NSMakeRange(0, 0);
}
/**
* Returns suggested line fragment heights for precise layout calculations.
*
* This method analyzes the attributed string and returns the heights
* of each line as CoreText would lay them out. This is used for:
* - Precise pagination calculations
* - Avoiding orphans and widows
* - Ensuring consistent line spacing
*/
- (NSArray<NSNumber *> *)suggestedLineFragHeights {
[self createTypesetter];
if (!_typesetter || !_internalAttributedString) {
return @[];
}
[_layoutLock lock];
NSMutableArray<NSNumber *> *heights = [NSMutableArray array];
NSUInteger totalLength = [_internalAttributedString length];
NSUInteger currentIndex = 0;
// Use a large width to get all lines in a single column
CGFloat width = _config ? _config.frameWidth : 320.0;
while (currentIndex < totalLength) {
CFIndex lineBreakIndex = CTTypesetterSuggestLineBreak(
_typesetter,
currentIndex,
width
);
if (lineBreakIndex <= 0) {
break;
}
// Create a temporary CTLine to measure its height
CTLineRef line = CTTypesetterCreateLine(
_typesetter,
CFRangeMake(currentIndex, lineBreakIndex)
);
if (line) {
CGFloat ascent, descent, leading;
CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
CGFloat lineHeight = ascent + descent + leading;
[heights addObject:@(lineHeight)];
CFRelease(line);
}
currentIndex += lineBreakIndex;
}
[_layoutLock unlock];
return [heights copy];
}
@end
+111
View File
@@ -0,0 +1,111 @@
//
// WREpubParser.h
// WeRead (微信读书)
// Reverse-engineered header
//
// EPUB file parser. Parses OPF (content.opf), NCX (toc.ncx), and XHTML chapter files.
// Resolves EPUB structure: container.xml -> content.opf -> spine -> chapters.
// Returns chapter list and resource mapping.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class WRBook;
@class WHAlbumInfo;
// ---------------------------------------------------------------------------
// Error domain and codes for EPUB parsing failures
// ---------------------------------------------------------------------------
extern NSString *const WREpubParserErrorDomain;
typedef NS_ENUM(NSInteger, WREpubParserErrorCode) {
WREpubParserErrorFileNotFound = -1000,
WREpubParserErrorContainerParseFail = -1001,
WREpubParserErrorOPFParseFail = -1002,
WREpubParserErrorNCXParseFail = -1003,
WREpubParserErrorSpineEmpty = -1004,
WREpubParserErrorChapterLoadFail = -1005,
WREpubParserErrorDecryptionFail = -1006,
};
// ---------------------------------------------------------------------------
// WREpubParserDelegate
// ---------------------------------------------------------------------------
@protocol WREpubParserDelegate <NSObject>
@optional
/// Called when the EPUB controller encounters a fatal parsing error.
/// The parser invokes this on the delegate (typically a UIViewController)
/// so the UI layer can present the error to the user.
- (void)epubController:(id)controller didFailWithError:(NSError *)error;
@end
// ---------------------------------------------------------------------------
// WREpubParser
// ---------------------------------------------------------------------------
@interface WREpubParser : NSObject
// --- Ivars (from binary analysis) ---
// {
// NSString *_epubFilePath; // path to the .epub file on disk
// NSString *_baseDirectory; // extracted root directory
// NSError *_lastError; // most recent parse error
// UIViewController *_epubController; // weak ref to presenting controller
// WRBook *_book; // associated book model
// WHAlbumInfo *_albumInfo; // album / collection metadata
// }
@property (nonatomic, copy, readonly) NSString *epubFilePath;
@property (nonatomic, copy, readonly) NSString *baseDirectory;
@property (nonatomic, strong, readonly, nullable) NSError *lastError;
@property (nonatomic, weak, nullable) id<WREpubParserDelegate> delegate;
@property (nonatomic, strong, readonly, nullable) WRBook *book;
@property (nonatomic, strong, readonly, nullable) WHAlbumInfo *albumInfo;
/// Chapters parsed from the spine, in reading order.
@property (nonatomic, strong, readonly) NSArray<NSDictionary *> *chapters;
/// Resource map: relative path -> absolute path for images, CSS, fonts, etc.
@property (nonatomic, strong, readonly) NSDictionary<NSString *, NSString *> *resourceMap;
/// Ordered list of spine item IDs (for navigation).
@property (nonatomic, strong, readonly) NSArray<NSString *> *spineItemIDs;
#pragma mark - Initialization
- (instancetype)initWithFilePath:(NSString *)path
book:(nullable WRBook *)book;
#pragma mark - Parsing
/// Parse the EPUB archive. Returns YES on success.
- (BOOL)parse:(NSError *_Nullable *_Nullable)error;
/// Parse container.xml and return the path to the OPF file.
- (nullable NSString *)parseContainerXML:(NSError *_Nullable *_Nullable)error;
/// Parse content.opf and populate chapters + resourceMap.
- (BOOL)parseOPFAtRelativePath:(NSString *)opfRelPath
error:(NSError *_Nullable *_Nullable)error;
/// Parse toc.ncx and return the table-of-contents tree.
- (nullable NSArray *)parseNCX:(NSError *_Nullable *_Nullable)error;
/// Read and return the XHTML content of a single chapter.
- (nullable NSString *)contentForChapterAtIndex:(NSUInteger)index
error:(NSError *_Nullable *_Nullable)error;
/// Resolve a relative resource path to an absolute file path.
- (nullable NSString *)absolutePathForResource:(NSString *)relativePath;
#pragma mark - Delegate callback (internal)
- (void)notifyDelegateOfError:(NSError *)error;
@end
NS_ASSUME_NONNULL_END
+607
View File
@@ -0,0 +1,607 @@
//
// WREpubParser.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// Detailed pseudo-code based on binary analysis, ivar types, known methods,
// and contextual knowledge of EPUB file format handling.
//
#import "WREpubParser.h"
#import "WRBook.h"
#import "WHAlbumInfo.h"
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
NSString *const WREpubParserErrorDomain = @"WREpubParserErrorDomain";
static NSString *const kContainerXMLPath = @"META-INF/container.xml";
static NSString *const kOPFMIMEType = @"application/oebps-package+xml";
static NSString *const kNCXMIMEType = @"application/x-dtbncx+xml";
#pragma mark - Private Interface
@interface WREpubParser ()
@property (nonatomic, copy, readwrite) NSString *epubFilePath;
@property (nonatomic, copy, readwrite) NSString *baseDirectory;
@property (nonatomic, strong, readwrite, nullable) NSError *lastError;
@property (nonatomic, strong, readwrite) NSArray<NSDictionary *> *chapters;
@property (nonatomic, strong, readwrite) NSDictionary<NSString *, NSString *> *resourceMap;
@property (nonatomic, strong, readwrite) NSArray<NSString *> *spineItemIDs;
@end
#pragma mark - Implementation
@implementation WREpubParser
{
// Ivars confirmed from binary analysis:
NSString *_epubFilePath;
NSString *_baseDirectory;
NSError *_lastError;
UIViewController *_epubController; // weak, assigned from delegate
WRBook *_book;
WHAlbumInfo *_albumInfo;
// Internal caches (not exported in header but inferred):
NSMutableDictionary<NSString *, NSString *> *_manifestMap; // id -> href
NSMutableDictionary<NSString *, NSString *> *_mediaTypeMap; // id -> media-type
NSMutableArray<NSString *> *_spineRefs; // idref list
}
#pragma mark - Lifecycle
- (instancetype)initWithFilePath:(NSString *)path
book:(nullable WRBook *)book
{
self = [super init];
if (self) {
_epubFilePath = [path copy];
_book = book;
_manifestMap = [NSMutableDictionary dictionary];
_mediaTypeMap = [NSMutableDictionary dictionary];
_spineRefs = [NSMutableArray array];
_chapters = @[];
_resourceMap = @{};
_spineItemIDs = @[];
}
return self;
}
#pragma mark - Public: Parsing
- (BOOL)parse:(NSError *_Nullable *_Nullable)error
{
// 1. Locate the OPF path from container.xml
NSError *containerError = nil;
NSString *opfRelPath = [self parseContainerXML:&containerError];
if (!opfRelPath) {
[self _setError:error
code:WREpubParserErrorContainerParseFail
description:@"Failed to parse container.xml"
underlyingError:containerError];
return NO;
}
// 2. Parse the OPF to populate manifest, spine, and metadata
NSError *opfError = nil;
if (![self parseOPFAtRelativePath:opfRelPath error:&opfError]) {
[self _setError:error
code:WREpubParserErrorOPFParseFail
description:@"Failed to parse content.opf"
underlyingError:opfError];
return NO;
}
// 3. Optionally parse NCX for table of contents
NSError *ncxError = nil;
[self parseNCX:&ncxError];
// NCX failure is non-fatal; log but continue
if (ncxError) {
NSLog(@"[WREpubParser] NCX parse warning: %@", ncxError);
}
// 4. Build the chapter list from the spine
[self _buildChapterList];
// 5. Build the resource map from the manifest
[self _buildResourceMap];
if (self.chapters.count == 0) {
[self _setError:error
code:WREpubParserErrorSpineEmpty
description:@"Spine contains no items"
underlyingError:nil];
return NO;
}
return YES;
}
// ---------------------------------------------------------------------------
// Parse META-INF/container.xml
// ---------------------------------------------------------------------------
- (nullable NSString *)parseContainerXML:(NSError *_Nullable *_Nullable)error
{
NSString *containerPath =
[self.epubFilePath stringByAppendingPathComponent:kContainerXMLPath];
// container.xml is always plain text (unencrypted)
NSData *data = [NSData dataWithContentsOfFile:containerPath options:0 error:error];
if (!data) return nil;
// Use NSXMLParser to extract the rootfile full-path
//
// Expected structure:
// <container>
// <rootfiles>
// <rootfile full-path="OEBPS/content.opf" media-type="..."/>
// </rootfiles>
// </container>
NSXMLParser *parser = [[NSXMLParser alloc] initWithData:data];
WREpubContainerParserDelegate *delegate =
[[WREpubContainerParserDelegate alloc] init];
parser.delegate = delegate;
if (![parser parse]) {
if (error) *error = parser.parserError;
return nil;
}
return delegate.rootFilePath;
}
// ---------------------------------------------------------------------------
// Parse the OPF (content.opf) file
// ---------------------------------------------------------------------------
- (BOOL)parseOPFAtRelativePath:(NSString *)opfRelPath
error:(NSError *_Nullable *_Nullable)error
{
NSString *opfFullPath =
[self.epubFilePath stringByAppendingPathComponent:opfRelPath];
// Set the base directory for resolving relative paths within the OPF
_baseDirectory = [opfFullPath stringByDeletingLastPathComponent];
NSData *data = [NSData dataWithContentsOfFile:opfFullPath options:0 error:error];
if (!data) return NO;
// Use NSXMLParser to walk the OPF XML
//
// Sections to parse:
// <manifest> -> populate _manifestMap and _mediaTypeMap
// <spine> -> populate _spineRefs (ordered idref list)
// <metadata> -> extract title, identifier, etc. for WRBook
WREpubOPFParserDelegate *opfDelegate =
[[WREpubOPFParserDelegate alloc] initWithBaseDirectory:_baseDirectory];
NSXMLParser *parser = [[NSXMLParser alloc] initWithData:data];
parser.delegate = opfDelegate;
if (![parser parse]) {
if (error) *error = parser.parserError;
return NO;
}
// Transfer parsed data
[_manifestMap setDictionary:opfDelegate.manifestItems];
[_mediaTypeMap setDictionary:opfDelegate.mediaTypes];
[_spineRefs setArray:opfDelegate.spineItemRefs];
// Extract metadata into _book if available
if (opfDelegate.bookTitle) {
_book.title = opfDelegate.bookTitle;
}
return YES;
}
// ---------------------------------------------------------------------------
// Parse toc.ncx for table of contents
// ---------------------------------------------------------------------------
- (nullable NSArray *)parseNCX:(NSError *_Nullable *_Nullable)error
{
// Find the NCX item in the manifest (media-type = application/x-dtbncx+xml)
NSString *ncxID = nil;
for (NSString *itemID in _mediaTypeMap) {
if ([_mediaTypeMap[itemID] isEqualToString:kNCXMIMEType]) {
ncxID = itemID;
break;
}
}
if (!ncxID) {
// Some EPUBs use nav.xhtml instead of NCX (EPUB3)
// Try finding nav document
return nil;
}
NSString *ncxRelPath = _manifestMap[ncxID];
if (!ncxRelPath) return nil;
NSString *ncxFullPath =
[_baseDirectory stringByAppendingPathComponent:ncxRelPath];
NSData *data = [NSData dataWithContentsOfFile:ncxFullPath options:0 error:error];
if (!data) return nil;
// Parse NCX XML:
// <ncx>
// <navMap>
// <navPoint id="..." playOrder="1">
// <navLabel><text>Chapter 1</text></navLabel>
// <content src="chapter1.xhtml"/>
// <navPoint> ... nested ... </navPoint>
// </navPoint>
// </navMap>
// </ncx>
WREpubNCXParserDelegate *ncxDelegate = [[WREpubNCXParserDelegate alloc] init];
NSXMLParser *parser = [[NSXMLParser alloc] initWithData:data];
parser.delegate = ncxDelegate;
if (![parser parse]) {
if (error) *error = parser.parserError;
return nil;
}
return ncxDelegate.tocEntries;
}
// ---------------------------------------------------------------------------
// Return chapter content (XHTML) for a given index
// ---------------------------------------------------------------------------
- (nullable NSString *)contentForChapterAtIndex:(NSUInteger)index
error:(NSError *_Nullable *_Nullable)error
{
if (index >= self.chapters.count) return nil;
NSDictionary *chapterInfo = self.chapters[index];
NSString *href = chapterInfo[@"href"];
if (!href) return nil;
NSString *fullPath =
[_baseDirectory stringByAppendingPathComponent:href];
// If the file is encrypted, it must be decrypted first via WREncryptedFileManager
NSData *data = [NSData dataWithContentsOfFile:fullPath options:0 error:error];
if (!data) return nil;
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
}
// ---------------------------------------------------------------------------
// Resolve a relative resource path
// ---------------------------------------------------------------------------
- (nullable NSString *)absolutePathForResource:(NSString *)relativePath
{
if (!relativePath) return nil;
// Try the resource map first
NSString *mapped = self.resourceMap[relativePath];
if (mapped) return mapped;
// Fallback: resolve relative to the base directory
return [_baseDirectory stringByAppendingPathComponent:relativePath];
}
// ---------------------------------------------------------------------------
// Delegate callback
// ---------------------------------------------------------------------------
- (void)notifyDelegateOfError:(NSError *)error
{
_lastError = error;
// The delegate protocol method uses epubController:didFailWithError:
// The "controller" here is the UIViewController that owns the reader.
if ([self.delegate respondsToSelector:@selector(epubController:didFailWithError:)]) {
[self.delegate epubController:_epubController didFailWithError:error];
}
}
#pragma mark - Private Helpers
/// Build the chapters array from spine references + manifest.
- (void)_buildChapterList
{
NSMutableArray *chapters = [NSMutableArray arrayWithCapacity:_spineRefs.count];
for (NSString *idref in _spineRefs) {
NSString *href = _manifestMap[idref];
NSString *mediaType = _mediaTypeMap[idref];
if (!href) continue;
NSMutableDictionary *chapterInfo = [NSMutableDictionary dictionary];
chapterInfo[@"id"] = idref;
chapterInfo[@"href"] = href;
chapterInfo[@"mediaType"] = mediaType ?: @"application/xhtml+xml";
chapterInfo[@"fullPath"] =
[_baseDirectory stringByAppendingPathComponent:href];
[chapters addObject:[chapterInfo copy]];
}
_chapters = [chapters copy];
}
/// Build a flat resource map for images, CSS, fonts, etc.
- (void)_buildResourceMap
{
NSMutableDictionary *map = [NSMutableDictionary dictionary];
for (NSString *itemID in _manifestMap) {
NSString *href = _manifestMap[itemID];
if (!href) continue;
NSString *absPath =
[_baseDirectory stringByAppendingPathComponent:href];
map[href] = absPath;
// Also index by filename for convenience
NSString *filename = [href lastPathComponent];
if (filename) {
map[filename] = absPath;
}
}
_resourceMap = [map copy];
}
/// Helper to set the error pointer and store lastError.
- (void)_setError:(NSError *_Nullable *_Nullable)outError
code:(WREpubParserErrorCode)code
description:(NSString *)description
underlyingError:(nullable NSError *)underlyingError
{
NSMutableDictionary *userInfo = [NSMutableDictionary dictionary];
userInfo[NSLocalizedDescriptionKey] = description;
if (underlyingError) {
userInfo[NSUnderlyingErrorKey] = underlyingError;
}
NSError *err = [NSError errorWithDomain:WREpubParserErrorDomain
code:code
userInfo:userInfo];
_lastError = err;
if (outError) *outError = err;
}
@end
// ===========================================================================
// Internal XML Parser Delegates (file-private)
// ===========================================================================
#pragma mark - Container Parser Delegate
/// Parses META-INF/container.xml to extract the rootfile path.
@interface WREpubContainerParserDelegate : NSObject <NSXMLParserDelegate>
@property (nonatomic, copy, nullable) NSString *rootFilePath;
@end
@implementation WREpubContainerParserDelegate
{
BOOL _insideRootfile;
}
- (void)parser:(NSXMLParser *)parser
didStartElement:(NSString *)elementName
namespaceURI:(NSString *)namespaceURI
qualifiedName:(NSString *)qName
attributes:(NSDictionary<NSString *,NSString *> *)attributeDict
{
if ([elementName isEqualToString:@"rootfile"]) {
_rootFilePath = attributeDict[@"full-path"];
_insideRootfile = YES;
}
}
- (void)parser:(NSXMLParser *)parser
didEndElement:(NSString *)elementName
namespaceURI:(NSString *)namespaceURI
qualifiedName:(NSString *)qName
{
if ([elementName isEqualToString:@"rootfile"]) {
_insideRootfile = NO;
}
}
@end
#pragma mark - OPF Parser Delegate
/// Parses the OPF file: manifest, spine, and metadata sections.
@interface WREpubOPFParserDelegate : NSObject <NSXMLParserDelegate>
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSString *> *manifestItems;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSString *> *mediaTypes;
@property (nonatomic, strong) NSMutableArray<NSString *> *spineItemRefs;
@property (nonatomic, copy, nullable) NSString *bookTitle;
- (instancetype)initWithBaseDirectory:(NSString *)baseDir;
@end
@implementation WREpubOPFParserDelegate
{
NSString *_baseDirectory;
BOOL _inMetadata;
BOOL _inManifest;
BOOL _inSpine;
NSMutableString *_currentText;
}
- (instancetype)initWithBaseDirectory:(NSString *)baseDir
{
self = [super init];
if (self) {
_baseDirectory = baseDir;
_manifestItems = [NSMutableDictionary dictionary];
_mediaTypes = [NSMutableDictionary dictionary];
_spineItemRefs = [NSMutableArray array];
}
return self;
}
- (void)parser:(NSXMLParser *)parser
didStartElement:(NSString *)elementName
namespaceURI:(NSString *)namespaceURI
qualifiedName:(NSString *)qName
attributes:(NSDictionary<NSString *,NSString *> *)attributeDict
{
if ([elementName isEqualToString:@"metadata"]) {
_inMetadata = YES;
} else if ([elementName isEqualToString:@"manifest"]) {
_inManifest = YES;
} else if ([elementName isEqualToString:@"spine"]) {
_inSpine = YES;
}
if (_inManifest && [elementName isEqualToString:@"item"]) {
NSString *itemId = attributeDict[@"id"];
NSString *href = attributeDict[@"href"];
NSString *mediaType = attributeDict[@"media-type"];
if (itemId && href) {
_manifestItems[itemId] = href;
if (mediaType) {
_mediaTypes[itemId] = mediaType;
}
}
}
if (_inSpine && [elementName isEqualToString:@"itemref"]) {
NSString *idref = attributeDict[@"idref"];
if (idref) {
[_spineItemRefs addObject:idref];
}
}
_currentText = [NSMutableString string];
}
- (void)parser:(NSXMLParser *)parser
foundCharacters:(NSString *)string
{
[_currentText appendString:string];
}
- (void)parser:(NSXMLParser *)parser
didEndElement:(NSString *)elementName
namespaceURI:(NSString *)namespaceURI
qualifiedName:(NSString *)qName
{
if ([elementName isEqualToString:@"metadata"]) {
_inMetadata = NO;
} else if ([elementName isEqualToString:@"manifest"]) {
_inManifest = NO;
} else if ([elementName isEqualToString:@"spine"]) {
_inSpine = NO;
}
// Extract <dc:title> from metadata
if (_inMetadata && [elementName isEqualToString:@"dc:title"]) {
_bookTitle = [_currentText stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
}
_currentText = nil;
}
@end
#pragma mark - NCX Parser Delegate
/// Parses toc.ncx to extract the table of contents tree.
@interface WREpubNCXParserDelegate : NSObject <NSXMLParserDelegate>
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *tocEntries;
@end
@implementation WREpubNCXParserDelegate
{
BOOL _inNavPoint;
BOOL _inNavLabel;
BOOL _inContent;
BOOL _inText;
NSString *_currentNavPointId;
NSString *_currentLabel;
NSString *_currentSrc;
NSMutableString *_currentText;
NSUInteger _playOrder;
}
- (instancetype)init
{
self = [super init];
if (self) {
_tocEntries = [NSMutableArray array];
}
return self;
}
- (void)parser:(NSXMLParser *)parser
didStartElement:(NSString *)elementName
namespaceURI:(NSString *)namespaceURI
qualifiedName:(NSString *)qName
attributes:(NSDictionary<NSString *,NSString *> *)attributeDict
{
_currentText = [NSMutableString string];
if ([elementName isEqualToString:@"navPoint"]) {
_inNavPoint = YES;
_currentNavPointId = attributeDict[@"id"];
_playOrder = [attributeDict[@"playOrder"] integerValue];
} else if ([elementName isEqualToString:@"navLabel"]) {
_inNavLabel = YES;
} else if ([elementName isEqualToString:@"text"]) {
_inText = YES;
} else if ([elementName isEqualToString:@"content"]) {
_inContent = YES;
_currentSrc = attributeDict[@"src"];
}
}
- (void)parser:(NSXMLParser *)parser
foundCharacters:(NSString *)string
{
[_currentText appendString:string];
}
- (void)parser:(NSXMLParser *)parser
didEndElement:(NSString *)elementName
namespaceURI:(NSString *)namespaceURI
qualifiedName:(NSString *)qName
{
if ([elementName isEqualToString:@"text"] && _inText) {
_currentLabel = [_currentText stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
_inText = NO;
} else if ([elementName isEqualToString:@"content"]) {
_inContent = NO;
} else if ([elementName isEqualToString:@"navLabel"]) {
_inNavLabel = NO;
} else if ([elementName isEqualToString:@"navPoint"]) {
_inNavPoint = NO;
if (_currentLabel && _currentSrc) {
NSDictionary *entry = @{
@"id" : _currentNavPointId ?: @"",
@"label" : _currentLabel,
@"src" : _currentSrc,
@"playOrder" : @(_playOrder)
};
[_tocEntries addObject:entry];
}
_currentNavPointId = nil;
_currentLabel = nil;
_currentSrc = nil;
}
_currentText = nil;
}
@end
@@ -0,0 +1,98 @@
//
// WREpubPositionConverter.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Position converter between file positions and character positions.
// Used for bookmark synchronization and reading progress tracking.
// Maps (fileIndex, row, column) triples to character offsets in the
// concatenated text of the book.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@class NSAttributedString;
// ---------------------------------------------------------------------------
// Position pair structure used in row/column-based lookups
// ---------------------------------------------------------------------------
@interface WRPositionPair : NSObject
@property (nonatomic) NSInteger row;
@property (nonatomic) NSInteger column;
@end
// ---------------------------------------------------------------------------
// WREpubPositionConverter
// ---------------------------------------------------------------------------
@interface WREpubPositionConverter : NSObject
// --- Ivars (from binary analysis) ---
// {
// NSArray *_filePaths; // paths to chapter XHTML files
// NSArray *_attributedStrings; // parsed attributed strings per file
// NSMutableArray *_fileLengths; // character count per file
// NSMutableArray *_cumulativeOffsets; // cumulative character offsets
// NSMutableDictionary *_indexCache; // cached index lookups
// NSMutableDictionary *_stringRangeCache; // cached range lookups
// ... more ...
// }
@property (nonatomic, strong, readonly) NSArray<NSString *> *filePaths;
@property (nonatomic, strong, readonly) NSArray<NSAttributedString *> *attributedStrings;
#pragma mark - Initialization
/// Initialize with file paths and their corresponding attributed strings.
/// @param filePaths Array of chapter file paths (in spine order).
/// @param attributedStrings Array of NSAttributedString for each file.
/// @param offset Base character offset (e.g., for non-chapter content).
/// @param isContainIntroFlyleaf YES if the flyleaf/cover page is included.
- (instancetype)initWithFilePaths:(NSArray<NSString *> *)filePaths
attributedStrings:(NSArray<NSAttributedString *> *)attributedStrings
offset:(NSInteger)offset
isContainIntroFlyleaf:(BOOL)isContainIntroFlyleaf;
#pragma mark - Index Building
/// Build internal index tables for fast position lookup.
/// Must be called before performing conversions.
- (void)initIndices;
#pragma mark - Position Conversion
/// Convert row/column pairs to string indices within a specific file.
/// @param filePath The chapter file path.
/// @param rowColumnPairs Array of WRPositionPair objects.
/// @param stringIndices (out) Array of NSNumber (NSInteger) with resolved indices.
/// @param string The full text string of the file.
/// @param fileIndexOffset Offset to add to the file index.
/// @param stringIndexOffset Offset to add to the resulting string index.
- (void)indicesInFile:(NSString *)filePath
forRowColumnPairs:(NSArray<WRPositionPair *> *)rowColumnPairs
stringIndices:(NSArray *_Nullable *_Nullable)stringIndices
string:(NSString *)string
fileIndexOffset:(NSInteger)fileIndexOffset
stringIndexOffset:(NSInteger)stringIndexOffset;
/// Convert a file-based range (fileIndex, startOffset, endOffset)
/// to a character range in the concatenated book string.
/// @return NSRange in the global string, or NSNotFound if invalid.
- (NSRange)stringRangeFromFileRange:(NSDictionary *)fileRange;
#pragma mark - Utility
/// Return the total character count across all files.
- (NSInteger)totalCharacterCount;
/// Return the file index that contains the given global character position.
- (NSInteger)fileIndexForCharacterPosition:(NSInteger)position;
/// Return the local character offset within a file for a global position.
- (NSInteger)localOffsetInFileAtIndex:(NSInteger)fileIndex
forGlobalPosition:(NSInteger)position;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,326 @@
//
// WREpubPositionConverter.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// Detailed pseudo-code based on binary analysis, ivar types, known methods,
// and contextual knowledge of EPUB position/bookmark handling.
//
#import "WREpubPositionConverter.h"
// ---------------------------------------------------------------------------
// WRPositionPair
// ---------------------------------------------------------------------------
@implementation WRPositionPair
- (instancetype)initWithRow:(NSInteger)row column:(NSInteger)column
{
self = [super init];
if (self) {
_row = row;
_column = column;
}
return self;
}
- (NSString *)description
{
return [NSString stringWithFormat:@"<WRPositionPair row=%ld col=%ld>",
(long)_row, (long)_column];
}
@end
// ---------------------------------------------------------------------------
// WREpubPositionConverter
// ---------------------------------------------------------------------------
@implementation WREpubPositionConverter
{
// Core data from binary analysis
NSArray *_filePaths;
NSArray *_attributedStrings;
NSMutableArray *_fileLengths; // character count per file
NSMutableArray *_cumulativeOffsets; // prefix sum of character counts
NSMutableDictionary *_indexCache; // cache: key -> index
NSMutableDictionary *_stringRangeCache; // cache: key -> NSRange
// Additional state inferred from context
NSInteger _baseOffset; // offset for non-chapter content
BOOL _containsIntroFlyleaf; // includes cover/flyleaf page
BOOL _indicesBuilt; // whether initIndices has been called
}
#pragma mark - Lifecycle
- (instancetype)initWithFilePaths:(NSArray<NSString *> *)filePaths
attributedStrings:(NSArray<NSAttributedString *> *)attributedStrings
offset:(NSInteger)offset
isContainIntroFlyleaf:(BOOL)isContainIntroFlyleaf
{
self = [super init];
if (self) {
_filePaths = [filePaths copy];
_attributedStrings = [attributedStrings copy];
_baseOffset = offset;
_containsIntroFlyleaf = isContainIntroFlyleaf;
_fileLengths = [NSMutableArray arrayWithCapacity:filePaths.count];
_cumulativeOffsets = [NSMutableArray arrayWithCapacity:filePaths.count];
_indexCache = [NSMutableDictionary dictionary];
_stringRangeCache = [NSMutableDictionary dictionary];
_indicesBuilt = NO;
}
return self;
}
#pragma mark - Index Building
- (void)initIndices
{
if (_indicesBuilt) return;
[_fileLengths removeAllObjects];
[_cumulativeOffsets removeAllObjects];
NSInteger cumulative = _baseOffset;
for (NSUInteger i = 0; i < _attributedStrings.count; i++) {
NSAttributedString *attrStr = _attributedStrings[i];
NSInteger length = (NSInteger)attrStr.string.length;
[_fileLengths addObject:@(length)];
// Cumulative offset = sum of lengths of all previous files + baseOffset
[_cumulativeOffsets addObject:@(cumulative)];
cumulative += length;
}
_indicesBuilt = YES;
}
#pragma mark - Position Conversion: Row/Column -> String Index
- (void)indicesInFile:(NSString *)filePath
forRowColumnPairs:(NSArray<WRPositionPair *> *)rowColumnPairs
stringIndices:(NSArray *_Nullable *_Nullable)stringIndices
string:(NSString *)string
fileIndexOffset:(NSInteger)fileIndexOffset
stringIndexOffset:(NSInteger)stringIndexOffset
{
// 1. Find the file index for the given path
NSInteger fileIndex = [_filePaths indexOfObject:filePath];
if (fileIndex == NSNotFound) {
// Try matching by last path component (filename only)
NSString *filename = [filePath lastPathComponent];
for (NSUInteger i = 0; i < _filePaths.count; i++) {
if ([[_filePaths[i] lastPathComponent] isEqualToString:filename]) {
fileIndex = (NSInteger)i;
break;
}
}
}
if (fileIndex == NSNotFound) {
if (stringIndices) *stringIndices = @[];
return;
}
fileIndex += fileIndexOffset;
// 2. Build a line-offset table from the string
//
// We need to map (row, column) -> character offset within the string.
// A "row" is a line number; "column" is the character position in that line.
//
// Strategy: scan the string and record the starting offset of each line.
NSMutableArray<NSNumber *> *lineStarts = [NSMutableArray array];
[lineStarts addObject:@(0)]; // line 0 starts at offset 0
NSUInteger len = string.length;
for (NSUInteger i = 0; i < len; i++) {
unichar c = [string characterAtIndex:i];
if (c == '\n' || c == '\r') {
// Handle \r\n as a single line ending
if (c == '\r' && i + 1 < len && [string characterAtIndex:i + 1] == '\n') {
i++; // skip the \n
}
if (i + 1 < len) {
[lineStarts addObject:@(i + 1)];
}
}
}
// 3. Convert each (row, column) pair to a string index
NSMutableArray<NSNumber *> *results =
[NSMutableArray arrayWithCapacity:rowColumnPairs.count];
for (WRPositionPair *pair in rowColumnPairs) {
NSInteger row = pair.row;
NSInteger column = pair.column;
if (row < 0 || row >= (NSInteger)lineStarts.count) {
[results addObject:@(NSNotFound)];
continue;
}
NSInteger lineStart = [lineStarts[row] integerValue];
// Find the end of this line
NSInteger lineEnd;
if (row + 1 < (NSInteger)lineStarts.count) {
lineEnd = [lineStarts[row + 1] integerValue] - 1;
} else {
lineEnd = (NSInteger)len;
}
NSInteger lineLength = lineEnd - lineStart;
if (column > lineLength) {
column = lineLength; // clamp to end of line
}
NSInteger stringIndex = lineStart + column + stringIndexOffset;
// Also add the cumulative offset for this file to get the global position
if (fileIndex >= 0 && fileIndex < (NSInteger)_cumulativeOffsets.count) {
stringIndex += [_cumulativeOffsets[fileIndex] integerValue];
}
[results addObject:@(stringIndex)];
}
if (stringIndices) {
*stringIndices = [results copy];
}
}
#pragma mark - Position Conversion: File Range -> String Range
- (NSRange)stringRangeFromFileRange:(NSDictionary *)fileRange
{
// Expected keys in fileRange:
// @"fileIndex" -> NSNumber (NSInteger)
// @"start" -> NSNumber (NSInteger) local offset within the file
// @"end" -> NSNumber (NSInteger) local offset within the file
NSNumber *fileIndexNum = fileRange[@"fileIndex"];
NSNumber *startNum = fileRange[@"start"];
NSNumber *endNum = fileRange[@"end"];
if (!fileIndexNum || !startNum || !endNum) {
return NSMakeRange(NSNotFound, 0);
}
NSInteger fileIndex = [fileIndexNum integerValue];
NSInteger localStart = [startNum integerValue];
NSInteger localEnd = [endNum integerValue];
// Check cache
NSString *cacheKey =
[NSString stringWithFormat:@"%ld:%ld:%ld", (long)fileIndex,
(long)localStart, (long)localEnd];
NSNumber *cached = _stringRangeCache[cacheKey];
if (cached) {
return NSRangeFromString(cached.stringValue);
}
// Validate file index
if (!_indicesBuilt) [self initIndices];
if (fileIndex < 0 || fileIndex >= (NSInteger)_cumulativeOffsets.count) {
return NSMakeRange(NSNotFound, 0);
}
// Get the cumulative offset for this file
NSInteger cumulativeOffset = [_cumulativeOffsets[fileIndex] integerValue];
// File-local length
NSInteger fileLength = [_fileLengths[fileIndex] integerValue];
// Clamp to file bounds
if (localStart < 0) localStart = 0;
if (localEnd > fileLength) localEnd = fileLength;
if (localStart >= localEnd) {
return NSMakeRange(NSNotFound, 0);
}
NSInteger globalStart = cumulativeOffset + localStart;
NSInteger length = localEnd - localStart;
NSRange result = NSMakeRange(globalStart, length);
// Cache the result
_stringRangeCache[cacheKey] = NSStringFromRange(result);
return result;
}
#pragma mark - Utility
- (NSInteger)totalCharacterCount
{
if (!_indicesBuilt) [self initIndices];
if (_cumulativeOffsets.count == 0) return _baseOffset;
NSInteger lastCumulative =
[_cumulativeOffsets.lastObject integerValue];
NSInteger lastLength = [_fileLengths.lastObject integerValue];
return lastCumulative + lastLength;
}
- (NSInteger)fileIndexForCharacterPosition:(NSInteger)position
{
if (!_indicesBuilt) [self initIndices];
// Binary search through cumulative offsets
NSInteger lo = 0;
NSInteger hi = (NSInteger)_cumulativeOffsets.count - 1;
NSInteger result = -1;
while (lo <= hi) {
NSInteger mid = lo + (hi - lo) / 2;
NSInteger offset = [_cumulativeOffsets[mid] integerValue];
if (offset <= position) {
result = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
// Verify the position is within this file's range
if (result >= 0) {
NSInteger fileLen = [_fileLengths[result] integerValue];
NSInteger cumOff = [_cumulativeOffsets[result] integerValue];
if (position >= cumOff + fileLen) {
result = -1; // beyond end of last file
}
}
return result;
}
- (NSInteger)localOffsetInFileAtIndex:(NSInteger)fileIndex
forGlobalPosition:(NSInteger)position
{
if (!_indicesBuilt) [self initIndices];
if (fileIndex < 0 || fileIndex >= (NSInteger)_cumulativeOffsets.count) {
return NSNotFound;
}
NSInteger cumulativeOffset = [_cumulativeOffsets[fileIndex] integerValue];
NSInteger localOffset = position - cumulativeOffset;
NSInteger fileLen = [_fileLengths[fileIndex] integerValue];
if (localOffset < 0 || localOffset >= fileLen) {
return NSNotFound;
}
return localOffset;
}
@end
+127
View File
@@ -0,0 +1,127 @@
//
// WREpubTypesetter.h
// WeRead (微信读书) - Reverse Engineered
//
// EPUB Typesetter: Converts XHTML content into NSAttributedString
// via DTHTMLAttributedStringBuilder with CSS cascade, image handling,
// hyperlink processing, traditional/simplified Chinese conversion,
// and free-trial truncation support.
//
// All public methods are class methods (no instance instantiation required).
// The four instance variables likely serve as cached state for the CSS
// loading pipeline, though the binary exposes no instance methods.
//
#import <Foundation/Foundation.h>
@class WRBook;
@class WRChapter;
@class WRChapterData;
NS_ASSUME_NONNULL_BEGIN
#pragma mark - Error Reporting Constants
/// Keys used in the error-reason dictionary returned by the typesetter.
FOUNDATION_EXPORT NSString *const WREpubTypesetterErrorStyleFileNotFound;
FOUNDATION_EXPORT NSString *const WREpubTypesetterErrorTranslationStyleNotFound;
FOUNDATION_EXPORT NSString *const WREpubTypesetterErrorTranslationContentNotFound;
#pragma mark - Custom CSS Attribute Names
/// NSAttributedString attribute: vertical centering style for inline elements.
/// Value: NSNumber (integer). 2 = enable vertical center (used on images).
FOUNDATION_EXPORT NSString *const WREpubTypesetterVerticalCenterStyleAttribute;
/// "wr-vertical-center-style" in CSS land.
/// NSAttributedString attribute: page-relative positioning hint.
/// Value: NSNumber (integer) indicating page relationship.
FOUNDATION_EXPORT NSString *const WREpubTypesetterPageRelateAttribute;
/// "weread-page-relate" in CSS land.
#pragma mark - Page Flipping Style Enum
typedef NS_ENUM(NSInteger, WRPageFlippingStyle) {
WRPageFlippingStyleDefault = 0,
WRPageFlippingStyleSlide = 1,
WRPageFlippingStyleCurl = 2,
WRPageFlippingStyleNone = 3,
};
#pragma mark - WREpubTypesetter
@interface WREpubTypesetter : NSObject {
// Instance variables (no instance methods found in the binary;
// these likely cache transient state during a typesetting pass).
NSString *_currentCSS; // Aggregated CSS after cascade merge
NSString *_epubEmbeddedCSS; // CSS extracted from the EPUB book itself
NSString *_userSettingsCSS; // User-applied CSS overrides (font, line-height, theme)
NSArray *_imageURLs; // Collected image attachment URLs for lazy loading
}
#pragma mark - Primary Typesetting Entry Point
/// Convert an EPUB XHTML chapter file into a styled NSAttributedString.
///
/// This is the main entry point. It:
/// 1. Loads and merges CSS in priority order (see implementation).
/// 2. Feeds XHTML + merged CSS into DTHTMLAttributedStringBuilder.
/// 3. Walks the resulting element tree to patch images, links,
/// custom attributes, and apply user typographic preferences.
/// 4. Optionally truncates the output for free-trial preview.
///
/// @param filePath Absolute path to the .xhtml / .html file inside the EPUB.
/// @param priority Unused or internal scheduling priority (pass 0 for default).
/// @param insertArticleToolAttachment If YES, embeds an article-tool NSTextAttachment
/// at the end of the attributed string.
/// @param insertBookChapterToolAttachment If YES, embeds a book-chapter-tool
/// NSTextAttachment.
/// @param insertRecommendView If YES, appends a recommendation-view attachment.
/// @param book The WRBook model containing metadata, CSS paths, etc.
/// @param chapter The WRChapter model (chapter ID, title, ordering).
/// @param pageFlippingStyle Desired page-flip animation style.
/// @param renderErrorReason [out] On failure, set to a human-readable error description.
/// May be NULL if the caller does not need it.
/// @param isStyleFileNotFound [out] Set to YES if a required CSS file was missing.
/// May be NULL.
/// @param options Additional NSDictionary of rendering options (font family,
/// line-height multiplier, dark-mode flag, etc.).
///
/// @return A fully styled NSAttributedString, or nil on unrecoverable error.
+ (nullable NSAttributedString *)
attributeStringWithFilePath:(NSString *)filePath
priority:(NSInteger)priority
insertArticleToolAttachment:(BOOL)insertArticleToolAttachment
insertBookChapterToolAttachment:(BOOL)insertBookChapterToolAttachment
insertRecommendView:(BOOL)insertRecommendView
book:(WRBook *)book
chapter:(WRChapter *)chapter
pageFlippingStyle:(WRPageFlippingStyle)pageFlippingStyle
renderErrorReason:(NSString * _Nullable * _Nullable)renderErrorReason
isStyleFileNotFound:(BOOL * _Nullable)isStyleFileNotFound
options:(NSDictionary * _Nullable)options;
#pragma mark - Translation Error Reporting
/// Report a translation-related rendering error to the analytics subsystem.
///
/// Called when the typesetter detects missing translation CSS or content
/// during bilingual (original + translated) rendering.
///
/// @param error The underlying NSError, if any.
/// @param bookId Book identifier string.
/// @param chapter The WRChapter that failed translation rendering.
/// @param isTranslationStyleNotFound YES if the translation CSS file was missing.
/// @param isTranslationContentNotFound YES if the translated XHTML content was missing.
/// @param isTranslateTagButNoTranslateStyle YES if the HTML contained a translate tag
/// but no corresponding CSS rule was found.
+ (void)tryReportTranslationError:(nullable NSError *)error
bookId:(NSString *)bookId
chapter:(WRChapter *)chapter
isTranslationStyleNotFound:(BOOL)isTranslationStyleNotFound
isTranslationContentNotFound:(BOOL)isTranslationContentNotFound
isTranslateTagButNoTranslateStyle:(BOOL)isTranslateTagButNoTranslateStyle;
@end
NS_ASSUME_NONNULL_END
+794
View File
@@ -0,0 +1,794 @@
//
// WREpubTypesetter.m
// WeRead (微信读书) - Reverse Engineered Implementation Reconstruction
//
// This is a detailed pseudo-code reconstruction based on:
// - Binary strings output (method signatures, CSS filenames, attribute names)
// - Architecture documentation (CSS cascade order, processing pipeline)
// - DTCSSStylesheet / DTHTMLAttributedStringBuilder public API
// - Behavioral analysis of WeRead EPUB rendering
//
// Disclaimer: This is a reconstruction, not a decompiled original.
// Variable names, control flow, and helper methods are educated guesses
// informed by the above evidence.
//
#import "WREpubTypesetter.h"
// DTLite (DTCoreText) framework headers
#import "DTHTMLAttributedStringBuilder.h"
#import "DTHTMLElement.h"
#import "DTCoreTextFontDescriptor.h"
#import "DTCSSStylesheet.h"
#import "DTTextAttachment.h"
#import "DTLinkButton.h"
#import "DTColor.h"
// WeRead internal
#import "WRBook.h"
#import "WRChapter.h"
#import "WRChapterData.h"
#import "WRCoreTextLayouter.h"
#pragma mark - Constants
NSString *const WREpubTypesetterVerticalCenterStyleAttribute = @"wr-vertical-center-style";
NSString *const WREpubTypesetterPageRelateAttribute = @"weread-page-relate";
NSString *const WREpubTypesetterErrorStyleFileNotFound = @"WREpubTypesetterErrorStyleFileNotFound";
NSString *const WREpubTypesetterErrorTranslationStyleNotFound = @"WREpubTypesetterErrorTranslationStyleNotFound";
NSString *const WREpubTypesetterErrorTranslationContentNotFound = @"WREpubTypesetterErrorTranslationContentNotFound";
#pragma mark - File-Private Helpers (Forward Declarations)
/// Merge multiple CSS sources into a single DTCSSStylesheet in priority order.
static DTCSSStylesheet *_WRCascadeStylesheets(
NSString *defaultCSSPath,
NSString *replaceCSSPath,
NSString *darkCSSPath,
NSString *epubEmbeddedCSSString,
NSString *userSettingsCSSString
);
/// Load the contents of a CSS file from the app bundle or EPUB container.
/// Returns nil if the file does not exist; sets *outFound to NO.
static NSString *_WRLoadCSSFileAtPath(NSString *path, BOOL *outFound);
/// Convert simplified Chinese (Hans) to traditional Chinese (Hant) for
/// locales that require it (zh-Hant / zh-TW / zh-HK).
static NSString *_WRConvertHansToHantIfNeeded(NSString *htmlString, NSString *languageCode);
/// Build the user-settings CSS string from the options dictionary.
static NSString *_WRBuildUserSettingsCSS(NSDictionary *options);
/// Truncate the attributed string at the free-trial boundary.
/// Returns a sub-string ending at the last paragraph break before the limit.
static NSAttributedString *_WRTruncateForFreeTrial(
NSAttributedString *fullString,
NSUInteger maxCharacterCount
);
/// Walk the DTHTMLElement tree and patch custom attributes (vertical center,
/// page-relate), rewrite image attachments, and resolve hyperlinks.
static void _WRPostProcessElementTree(DTHTMLElement *root, NSDictionary *options);
/// Build the combined rendering options dict that DTHTMLAttributedStringBuilder expects.
static NSDictionary *_WRBuildDTOptions(
DTCSSStylesheet *stylesheet,
WRBook *book,
NSDictionary *userOptions
);
#pragma mark - Implementation
@implementation WREpubTypesetter
#pragma mark - Primary Typesetting Entry Point
+ (NSAttributedString *)
attributeStringWithFilePath:(NSString *)filePath
priority:(NSInteger)priority
insertArticleToolAttachment:(BOOL)insertArticleToolAttachment
insertBookChapterToolAttachment:(BOOL)insertBookChapterToolAttachment
insertRecommendView:(BOOL)insertRecommendView
book:(WRBook *)book
chapter:(WRChapter *)chapter
pageFlippingStyle:(WRPageFlippingStyle)pageFlippingStyle
renderErrorReason:(NSString * _Nullable * _Nullable)renderErrorReason
isStyleFileNotFound:(BOOL * _Nullable)isStyleFileNotFound
options:(NSDictionary * _Nullable)options
{
// ========================================================================
// Step 0: Guard — validate inputs
// ========================================================================
if (!filePath.length || !book || !chapter) {
if (renderErrorReason) {
*renderErrorReason = @"Invalid parameters: filePath, book, or chapter is nil.";
}
return nil;
}
// Default options to empty dict
NSDictionary *effectiveOptions = options ?: @{};
// ========================================================================
// Step 1: Load the XHTML source
// ========================================================================
//
// Read the raw XHTML/HTML from the EPUB file on disk.
// If the file is missing or unreadable, bail out early.
//
NSError *readError = nil;
NSData *htmlData = [NSData dataWithContentsOfFile:filePath
options:0
error:&readError];
if (!htmlData) {
if (renderErrorReason) {
*renderErrorReason = [NSString stringWithFormat:
@"Failed to read XHTML file at path: %@ — %@", filePath, readError.localizedDescription];
}
return nil;
}
// Detect encoding: EPUB 3 defaults to UTF-8; older EPUBs may use UTF-16.
// DTHTMLAttributedStringBuilder handles encoding detection, but we convert
// NSData → NSString here for the Hans-to-Hant step.
NSString *htmlString = [[NSString alloc] initWithData:htmlData encoding:NSUTF8StringEncoding];
if (!htmlString) {
// Fallback to ASCII lossy conversion
htmlString = [[NSString alloc] initWithData:htmlData
encoding:NSASCIIStringEncoding];
}
// ========================================================================
// Step 1.5: Traditional / Simplified Chinese conversion
// ========================================================================
//
// WeRead supports reading in Traditional Chinese even when the book
// is authored in Simplified. If the user's locale / book language is
// zh-Hant, convert all Simplified characters to Traditional.
//
NSString *bookLanguage = book.language ?: @"zh-Hans";
if ([bookLanguage containsString:@"Hant"] ||
[bookLanguage containsString:@"TW"] ||
[bookLanguage containsString:@"HK"])
{
htmlString = _WRConvertHansToHantIfNeeded(htmlString, bookLanguage);
}
// ========================================================================
// Step 2: Load and cascade CSS stylesheets
// ========================================================================
//
// CSS priority (lowest → highest, i.e., later sources override earlier):
// 1. default.css — Base HTML tag styles (body, p, h1-h6, ul, ol, etc.)
// 2. replace.css — WeRead's default replacements:
// • headings → Source Han Serif CN (思源宋体)
// • code blocks → Menlo
// • images → .bodyPic with wr-vertical-center-style:2
// 3. dark.css — Dark-theme color overrides (background, text color)
// 4. EPUB embedded — The book's own <style> blocks and linked CSS
// 5. User settings — Runtime user prefs (font size, line-height, theme)
//
NSString *bundlePath = [[NSBundle mainBundle] bundlePath];
NSString *defaultCSSPath = [bundlePath stringByAppendingPathComponent:@"default.css"];
NSString *replaceCSSPath = [bundlePath stringByAppendingPathComponent:@"replace.css"];
NSString *darkCSSPath = [bundlePath stringByAppendingPathComponent:@"dark.css"];
// Dark theme: only load dark.css if the user has selected dark mode.
BOOL isDarkMode = [effectiveOptions[@"darkMode"] boolValue];
if (!isDarkMode) {
darkCSSPath = nil; // skip dark.css
}
// EPUB embedded CSS: extracted from the book's <style> and <link> tags
// during EPUB parsing. Stored on WRChapterData or WRBook.
NSString *epubEmbeddedCSS = book.epubEmbeddedCSS ?: @"";
// User settings CSS: built from the user's runtime preferences.
NSString *userSettingsCSS = _WRBuildUserSettingsCSS(effectiveOptions);
// Cascade merge
DTCSSStylesheet *mergedStylesheet = _WRCascadeStylesheets(
defaultCSSPath,
replaceCSSPath,
darkCSSPath,
epubEmbeddedCSS,
userSettingsCSS
);
// Track whether any required CSS file was missing.
BOOL styleFileMissing = NO;
{
// Quick existence check — the loader already sets this, but we
// double-check for the primary stylesheet.
BOOL dummyFound = YES;
_WRLoadCSSFileAtPath(defaultCSSPath, &dummyFound);
if (!dummyFound) {
styleFileMissing = YES;
}
}
if (isStyleFileNotFound) {
*isStyleFileNotFound = styleFileMissing;
}
// ========================================================================
// Step 3: Configure DTHTMLAttributedStringBuilder
// ========================================================================
//
// DTHTMLAttributedStringBuilder is a third-party (DTCoreText / DTLite)
// library that parses HTML/CSS into a tree of DTHTMLElement nodes, then
// lays out the tree via CoreText to produce an NSAttributedString.
//
NSDictionary *dtOptions = _WRBuildDTOptions(mergedStylesheet, book, effectiveOptions);
DTHTMLAttributedStringBuilder *builder =
[[DTHTMLAttributedStringBuilder alloc] initWithHTML:[htmlString dataUsingEncoding:NSUTF8StringEncoding]
options:dtOptions
documentAttributes:nil];
// Wire up custom post-processing via the builder's delegate.
// DTHTMLAttributedStringBuilder fires willFlushCallback for each
// DTHTMLElement before it is converted to NSAttributedString runs.
[builder setWillFlushCallback:^(DTHTMLElement *element) {
_WRPostProcessElementTree(element, effectiveOptions);
}];
// ========================================================================
// Step 4: Build the attributed string
// ========================================================================
NSAttributedString *result = [builder generatedAttributedString];
if (!result) {
if (renderErrorReason) {
*renderErrorReason = @"DTHTMLAttributedStringBuilder returned nil — HTML parsing may have failed.";
}
return nil;
}
// ========================================================================
// Step 5: Free-trial truncation
// ========================================================================
//
// WeRead offers free preview of N characters per chapter. If the
// chapter exceeds the trial limit, truncate at the last paragraph
// boundary before the limit.
//
BOOL isFreeTrial = [effectiveOptions[@"freeTrial"] boolValue];
NSUInteger trialCharacterLimit = [effectiveOptions[@"trialCharacterLimit"] unsignedIntegerValue];
if (isFreeTrial && trialCharacterLimit > 0 && result.length > trialCharacterLimit) {
result = _WRTruncateForFreeTrial(result, trialCharacterLimit);
}
// ========================================================================
// Step 6: Append tool / recommendation attachments
// ========================================================================
//
// WeRead optionally appends interactive tool bars and recommendation
// views as NSTextAttachment objects at the end of the chapter.
//
NSMutableAttributedString *finalResult = [result mutableCopy];
if (insertArticleToolAttachment) {
// Create a zero-width text attachment that the layout engine will
// render as the article toolbar (highlight, note, share buttons).
NSTextAttachment *articleToolAttachment = [[NSTextAttachment alloc] initWithData:nil
ofType:nil];
// Attach a custom data payload so the rendering layer can identify it.
articleToolAttachment.userInfo = @{
@"type": @"articleTool",
@"chapterId": chapter.chapterId ?: @""
};
NSAttributedString *attachmentStr =
[NSAttributedString attributedStringWithAttachment:articleToolAttachment];
[finalResult appendAttributedString:attachmentStr];
}
if (insertBookChapterToolAttachment) {
NSTextAttachment *chapterToolAttachment = [[NSTextAttachment alloc] initWithData:nil
ofType:nil];
chapterToolAttachment.userInfo = @{
@"type": @"bookChapterTool",
@"chapterId": chapter.chapterId ?: @""
};
[finalResult appendAttributedString:
[NSAttributedString attributedStringWithAttachment:chapterToolAttachment]];
}
if (insertRecommendView) {
NSTextAttachment *recommendAttachment = [[NSTextAttachment alloc] initWithData:nil
ofType:nil];
recommendAttachment.userInfo = @{
@"type": @"recommendView",
@"bookId": book.bookId ?: @""
};
[finalResult appendAttributedString:
[NSAttributedString attributedStringWithAttachment:recommendAttachment]];
}
return [finalResult copy];
}
#pragma mark - Translation Error Reporting
+ (void)tryReportTranslationError:(nullable NSError *)error
bookId:(NSString *)bookId
chapter:(WRChapter *)chapter
isTranslationStyleNotFound:(BOOL)isTranslationStyleNotFound
isTranslationContentNotFound:(BOOL)isTranslationContentNotFound
isTranslateTagButNoTranslateStyle:(BOOL)isTranslateTagButNoTranslateStyle
{
// ========================================================================
// Build a structured analytics event and fire it via WeRead's telemetry.
//
// Translation errors occur when the user requests bilingual mode
// (original + translated text) but the translation data or CSS is
// missing from the EPUB package.
// ========================================================================
// Only report if at least one error flag is set.
if (!isTranslationStyleNotFound &&
!isTranslationContentNotFound &&
!isTranslateTagButNoTranslateStyle)
{
return;
}
NSMutableDictionary *eventPayload = [NSMutableDictionary dictionary];
eventPayload[@"bookId"] = bookId ?: @"";
eventPayload[@"chapterId"] = chapter.chapterId ?: @"";
eventPayload[@"chapterTitle"] = chapter.title ?: @"";
eventPayload[@"isTranslationStyleNotFound"] = @(isTranslationStyleNotFound);
eventPayload[@"isTranslationContentNotFound"] = @(isTranslationContentNotFound);
eventPayload[@"isTranslateTagButNoTranslateStyle"] = @(isTranslateTagButNoTranslateStyle);
if (error) {
eventPayload[@"errorCode"] = @(error.code);
eventPayload[@"errorDomain"] = error.domain ?: @"";
eventPayload[@"errorMessage"] = error.localizedDescription ?: @"";
}
// WeRead uses a custom telemetry SDK (likely based on Tencent's MTA or
// a proprietary solution). The event name is likely "epub_translation_error".
//
// [WRAnalytics reportEvent:@"epub_translation_error" params:eventPayload];
NSLog(@"[WREpubTypesetter] Translation error for book %@ chapter %@: %@",
bookId, chapter.chapterId, eventPayload);
}
@end
#pragma mark - File-Private Helper Implementations
// ============================================================================
// _WRCascadeStylesheets
// ============================================================================
//
// Merge multiple CSS sources into a single DTCSSStylesheet.
// Later sources override earlier ones — this is the "cascade" in CSS.
//
static DTCSSStylesheet *_WRCascadeStylesheets(
NSString *defaultCSSPath,
NSString *replaceCSSPath,
NSString *darkCSSPath,
NSString *epubEmbeddedCSSString,
NSString *userSettingsCSSString)
{
DTCSSStylesheet *merged = [[DTCSSStylesheet alloc] init];
// --- Layer 1: default.css (base HTML tag styles) ---
//
// This file defines how standard HTML elements look:
// body { font-family: ...; margin: 0; }
// p { margin-top: 0.5em; margin-bottom: 0.5em; }
// h1 { font-size: 1.8em; font-weight: bold; }
// ... etc.
//
if (defaultCSSPath) {
NSString *css = _WRLoadCSSFileAtPath(defaultCSSPath, NULL);
if (css) {
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:css];
[merged mergeStylesheet:layer];
}
}
// --- Layer 2: replace.css (WeRead's default replacements) ---
//
// Overrides specific elements with WeRead's preferred styling:
// h1, h2, h3 { font-family: "Source Han Serif CN", serif; }
// pre, code { font-family: "Menlo", monospace; }
// img.bodyPic { wr-vertical-center-style: 2; max-width: 100%; }
//
// This ensures a consistent reading experience across different EPUBs.
//
if (replaceCSSPath) {
NSString *css = _WRLoadCSSFileAtPath(replaceCSSPath, NULL);
if (css) {
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:css];
[merged mergeStylesheet:layer];
}
}
// --- Layer 3: dark.css (dark theme overrides) ---
//
// Only loaded when the user is in dark / night mode:
// body { background-color: #1a1a1a; color: #cccccc; }
// a { color: #6eaad7; }
// img { filter: brightness(0.85); }
//
if (darkCSSPath) {
NSString *css = _WRLoadCSSFileAtPath(darkCSSPath, NULL);
if (css) {
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:css];
[merged mergeStylesheet:layer];
}
}
// --- Layer 4: EPUB embedded CSS ---
//
// The book author's own styles, extracted from <style> blocks and
// linked .css files inside the EPUB container.
//
if (epubEmbeddedCSSString.length) {
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:epubEmbeddedCSSString];
[merged mergeStylesheet:layer];
}
// --- Layer 5: User settings CSS ---
//
// Dynamically generated from the user's runtime preferences:
// body {
// font-size: 18px;
// line-height: 1.8;
// font-family: "PingFang SC", sans-serif;
// background-color: #f5f0e8; (sepia theme)
// }
//
if (userSettingsCSSString.length) {
DTCSSStylesheet *layer = [[DTCSSStylesheet alloc] initWithStyleBlock:userSettingsCSSString];
[merged mergeStylesheet:layer];
}
return merged;
}
// ============================================================================
// _WRLoadCSSFileAtPath
// ============================================================================
//
// Read a CSS file from disk. Returns the string contents, or nil if
// the file does not exist. Optionally reports existence via outFound.
//
static NSString *_WRLoadCSSFileAtPath(NSString *path, BOOL *outFound)
{
if (!path.length) {
if (outFound) *outFound = NO;
return nil;
}
NSFileManager *fm = [NSFileManager defaultManager];
if (![fm fileExistsAtPath:path]) {
if (outFound) *outFound = NO;
return nil;
}
if (outFound) *outFound = YES;
NSError *error = nil;
NSString *contents = [NSString stringWithContentsOfFile:path
encoding:NSUTF8StringEncoding
error:&error];
if (!contents) {
NSLog(@"[WREpubTypesetter] Failed to load CSS at %@: %@", path, error.localizedDescription);
}
return contents;
}
// ============================================================================
// _WRConvertHansToHantIfNeeded
// ============================================================================
//
// Convert Simplified Chinese characters to Traditional Chinese.
// WeRead supports bilingual mode and locale-based character conversion.
//
// The actual conversion likely uses Apple's CFStringTransform with
// kCFStringTransformToLatin + kCFStringTransformLatinHant, or a
// custom lookup table for high-fidelity conversion.
//
static NSString *_WRConvertHansToHantIfNeeded(NSString *htmlString, NSString *languageCode)
{
if (!htmlString.length) return htmlString;
// CFStringTransform approach (simplified):
// 1. Convert Hans → Latin (pinyin)
// 2. Convert Latin → Hant (traditional)
//
// This is lossy for some characters. WeRead may use a custom
// dictionary (OpenCC or similar) for better accuracy.
//
NSMutableString *mutable = [htmlString mutableCopy];
// Apple's built-in transliteration:
// Hans → Hant is not directly supported via CFStringTransform,
// so a two-step Latin bridge is used.
//
CFStringRef str = (__bridge CFStringRef)mutable;
CFStringTransform(str, NULL, kCFStringTransformToLatin, NO);
CFStringTransform(str, NULL, kCFStringTransformLatinHant, NO);
return [mutable copy];
}
// ============================================================================
// _WRBuildUserSettingsCSS
// ============================================================================
//
// Build a CSS string from the user's runtime preferences dictionary.
// These override any CSS from the book or WeRead defaults.
//
static NSString *_WRBuildUserSettingsCSS(NSDictionary *options)
{
if (!options.count) return @"";
NSMutableString *css = [NSMutableString string];
// Font family
NSString *fontFamily = options[@"fontFamily"];
if (fontFamily.length) {
[css appendFormat:@"body { font-family: \"%@\", sans-serif; }\n", fontFamily];
}
// Font size
NSNumber *fontSize = options[@"fontSize"];
if (fontSize) {
[css appendFormat:@"body { font-size: %ldpx; }\n", (long)fontSize.integerValue];
}
// Line height
NSNumber *lineHeight = options[@"lineHeight"];
if (lineHeight) {
[css appendFormat:@"body { line-height: %.2f; }\n", lineHeight.floatValue];
}
// Letter spacing (character spacing)
NSNumber *letterSpacing = options[@"letterSpacing"];
if (letterSpacing) {
[css appendFormat:@"body { letter-spacing: %ldpx; }\n", (long)letterSpacing.integerValue];
}
// Theme background color
NSString *backgroundColor = options[@"backgroundColor"];
if (backgroundColor.length) {
[css appendFormat:@"body { background-color: %@; }\n", backgroundColor];
}
// Theme text color
NSString *textColor = options[@"textColor"];
if (textColor.length) {
[css appendFormat:@"body, p, span, div { color: %@; }\n", textColor];
}
// Paragraph margin / first-line indent
NSNumber *paragraphSpacing = options[@"paragraphSpacing"];
if (paragraphSpacing) {
[css appendFormat:@"p { margin-top: %ldpx; margin-bottom: %ldpx; }\n",
(long)paragraphSpacing.integerValue,
(long)paragraphSpacing.integerValue];
}
NSNumber *firstLineIndent = options[@"firstLineIndent"];
if (firstLineIndent) {
[css appendFormat:@"p { text-indent: %ldpx; }\n",
(long)firstLineIndent.integerValue];
}
return [css copy];
}
// ============================================================================
// _WRBuildDTOptions
// ============================================================================
//
// Build the options dictionary for DTHTMLAttributedStringBuilder.
//
static NSDictionary *_WRBuildDTOptions(
DTCSSStylesheet *stylesheet,
WRBook *book,
NSDictionary *userOptions)
{
NSMutableDictionary *opts = [NSMutableDictionary dictionary];
// Base URL for resolving relative image paths inside the EPUB.
// This is the directory containing the XHTML file.
if (book.epubBasePath) {
opts[DTBaseURL] = [NSURL fileURLWithPath:book.epubBasePath];
}
// Apply the merged CSS stylesheet
if (stylesheet) {
opts[DTDefaultStylesheet] = stylesheet;
}
// CoreText font descriptor for the default body font.
// WeRead uses system fonts (PingFang SC) or user-chosen fonts.
DTCoreTextFontDescriptor *fontDesc = [[DTCoreTextFontDescriptor alloc] init];
NSString *userFont = userOptions[@"fontFamily"];
if (userFont.length) {
fontDesc.fontName = userFont;
} else {
fontDesc.fontName = @"PingFang SC";
}
NSNumber *fontSize = userOptions[@"fontSize"];
fontDesc.pointSize = fontSize ? fontSize.floatValue : 18.0f;
opts[DTDefaultFontDescriptor] = fontDesc;
// Text color
opts[DTDefaultTextColor] = [DTColor blackColor];
// Link color
opts[DTDefaultLinkColor] = [DTColor colorWithRed:0.0 green:0.478 blue:1.0 alpha:1.0];
// Disable image downloading — WeRead handles images locally from the EPUB.
opts[DTIgnoreInlineStyles] = @NO;
opts[DTMaxImageSize] = @(CGSizeMake(1080, 1920)); // Max display size
return [opts copy];
}
// ============================================================================
// _WRPostProcessElementTree
// ============================================================================
//
// Walk the DTHTMLElement tree before it is flushed to NSAttributedString
// and apply WeRead-specific patches:
//
// 1. Images → wrap in NSTextAttachment with .bodyPic class, apply
// wr-vertical-center-style:2 so images center vertically.
//
// 2. Hyperlinks → attach DTLinkButton metadata, set link color.
//
// 3. Custom attributes:
// - wr-vertical-center-style: element is vertically centered
// - weread-page-relate: element has page-relative positioning
//
// 4. Font patching: if the book specifies a custom font, override
// font descriptors in heading and paragraph nodes.
//
static void _WRPostProcessElementTree(DTHTMLElement *element, NSDictionary *options)
{
if (!element) return;
// --- Image handling ---
//
// DTHTMLElement nodes with a textAttachment represent <img> tags.
// WeRead patches image attachments to:
// - Set a maximum display size
// - Add the "bodyPic" CSS class
// - Apply vertical centering (wr-vertical-center-style:2)
//
if (element.textAttachment) {
DTTextAttachment *attachment = element.textAttachment;
// Enforce max image dimensions from user options or defaults.
CGSize maxSize = CGSizeMake(1080, 1920);
NSNumber *maxW = options[@"maxImageWidth"];
if (maxW) maxSize.width = maxW.floatValue;
if (attachment.originalSize.width > maxSize.width) {
CGFloat scale = maxSize.width / attachment.originalSize.width;
attachment.displaySize = CGSizeMake(
attachment.originalSize.width * scale,
attachment.originalSize.height * scale
);
}
// Mark as vertically centered (matches replace.css rule:
// img.bodyPic { wr-vertical-center-style: 2; })
//
// The custom attribute "wr-vertical-center-style" is read by
// WRCoreTextLayouter during line layout to adjust the baseline
// offset so the image sits vertically centered on the line.
//
element.textAttachment.attributes = @{
WREpubTypesetterVerticalCenterStyleAttribute: @(2)
};
// Add "bodyPic" CSS class for styling consistency.
[element addClass:@"bodyPic"];
}
// --- Hyperlink handling ---
//
// <a href="..."> tags produce DTHTMLElement nodes with a link.
// WeRead stores the link URL in the element's attribute dictionary
// so that WRCoreTextLayouter can render a tappable link.
//
NSString *linkURL = element.link;
if (linkURL.length) {
// Internal EPUB links (e.g., "#footnote-1") are resolved relative
// to the current file. External links open in a browser.
//
// Store the URL as a custom attribute that the rendering layer
// will pick up.
element.fontDescriptor.underlineTrait = YES;
}
// --- Custom CSS attributes ---
//
// Scan the element's style dictionary for WeRead-specific properties.
//
NSString *verticalCenter = element.styleAttributes[WREpubTypesetterVerticalCenterStyleAttribute];
if (verticalCenter) {
// Store as an NSNumber on the element for the layout engine.
// The value "2" means "center the element on the line."
element.textAttachment.attributes = @{
WREpubTypesetterVerticalCenterStyleAttribute: @([verticalCenter integerValue])
};
}
NSString *pageRelate = element.styleAttributes[WREpubTypesetterPageRelateAttribute];
if (pageRelate) {
// Page-relative positioning: used for elements that should
// appear at a fixed position relative to the page (e.g., headers).
}
// --- Recurse into children ---
//
for (DTHTMLElement *child in element.childNodes) {
_WRPostProcessElementTree(child, options);
}
}
// ============================================================================
// _WRTruncateForFreeTrial
// ============================================================================
//
// Truncate an NSAttributedString at the free-trial boundary.
//
// WeRead lets non-VIP users read a limited number of characters per
// chapter. The truncation point is the last paragraph break (\n or
// NSParagraphSeparator) before the character limit.
//
static NSAttributedString *_WRTruncateForFreeTrial(
NSAttributedString *fullString,
NSUInteger maxCharacterCount)
{
if (fullString.length <= maxCharacterCount) {
return fullString;
}
// Walk backwards from the limit to find a paragraph boundary.
NSString *plainText = fullString.string;
NSUInteger truncateAt = maxCharacterCount;
while (truncateAt > 0) {
unichar c = [plainText characterAtIndex:truncateAt - 1];
if (c == '\n' || c == 0x2029 /* NSParagraphSeparator */) {
break;
}
truncateAt--;
}
// If we couldn't find a paragraph break, just cut at the limit.
if (truncateAt == 0) {
truncateAt = maxCharacterCount;
}
// Create a sub-attributed-string up to the truncation point.
NSRange range = NSMakeRange(0, truncateAt);
NSMutableAttributedString *truncated =
[[fullString attributedSubstringFromRange:range] mutableCopy];
// Append a "..." indicator so the reader knows the chapter continues.
NSDictionary *lastAttributes = [truncated attributesAtIndex:truncated.length - 1
effectiveRange:NULL];
NSAttributedString *ellipsis =
[[NSAttributedString alloc] initWithString:@"\n\n...\n\n"
attributes:lastAttributes];
[truncated appendAttributedString:ellipsis];
return [truncated copy];
}
+67
View File
@@ -0,0 +1,67 @@
//
// WRPageHighlight.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Annotation model for text highlights. Stores the highlighted text range,
// color, and associated metadata. 3 methods identified from binary.
//
// Inherits from or relates to WRPageMark (base class).
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class WRBook;
@class WRBookmark;
// ---------------------------------------------------------------------------
// Highlight color presets
// ---------------------------------------------------------------------------
typedef NS_ENUM(NSInteger, WRHighlightColor) {
WRHighlightColorYellow = 0,
WRHighlightColorBlue = 1,
WRHighlightColorRed = 2,
WRHighlightColorGreen = 3,
WRHighlightColorPurple = 4,
};
// ---------------------------------------------------------------------------
// WRPageHighlight
// ---------------------------------------------------------------------------
@interface WRPageHighlight : NSObject
@property (nonatomic, copy) NSString *highlightId; // unique ID
@property (nonatomic, copy) NSString *bookId;
@property (nonatomic, copy) NSString *chapterUid;
@property (nonatomic, assign) NSInteger startPos; // global start position
@property (nonatomic, assign) NSInteger endPos; // global end position
@property (nonatomic, assign) NSInteger chapterOffset; // offset within chapter
@property (nonatomic, copy) NSString *markedText; // the highlighted text
@property (nonatomic, assign) WRHighlightColor color; // highlight color enum
@property (nonatomic, copy, nullable) NSString *colorStyle; // color name string
@property (nonatomic, assign) NSInteger pageIndex; // page in the reader
@property (nonatomic, assign) NSTimeInterval createTime;
@property (nonatomic, assign) BOOL isSynced;
#pragma mark - Methods (3 identified from binary)
/// Initialize a highlight with the given range and color.
- (instancetype)initWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
startPos:(NSInteger)startPos
endPos:(NSInteger)endPos
text:(NSString *)text
color:(WRHighlightColor)color;
/// Convert this highlight to a WRBookmark for persistence/sync.
- (WRBookmark *)toBookmark;
/// Return the highlight color as a UIColor for rendering.
- (UIColor *)uiColor;
@end
NS_ASSUME_NONNULL_END
+105
View File
@@ -0,0 +1,105 @@
//
// WRPageHighlight.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// 3 methods identified from binary. WRPageHighlight is a lightweight
// model for text highlights on a page.
//
#import "WRPageHighlight.h"
#import "WRBookmark.h"
// ---------------------------------------------------------------------------
// Color utility
// ---------------------------------------------------------------------------
static UIColor *UIColorForHighlightColor(WRHighlightColor color)
{
switch (color) {
case WRHighlightColorYellow:
return [UIColor colorWithRed:1.0 green:0.92 blue:0.23 alpha:0.35];
case WRHighlightColorBlue:
return [UIColor colorWithRed:0.26 green:0.65 blue:0.96 alpha:0.35];
case WRHighlightColorRed:
return [UIColor colorWithRed:0.96 green:0.26 blue:0.26 alpha:0.35];
case WRHighlightColorGreen:
return [UIColor colorWithRed:0.30 green:0.85 blue:0.39 alpha:0.35];
case WRHighlightColorPurple:
return [UIColor colorWithRed:0.67 green:0.33 blue:0.97 alpha:0.35];
default:
return [UIColor colorWithRed:1.0 green:0.92 blue:0.23 alpha:0.35];
}
}
static NSString *NSStringFromHighlightColor(WRHighlightColor color)
{
switch (color) {
case WRHighlightColorYellow: return @"yellow";
case WRHighlightColorBlue: return @"blue";
case WRHighlightColorRed: return @"red";
case WRHighlightColorGreen: return @"green";
case WRHighlightColorPurple: return @"purple";
default: return @"yellow";
}
}
#pragma mark - Implementation
@implementation WRPageHighlight
- (instancetype)initWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
startPos:(NSInteger)startPos
endPos:(NSInteger)endPos
text:(NSString *)text
color:(WRHighlightColor)color
{
self = [super init];
if (self) {
_highlightId = [[NSUUID UUID] UUIDString];
_bookId = [bookId copy];
_chapterUid = [chapterUid copy];
_startPos = startPos;
_endPos = endPos;
_chapterOffset = startPos; // Simplified; real impl maps global->local
_markedText = [text copy];
_color = color;
_colorStyle = NSStringFromHighlightColor(color);
_createTime = [[NSDate date] timeIntervalSince1970];
_isSynced = NO;
}
return self;
}
- (WRBookmark *)toBookmark
{
WRBookmark *bookmark = [WRBookmark highlightWithBookId:_bookId
chapterUid:_chapterUid
startPos:_startPos
endPos:_endPos
text:_markedText
colorStyle:_colorStyle];
bookmark.bookmarkId = _highlightId;
bookmark.chapterOffset = _chapterOffset;
bookmark.pageIndex = _pageIndex;
bookmark.createTime = _createTime;
bookmark.isSynced = _isSynced;
return bookmark;
}
- (UIColor *)uiColor
{
return UIColorForHighlightColor(_color);
}
#pragma mark - NSObject
- (NSString *)description
{
return [NSString stringWithFormat:@"<WRPageHighlight: %@ [%@] '%@'>",
_highlightId, _colorStyle,
[_markedText substringToIndex:MIN(40, _markedText.length)]];
}
@end
+139
View File
@@ -0,0 +1,139 @@
//
// WRPageMark.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Annotation model for page bookmarks/marks (dog-ear style).
// 25 methods identified from binary. The most feature-rich annotation type.
// Supports bookmark management, sorting, filtering, and display.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class WRBook;
@class WRBookmark;
@class WRPageHighlight;
@class WRPageUnderline;
// ---------------------------------------------------------------------------
// Mark display mode
// ---------------------------------------------------------------------------
typedef NS_ENUM(NSInteger, WRPageMarkDisplayMode) {
WRPageMarkDisplayModeIcon = 0, // Show bookmark icon
WRPageMarkDisplayModeList = 1, // Show in list view
WRPageMarkDisplayModeInline = 2, // Show inline in text
};
// ---------------------------------------------------------------------------
// WRPageMark
// ---------------------------------------------------------------------------
@interface WRPageMark : NSObject
@property (nonatomic, copy) NSString *markId;
@property (nonatomic, copy) NSString *bookId;
@property (nonatomic, copy) NSString *chapterUid;
@property (nonatomic, assign) NSInteger chapterOffset;
@property (nonatomic, assign) NSInteger pageIndex;
@property (nonatomic, copy) NSString *chapterTitle; // display title
@property (nonatomic, copy, nullable) NSString *excerptText; // text near the mark
@property (nonatomic, assign) NSTimeInterval createTime;
@property (nonatomic, assign) NSTimeInterval updateTime;
@property (nonatomic, assign) BOOL isSynced;
@property (nonatomic, copy, nullable) NSString *syncKey;
// Color/style for the mark icon
@property (nonatomic, strong, nullable) UIColor *markColor;
@property (nonatomic, assign) WRPageMarkDisplayMode displayMode;
// Linked annotations at the same position
@property (nonatomic, strong, nullable) NSArray<WRPageHighlight *> *linkedHighlights;
@property (nonatomic, strong, nullable) NSArray<WRPageUnderline *> *linkedUnderlines;
#pragma mark - Initialization
/// Create a page mark at the given position.
- (instancetype)initWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
chapterOffset:(NSInteger)offset
pageIndex:(NSInteger)pageIndex
chapterTitle:(NSString *)title;
#pragma mark - Conversion (25 methods, reconstructed)
/// Convert to WRBookmark for persistence.
- (WRBookmark *)toBookmark;
/// Create from WRBookmark.
+ (nullable instancetype)fromBookmark:(WRBookmark *)bookmark;
#pragma mark - Linked Annotations
/// Add a highlight linked to this mark.
- (void)addLinkedHighlight:(WRPageHighlight *)highlight;
/// Add an underline linked to this mark.
- (void)addLinkedUnderline:(WRPageUnderline *)underline;
/// Remove a linked highlight by ID.
- (void)removeLinkedHighlightWithId:(NSString *)highlightId;
/// Remove a linked underline by ID.
- (void)removeLinkedUnderlineWithId:(NSString *)underlineId;
/// Return all linked annotation IDs.
- (NSArray<NSString *> *)allLinkedAnnotationIds;
#pragma mark - Display
/// Return the title for display in the bookmark list.
- (NSString *)displayTitle;
/// Return the subtitle/detail text.
- (NSString *)displaySubtitle;
/// Return a formatted date string.
- (NSString *)formattedDate;
/// Return the mark icon image.
- (UIImage *)markIcon;
#pragma mark - Sorting & Filtering
/// Compare marks by position (for sorting in reading order).
- (NSComparisonResult)compareByPosition:(WRPageMark *)other;
/// Compare marks by creation date.
- (NSComparisonResult)compareByDate:(WRPageMark *)other;
/// Check if this mark is in the given chapter.
- (BOOL)isInChapter:(NSString *)chapterUid;
#pragma mark - Serialization
- (NSDictionary *)toDictionary;
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict;
- (nullable NSData *)toJSONData;
+ (nullable instancetype)fromJSONData:(NSData *)data;
#pragma mark - Batch Operations (class methods)
/// Sort an array of marks by position.
+ (NSArray<WRPageMark *> *)marksSortedByPosition:(NSArray<WRPageMark *> *)marks;
/// Sort an array of marks by date.
+ (NSArray<WRPageMark *> *)marksSortedByDate:(NSArray<WRPageMark *> *)marks;
/// Filter marks for a specific chapter.
+ (NSArray<WRPageMark *> *)marksInChapter:(NSString *)chapterUid
fromMarks:(NSArray<WRPageMark *> *)marks;
/// Filter marks within a position range.
+ (NSArray<WRPageMark *> *)marksInRange:(NSRange)range
fromMarks:(NSArray<WRPageMark *> *)marks;
@end
NS_ASSUME_NONNULL_END
+381
View File
@@ -0,0 +1,381 @@
//
// WRPageMark.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// 25 methods identified from binary. WRPageMark is the most comprehensive
// annotation model, handling page bookmarks with linked highlights
// and underlines.
//
#import "WRPageMark.h"
#import "WRBookmark.h"
#import "WRPageHighlight.h"
#import "WRPageUnderline.h"
#pragma mark - Implementation
@implementation WRPageMark
{
NSMutableArray<WRPageHighlight *> *_mutableHighlights;
NSMutableArray<WRPageUnderline *> *_mutableUnderlines;
}
#pragma mark - Initialization
- (instancetype)initWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
chapterOffset:(NSInteger)offset
pageIndex:(NSInteger)pageIndex
chapterTitle:(NSString *)title
{
self = [super init];
if (self) {
_markId = [[NSUUID UUID] UUIDString];
_bookId = [bookId copy];
_chapterUid = [chapterUid copy];
_chapterOffset = offset;
_pageIndex = pageIndex;
_chapterTitle = [title copy];
_createTime = [[NSDate date] timeIntervalSince1970];
_updateTime = _createTime;
_isSynced = NO;
_displayMode = WRPageMarkDisplayModeIcon;
_markColor = [UIColor colorWithRed:0.9 green:0.3 blue:0.2 alpha:1.0];
_mutableHighlights = [NSMutableArray array];
_mutableUnderlines = [NSMutableArray array];
_linkedHighlights = @[];
_linkedUnderlines = @[];
}
return self;
}
#pragma mark - Conversion
- (WRBookmark *)toBookmark
{
WRBookmark *bm = [WRBookmark bookmarkWithBookId:_bookId
chapterUid:_chapterUid
offset:_chapterOffset
text:_excerptText ?: @""
type:WRBookmarkTypeMark];
bm.bookmarkId = _markId;
bm.chapterIndex = _pageIndex;
bm.chapterOffset = _chapterOffset;
bm.createTime = _createTime;
bm.updateTime = _updateTime;
bm.isSynced = _isSynced;
bm.syncKey = _syncKey;
// Store linked annotation info in extra metadata
NSMutableDictionary *meta = [NSMutableDictionary dictionary];
if (_linkedHighlights.count > 0) {
NSMutableArray *ids = [NSMutableArray array];
for (WRPageHighlight *h in _linkedHighlights) {
[ids addObject:h.highlightId];
}
meta[@"linkedHighlightIds"] = ids;
}
if (_linkedUnderlines.count > 0) {
NSMutableArray *ids = [NSMutableArray array];
for (WRPageUnderline *u in _linkedUnderlines) {
[ids addObject:u.underlineId];
}
meta[@"linkedUnderlineIds"] = ids;
}
bm.extraMetadata = meta;
return bm;
}
+ (nullable instancetype)fromBookmark:(WRBookmark *)bookmark
{
if (!bookmark || bookmark.type != WRBookmarkTypeMark) return nil;
WRPageMark *mark = [[WRPageMark alloc]
initWithBookId:bookmark.bookId
chapterUid:bookmark.chapterUid
chapterOffset:bookmark.chapterOffset
pageIndex:bookmark.chapterIndex
chapterTitle:@""]; // Title resolved separately
mark.markId = bookmark.bookmarkId;
mark.excerptText = bookmark.markText;
mark.createTime = bookmark.createTime;
mark.updateTime = bookmark.updateTime;
mark.isSynced = bookmark.isSynced;
mark.syncKey = bookmark.syncKey;
return mark;
}
#pragma mark - Linked Annotations
- (void)addLinkedHighlight:(WRPageHighlight *)highlight
{
if (!highlight) return;
[_mutableHighlights addObject:highlight];
_linkedHighlights = [_mutableHighlights copy];
_updateTime = [[NSDate date] timeIntervalSince1970];
}
- (void)addLinkedUnderline:(WRPageUnderline *)underline
{
if (!underline) return;
[_mutableUnderlines addObject:underline];
_linkedUnderlines = [_mutableUnderlines copy];
_updateTime = [[NSDate date] timeIntervalSince1970];
}
- (void)removeLinkedHighlightWithId:(NSString *)highlightId
{
[_mutableHighlights filterUsingPredicate:
[NSPredicate predicateWithFormat:@"highlightId != %@", highlightId]];
_linkedHighlights = [_mutableHighlights copy];
_updateTime = [[NSDate date] timeIntervalSince1970];
}
- (void)removeLinkedUnderlineWithId:(NSString *)underlineId
{
[_mutableUnderlines filterUsingPredicate:
[NSPredicate predicateWithFormat:@"underlineId != %@", underlineId]];
_linkedUnderlines = [_mutableUnderlines copy];
_updateTime = [[NSDate date] timeIntervalSince1970];
}
- (NSArray<NSString *> *)allLinkedAnnotationIds
{
NSMutableArray *ids = [NSMutableArray array];
for (WRPageHighlight *h in _linkedHighlights) {
[ids addObject:h.highlightId];
}
for (WRPageUnderline *u in _linkedUnderlines) {
[ids addObject:u.underlineId];
}
return [ids copy];
}
#pragma mark - Display
- (NSString *)displayTitle
{
if (_chapterTitle.length > 0) {
return _chapterTitle;
}
if (_excerptText.length > 0) {
return [_excerptText substringToIndex:MIN(50, _excerptText.length)];
}
return [NSString stringWithFormat:@"Page %ld", (long)_pageIndex];
}
- (NSString *)displaySubtitle
{
NSMutableArray *parts = [NSMutableArray array];
if (_linkedHighlights.count > 0) {
[parts addObject:[NSString stringWithFormat:@"%lu highlights",
(unsigned long)_linkedHighlights.count]];
}
if (_linkedUnderlines.count > 0) {
[parts addObject:[NSString stringWithFormat:@"%lu underlines",
(unsigned long)_linkedUnderlines.count]];
}
[parts addObject:[self formattedDate]];
return [parts componentsJoinedByString:@" | "];
}
- (NSString *)formattedDate
{
NSDate *date = [NSDate dateWithTimeIntervalSince1970:_createTime];
NSDateFormatter *fmt = [[NSDateFormatter alloc] init];
fmt.dateFormat = @"yyyy-MM-dd HH:mm";
return [fmt stringFromDate:date];
}
- (UIImage *)markIcon
{
// Generate a bookmark icon image programmatically
CGSize size = CGSizeMake(24, 32);
UIGraphicsBeginImageContextWithOptions(size, NO, 0);
UIColor *color = _markColor ?: [UIColor redColor];
[color setFill];
// Draw a bookmark/flag shape
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(0, 0)];
[path addLineToPoint:CGPointMake(size.width, 0)];
[path addLineToPoint:CGPointMake(size.width, size.height - 6)];
[path addLineToPoint:CGPointMake(size.width / 2, size.height - 12)];
[path addLineToPoint:CGPointMake(0, size.height - 6)];
[path closePath];
[path fill];
UIImage *icon = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return icon;
}
#pragma mark - Sorting & Filtering
- (NSComparisonResult)compareByPosition:(WRPageMark *)other
{
if (![_chapterUid isEqualToString:other.chapterUid]) {
// Compare by chapter order (would need chapter index lookup)
return [_chapterUid compare:other.chapterUid];
}
if (_chapterOffset < other.chapterOffset) return NSOrderedAscending;
if (_chapterOffset > other.chapterOffset) return NSOrderedDescending;
return NSOrderedSame;
}
- (NSComparisonResult)compareByDate:(WRPageMark *)other
{
if (_createTime < other.createTime) return NSOrderedAscending;
if (_createTime > other.createTime) return NSOrderedDescending;
return NSOrderedSame;
}
- (BOOL)isInChapter:(NSString *)chapterUid
{
return [_chapterUid isEqualToString:chapterUid];
}
#pragma mark - Serialization
- (NSDictionary *)toDictionary
{
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
dict[@"markId"] = _markId ?: @"";
dict[@"bookId"] = _bookId ?: @"";
dict[@"chapterUid"] = _chapterUid ?: @"";
dict[@"chapterOffset"] = @(_chapterOffset);
dict[@"pageIndex"] = @(_pageIndex);
dict[@"chapterTitle"] = _chapterTitle ?: @"";
dict[@"excerptText"] = _excerptText ?: @"";
dict[@"createTime"] = @(_createTime);
dict[@"updateTime"] = @(_updateTime);
dict[@"isSynced"] = @(_isSynced);
if (_syncKey) dict[@"syncKey"] = _syncKey;
dict[@"displayMode"] = @(_displayMode);
if (_linkedHighlights.count > 0) {
NSMutableArray *highlights = [NSMutableArray array];
for (WRPageHighlight *h in _linkedHighlights) {
[highlights addObject:@{@"id": h.highlightId, @"text": h.markedText ?: @""}];
}
dict[@"linkedHighlights"] = highlights;
}
if (_linkedUnderlines.count > 0) {
NSMutableArray *underlines = [NSMutableArray array];
for (WRPageUnderline *u in _linkedUnderlines) {
[underlines addObject:@{@"id": u.underlineId, @"text": u.markedText ?: @""}];
}
dict[@"linkedUnderlines"] = underlines;
}
return [dict copy];
}
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict
{
if (!dict) return nil;
WRPageMark *mark = [[WRPageMark alloc]
initWithBookId:dict[@"bookId"]
chapterUid:dict[@"chapterUid"]
chapterOffset:[dict[@"chapterOffset"] integerValue]
pageIndex:[dict[@"pageIndex"] integerValue]
chapterTitle:dict[@"chapterTitle"] ?: @""];
mark.markId = dict[@"markId"];
mark.excerptText = dict[@"excerptText"];
mark.createTime = [dict[@"createTime"] doubleValue];
mark.updateTime = [dict[@"updateTime"] doubleValue];
mark.isSynced = [dict[@"isSynced"] boolValue];
mark.syncKey = dict[@"syncKey"];
mark.displayMode = [dict[@"displayMode"] integerValue];
return mark;
}
- (nullable NSData *)toJSONData
{
NSDictionary *dict = [self toDictionary];
return [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil];
}
+ (nullable instancetype)fromJSONData:(NSData *)data
{
if (!data) return nil;
NSError *error = nil;
NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data
options:0
error:&error];
if (![dict isKindOfClass:[NSDictionary class]]) return nil;
return [self fromDictionary:dict];
}
#pragma mark - Batch Operations
+ (NSArray<WRPageMark *> *)marksSortedByPosition:(NSArray<WRPageMark *> *)marks
{
return [marks sortedArrayUsingSelector:@selector(compareByPosition:)];
}
+ (NSArray<WRPageMark *> *)marksSortedByDate:(NSArray<WRPageMark *> *)marks
{
return [marks sortedArrayUsingSelector:@selector(compareByDate:)];
}
+ (NSArray<WRPageMark *> *)marksInChapter:(NSString *)chapterUid
fromMarks:(NSArray<WRPageMark *> *)marks
{
return [marks filteredArrayUsingPredicate:
[NSPredicate predicateWithFormat:@"chapterUid == %@", chapterUid]];
}
+ (NSArray<WRPageMark *> *)marksInRange:(NSRange)range
fromMarks:(NSArray<WRPageMark *> *)marks
{
NSInteger start = (NSInteger)range.location;
NSInteger end = start + (NSInteger)range.length;
return [marks filteredArrayUsingPredicate:
[NSPredicate predicateWithFormat:
@"chapterOffset >= %ld AND chapterOffset < %ld", start, end]];
}
#pragma mark - NSObject
- (NSString *)description
{
return [NSString stringWithFormat:
@"<WRPageMark: %@ ch=%@ offset=%ld page=%ld highlights=%lu underlines=%lu>",
_markId, _chapterUid, (long)_chapterOffset, (long)_pageIndex,
(unsigned long)_linkedHighlights.count,
(unsigned long)_linkedUnderlines.count];
}
- (BOOL)isEqual:(id)object
{
if (self == object) return YES;
if (![object isKindOfClass:[WRPageMark class]]) return NO;
return [self.markId isEqualToString:((WRPageMark *)object).markId];
}
- (NSUInteger)hash
{
return self.markId.hash;
}
@end
+101
View File
@@ -0,0 +1,101 @@
//
// WRPageUnderline.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Annotation model for text underlines. 10 methods identified from binary.
// Supports multiple underline styles (solid, dashed, wavy, etc.)
// and is associated with a text range in a chapter.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class WRBookmark;
// ---------------------------------------------------------------------------
// Underline styles
// ---------------------------------------------------------------------------
typedef NS_ENUM(NSInteger, WRUnderlineStyle) {
WRUnderlineStyleSolid = 0, // ____
WRUnderlineStyleDashed = 1, // ----
WRUnderlineStyleWavy = 2, // ~~~~
WRUnderlineStyleDotted = 3, // ....
};
// ---------------------------------------------------------------------------
// WRPageUnderline
// ---------------------------------------------------------------------------
@interface WRPageUnderline : NSObject
@property (nonatomic, copy) NSString *underlineId;
@property (nonatomic, copy) NSString *bookId;
@property (nonatomic, copy) NSString *chapterUid;
@property (nonatomic, assign) NSInteger startPos;
@property (nonatomic, assign) NSInteger endPos;
@property (nonatomic, assign) NSInteger chapterOffset;
@property (nonatomic, copy) NSString *markedText;
@property (nonatomic, assign) WRUnderlineStyle style;
@property (nonatomic, strong, nullable) UIColor *color;
@property (nonatomic, assign) NSInteger pageIndex;
@property (nonatomic, assign) NSTimeInterval createTime;
@property (nonatomic, assign) BOOL isSynced;
@property (nonatomic, copy, nullable) NSString *noteContent; // attached note
#pragma mark - Initialization
/// Initialize with text range and style.
- (instancetype)initWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
startPos:(NSInteger)startPos
endPos:(NSInteger)endPos
text:(NSString *)text
style:(WRUnderlineStyle)style;
#pragma mark - Conversion
/// Convert to a WRBookmark for persistence/sync.
- (WRBookmark *)toBookmark;
/// Create from an existing bookmark.
+ (nullable instancetype)fromBookmark:(WRBookmark *)bookmark;
#pragma mark - Drawing
/// Return the underline path for rendering in a given rect.
- (UIBezierPath *)underlinePathForRect:(CGRect)rect;
/// Return the underline color.
- (UIColor *)underlineColor;
#pragma mark - Methods (10 identified from binary, reconstructed)
/// Update the underline style.
- (void)setStyle:(WRUnderlineStyle)style;
/// Update the note content attached to this underline.
- (void)setNote:(NSString *)note;
/// Check if a given position falls within this underline's range.
- (BOOL)containsPosition:(NSInteger)position;
/// Merge with another underline (adjacent ranges).
- (BOOL)mergeWithUnderline:(WRPageUnderline *)other;
/// Return the text range length.
- (NSInteger)rangeLength;
/// Return a serialized dictionary.
- (NSDictionary *)toDictionary;
/// Create from a serialized dictionary.
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict;
/// Compare two underlines for ordering (by start position).
- (NSComparisonResult)compareTo:(WRPageUnderline *)other;
@end
NS_ASSUME_NONNULL_END
+261
View File
@@ -0,0 +1,261 @@
//
// WRPageUnderline.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// 10 methods identified from binary. WRPageUnderline renders underline
// annotations with configurable styles (solid, dashed, wavy, dotted).
//
#import "WRPageUnderline.h"
#import "WRBookmark.h"
#pragma mark - Implementation
@implementation WRPageUnderline
#pragma mark - Initialization
- (instancetype)initWithBookId:(NSString *)bookId
chapterUid:(NSString *)chapterUid
startPos:(NSInteger)startPos
endPos:(NSInteger)endPos
text:(NSString *)text
style:(WRUnderlineStyle)style
{
self = [super init];
if (self) {
_underlineId = [[NSUUID UUID] UUIDString];
_bookId = [bookId copy];
_chapterUid = [chapterUid copy];
_startPos = startPos;
_endPos = endPos;
_chapterOffset = startPos;
_markedText = [text copy];
_style = style;
_color = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.8];
_createTime = [[NSDate date] timeIntervalSince1970];
_isSynced = NO;
}
return self;
}
#pragma mark - Conversion
- (WRBookmark *)toBookmark
{
WRBookmark *bm = [WRBookmark bookmarkWithBookId:_bookId
chapterUid:_chapterUid
offset:_startPos
text:_markedText
type:WRBookmarkTypeUnderline];
bm.bookmarkId = _underlineId;
bm.startPos = _startPos;
bm.endPos = _endPos;
bm.rangeLength = _endPos - _startPos;
bm.chapterOffset = _chapterOffset;
bm.noteContent = _noteContent;
bm.createTime = _createTime;
bm.isSynced = _isSynced;
// Store underline style in extra metadata
bm.extraMetadata = @{@"underlineStyle": @(_style)};
return bm;
}
+ (nullable instancetype)fromBookmark:(WRBookmark *)bookmark
{
if (!bookmark || bookmark.type != WRBookmarkTypeUnderline) return nil;
WRPageUnderline *ul = [[WRPageUnderline alloc]
initWithBookId:bookmark.bookId
chapterUid:bookmark.chapterUid
startPos:bookmark.startPos
endPos:bookmark.endPos
text:bookmark.markText
style:[bookmark.extraMetadata[@"underlineStyle"] integerValue]];
ul.underlineId = bookmark.bookmarkId;
ul.chapterOffset = bookmark.chapterOffset;
ul.noteContent = bookmark.noteContent;
ul.createTime = bookmark.createTime;
ul.isSynced = bookmark.isSynced;
return ul;
}
#pragma mark - Drawing
- (UIBezierPath *)underlinePathForRect:(CGRect)rect
{
UIBezierPath *path = [UIBezierPath bezierPath];
CGFloat y = CGRectGetMaxY(rect) - 2.0; // 2pt below baseline
switch (_style) {
case WRUnderlineStyleSolid: {
[path moveToPoint:CGPointMake(CGRectGetMinX(rect), y)];
[path addLineToPoint:CGPointMake(CGRectGetMaxX(rect), y)];
path.lineWidth = 1.5;
break;
}
case WRUnderlineStyleDashed: {
[path moveToPoint:CGPointMake(CGRectGetMinX(rect), y)];
[path addLineToPoint:CGPointMake(CGRectGetMaxX(rect), y)];
path.lineWidth = 1.5;
// Set dash pattern: 6pt dash, 3pt gap
CGFloat pattern[] = {6.0, 3.0};
[path setLineDash:pattern count:2 phase:0];
break;
}
case WRUnderlineStyleWavy: {
// Wavy underline: sine wave approximation
CGFloat startX = CGRectGetMinX(rect);
CGFloat endX = CGRectGetMaxX(rect);
CGFloat width = endX - startX;
CGFloat amplitude = 2.0;
CGFloat wavelength = 8.0;
[path moveToPoint:CGPointMake(startX, y)];
for (CGFloat x = startX; x < endX; x += 1.0) {
CGFloat progress = (x - startX) / wavelength;
CGFloat waveY = y + sin(progress * M_PI * 2) * amplitude;
[path addLineToPoint:CGPointMake(x, waveY)];
}
path.lineWidth = 1.0;
break;
}
case WRUnderlineStyleDotted: {
[path moveToPoint:CGPointMake(CGRectGetMinX(rect), y)];
[path addLineToPoint:CGPointMake(CGRectGetMaxX(rect), y)];
path.lineWidth = 2.0;
CGFloat pattern[] = {1.0, 4.0};
[path setLineDash:pattern count:2 phase:0];
break;
}
}
return path;
}
- (UIColor *)underlineColor
{
return _color ?: [UIColor darkGrayColor];
}
#pragma mark - Public Methods
- (void)setStyle:(WRUnderlineStyle)style
{
_style = style;
}
- (void)setNote:(NSString *)note
{
_noteContent = [note copy];
}
- (BOOL)containsPosition:(NSInteger)position
{
return position >= _startPos && position < _endPos;
}
- (BOOL)mergeWithUnderline:(WRPageUnderline *)other
{
if (!other) return NO;
// Check if ranges are adjacent or overlapping
if (other.endPos < _startPos - 1 || other.startPos > _endPos + 1) {
return NO; // Not adjacent
}
// Check same book and chapter
if (![_bookId isEqualToString:other.bookId] ||
![_chapterUid isEqualToString:other.chapterUid]) {
return NO;
}
// Merge ranges
_startPos = MIN(_startPos, other.startPos);
_endPos = MAX(_endPos, other.endPos);
// Merge text (preserve order)
if (other.startPos < _startPos) {
_markedText = [other.markedText stringByAppendingString:_markedText];
} else {
_markedText = [_markedText stringByAppendingString:other.markedText];
}
return YES;
}
- (NSInteger)rangeLength
{
return _endPos - _startPos;
}
- (NSDictionary *)toDictionary
{
return @{
@"underlineId" : _underlineId ?: @"",
@"bookId" : _bookId ?: @"",
@"chapterUid" : _chapterUid ?: @"",
@"startPos" : @(_startPos),
@"endPos" : @(_endPos),
@"chapterOffset" : @(_chapterOffset),
@"markedText" : _markedText ?: @"",
@"style" : @(_style),
@"pageIndex" : @(_pageIndex),
@"createTime" : @(_createTime),
@"isSynced" : @(_isSynced),
@"noteContent" : _noteContent ?: @"",
};
}
+ (nullable instancetype)fromDictionary:(NSDictionary *)dict
{
if (!dict) return nil;
WRPageUnderline *ul = [[WRPageUnderline alloc]
initWithBookId:dict[@"bookId"]
chapterUid:dict[@"chapterUid"]
startPos:[dict[@"startPos"] integerValue]
endPos:[dict[@"endPos"] integerValue]
text:dict[@"markedText"]
style:[dict[@"style"] integerValue]];
ul.underlineId = dict[@"underlineId"];
ul.chapterOffset = [dict[@"chapterOffset"] integerValue];
ul.pageIndex = [dict[@"pageIndex"] integerValue];
ul.createTime = [dict[@"createTime"] doubleValue];
ul.isSynced = [dict[@"isSynced"] boolValue];
ul.noteContent = dict[@"noteContent"];
return ul;
}
- (NSComparisonResult)compareTo:(WRPageUnderline *)other
{
if (_startPos < other.startPos) return NSOrderedAscending;
if (_startPos > other.startPos) return NSOrderedDescending;
if (_endPos < other.endPos) return NSOrderedAscending;
if (_endPos > other.endPos) return NSOrderedDescending;
return NSOrderedSame;
}
#pragma mark - NSObject
- (NSString *)description
{
return [NSString stringWithFormat:
@"<WRPageUnderline: %@ style=%ld range=[%ld,%ld] '%@'>",
_underlineId, (long)_style, (long)_startPos, (long)_endPos,
[_markedText substringToIndex:MIN(30, _markedText.length)]];
}
@end
+168
View File
@@ -0,0 +1,168 @@
//
// WRPageView.h
// WeRead (微信读书)
//
// Reverse-engineered header reconstruction.
// The page rendering view. Inherits UIView. Uses CoreText direct drawing
// via drawRect: — does NOT use UILabel or UITextView for body text.
// Handles text selection via CoreText hit testing.
//
#import <UIKit/UIKit.h>
@class WRActivityIndicator;
@class WRLoadingProgressView;
@class WRFriendReviewsButton;
@class WRCoreTextLayoutFrame;
@class WRChapterData;
NS_ASSUME_NONNULL_BEGIN
// ============================================================================
#pragma mark - WRPageViewDelegate
// ============================================================================
@protocol WRPageViewDelegate <NSObject>
@optional
/// Called when the user taps a link or internal reference.
- (void)pageView:(WRPageView *)pageView didTapLinkWithURL:(NSURL *)url;
/// Called when the user long-presses to initiate text selection.
- (void)pageView:(WRPageView *)pageView didBeginSelectionAtPoint:(CGPoint)point;
/// Called when text selection changes.
- (void)pageView:(WRPageView *)pageView selectionDidChangeWithRange:(NSRange)range;
/// Called when the user taps the friend reviews button.
- (void)pageView:(WRPageView *)pageView didTapFriendReviewsWithCount:(NSUInteger)count;
/// Called when the page needs to reload its content.
- (void)pageViewNeedsReload:(WRPageView *)pageView;
@end
// ============================================================================
#pragma mark - WRPageView
// ============================================================================
@interface WRPageView : UIView
// ---- Delegate ----
@property (nonatomic, weak, nullable) id<WRPageViewDelegate> delegate;
// ---- Content ----
/// The chapter data driving this page's content.
@property (nonatomic, strong, nullable) WRChapterData *chapterData;
/// The page index within the chapter (0-based).
@property (nonatomic, assign) NSUInteger pageIndex;
/// The layout frame used for CoreText rendering.
@property (nonatomic, strong, nullable) WRCoreTextLayoutFrame *layoutFrame;
// ---- Ivars reconstructed from binary (NSArray, NSMutableArray, etc.) ----
/// Array of attachment image descriptors (rects, image refs) for inline images.
@property (nonatomic, strong, nullable) NSArray *imageAttachments;
/// Mutable array tracking visible highlight/underline ranges.
@property (nonatomic, strong, nullable) NSMutableArray *visibleHighlights;
/// Timer for auto-read advance.
@property (nonatomic, strong, nullable) NSTimer *autoReadTimer;
/// Timer for loading timeout.
@property (nonatomic, strong, nullable) NSTimer *loadingTimeoutTimer;
/// Current chapter identifier string.
@property (nonatomic, copy, nullable) NSString *chapterId;
// ---- UI Elements (non-text, overlaid on the CoreText layer) ----
/// Header label showing chapter title or page number.
@property (nonatomic, strong, nullable) UILabel *headerLabel;
/// Background/decorative image view.
@property (nonatomic, strong, nullable) UIImageView *backgroundImageView;
/// Loading activity indicator.
@property (nonatomic, strong, nullable) WRActivityIndicator *activityIndicator;
/// Error / empty-state message label.
@property (nonatomic, strong, nullable) UILabel *statusLabel;
/// Retry button shown on error.
@property (nonatomic, strong, nullable) QMUIButton *retryButton;
/// Share button.
@property (nonatomic, strong, nullable) QMUIButton *shareButton;
/// Loading progress view (thin bar at top or bottom).
@property (nonatomic, strong, nullable) WRLoadingProgressView *loadingProgressView;
/// Bookmark toggle button.
@property (nonatomic, strong, nullable) QMUIButton *bookmarkButton;
/// Font size increase button (toolbar).
@property (nonatomic, strong, nullable) QMUIButton *fontSizeUpButton;
/// Font size decrease button (toolbar).
@property (nonatomic, strong, nullable) QMUIButton *fontSizeDownButton;
/// Friend reviews / notes button.
@property (nonatomic, strong, nullable) WRFriendReviewsButton *friendReviewsButton;
// ---- Selection state ----
/// Whether the view is currently in text-selection mode.
@property (nonatomic, assign, readonly) BOOL isSelecting;
/// The currently selected string range (NSNotFound if none).
@property (nonatomic, assign, readonly) NSRange selectedRange;
// ---- CoreText Drawing ----
/// Main drawing entry point. Draws text and inline images directly into the
/// CGContext using CoreText. Called by UIKit from -drawRect:.
- (void)drawInContext:(CGContextRef)context
withData:(WRChapterData *)chapterData
size:(CGSize)size
inRect:(CGRect)rect
position:(CGPoint)position;
// ---- Accessibility ----
/// Overridden to provide a plain-text representation of the page content.
- (nullable NSString *)accessibilityValue;
/// Overridden to support VoiceOver page-scroll gestures.
- (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction;
// ---- Friend Reviews ----
/// Updates the friend reviews button with the current count.
- (void)friendReviewsCount:(NSUInteger)count;
// ---- Loading / Error States ----
/// Shows the loading indicator and optional progress bar.
- (void)showLoadingWithProgress:(float)progress;
/// Hides loading indicators.
- (void)hideLoading;
/// Shows an error state with a message and retry button.
- (void)showErrorWithMessage:(NSString *)message;
// ---- Text Selection (CoreText hit-testing) ----
/// Converts a point in the view to a string index using CTLineGetStringIndexForPosition.
- (NSInteger)stringIndexForPoint:(CGPoint)point;
/// Returns the character range for the line containing the given string index.
- (NSRange)lineRangeForStringIndex:(NSInteger)index;
/// Clears the current selection.
- (void)clearSelection;
@end
NS_ASSUME_NONNULL_END
+645
View File
@@ -0,0 +1,645 @@
//
// WRPageView.m
// WeRead (微信读书)
//
// Detailed pseudo-code reconstruction of the page rendering view.
// This view draws text directly via CoreText into CGContext — no UILabel
// or UITextView is used for the reading content.
//
#import "WRPageView.h"
#import "WRChapterData.h"
#import "WRCoreTextLayoutFrame.h"
#import "WRActivityIndicator.h"
#import "WRLoadingProgressView.h"
#import "WRFriendReviewsButton.h"
// ============================================================================
#pragma mark - Private Helpers
// ============================================================================
/// Extracts a plain-text NSString from a given range of the chapter's
/// attributed string, stripping any attachment characters.
static NSString *WRPlainStringFromRange(NSAttributedString *attrStr, NSRange range) {
if (!attrStr || range.location == NSNotFound) return @"";
NSString *raw = [[attrStr string] substringWithRange:range];
// Remove object replacement characters used for image attachments.
return [raw stringByReplacingOccurrencesOfString:@"" withString:@""];
}
/// Converts a UITouch point from view coordinates to the flipped coordinate
/// system expected by CoreText (origin at bottom-left).
static CGPoint WRSFlipPointForCoreText(CGPoint viewPoint, CGFloat viewHeight) {
return CGPointMake(viewPoint.x, viewHeight - viewPoint.y);
}
// ============================================================================
#pragma mark - WRPageView ()
// ============================================================================
@interface WRPageView ()
// ---- Private selection tracking ----
@property (nonatomic, assign) NSInteger selectionStartIndex;
@property (nonatomic, assign) NSInteger selectionEndIndex;
@property (nonatomic, assign) BOOL isSelecting;
// ---- Gesture recognizers ----
@property (nonatomic, strong) UITapGestureRecognizer *singleTapGR;
@property (nonatomic, strong) UILongPressGestureRecognizer *longPressGR;
@property (nonatomic, strong) UIPanGestureRecognizer *panGR;
// ---- Highlight/underline drawing cache ----
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *highlightRects;
@end
// ============================================================================
#pragma mark - WRPageView Implementation
// ============================================================================
@implementation WRPageView
// ---- Synthesize properties backed by ivars from the binary ----
// The binary shows these ivar types:
// NSArray -> imageAttachments
// NSMutableArray -> visibleHighlights
// NSTimer -> autoReadTimer
// NSTimer -> loadingTimeoutTimer
// NSString -> chapterId
// UILabel -> headerLabel
// UIImageView -> backgroundImageView
// WRActivityIndicator -> activityIndicator
// UILabel -> statusLabel
// QMUIButton -> retryButton
// QMUIButton -> shareButton
// WRLoadingProgressView -> loadingProgressView
// QMUIButton -> bookmarkButton
// QMUIButton -> fontSizeUpButton
// QMUIButton -> fontSizeDownButton
// WRFriendReviewsButton -> friendReviewsButton
// ============================================================================
#pragma mark - Lifecycle
// ============================================================================
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
// CoreText views must be opaque and have a cleared background for
// the CGContext to render correctly.
self.opaque = YES;
self.backgroundColor = [UIColor whiteColor];
self.clearsContextBeforeDrawing = YES;
// Enable multiple touches for selection handles.
self.multipleTouchEnabled = YES;
_selectionStartIndex = NSNotFound;
_selectionEndIndex = NSNotFound;
_isSelecting = NO;
[self _setupSubviews];
[self _setupGestureRecognizers];
}
return self;
}
- (void)dealloc {
[_autoReadTimer invalidate];
[_loadingTimeoutTimer invalidate];
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
// ============================================================================
#pragma mark - Subview Setup
// ============================================================================
/// Creates the non-text overlay UI elements. The reading text itself is
/// rendered entirely in -drawRect: via CoreText, so these are all floating
/// controls layered on top.
- (void)_setupSubviews {
// ---- Background image (e.g., paper texture) ----
_backgroundImageView = [[UIImageView alloc] initWithFrame:self.bounds];
_backgroundImageView.autoresizingMask = UIViewAutoresizingFlexibleWidth |
UIViewAutoresizingFlexibleHeight;
_backgroundImageView.contentMode = UIViewContentModeScaleAspectFill;
[self addSubview:_backgroundImageView];
// ---- Header label (chapter title, page indicator) ----
_headerLabel = [[UILabel alloc] initWithFrame:CGRectZero];
_headerLabel.font = [UIFont systemFontOfSize:12.0];
_headerLabel.textColor = [UIColor grayColor];
_headerLabel.textAlignment = NSTextAlignmentCenter;
[self addSubview:_headerLabel];
// ---- Activity indicator (shown during chapter load) ----
_activityIndicator = [[WRActivityIndicator alloc] initWithFrame:CGRectZero];
_activityIndicator.hidden = YES;
[self addSubview:_activityIndicator];
// ---- Status label (error / empty state) ----
_statusLabel = [[UILabel alloc] initWithFrame:CGRectZero];
_statusLabel.font = [UIFont systemFontOfSize:15.0];
_statusLabel.textColor = [UIColor darkGrayColor];
_statusLabel.textAlignment = NSTextAlignmentCenter;
_statusLabel.numberOfLines = 0;
_statusLabel.hidden = YES;
[self addSubview:_statusLabel];
// ---- Retry button ----
_retryButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_retryButton setTitle:@"重试" forState:UIControlStateNormal]; // "Retry"
[_retryButton addTarget:self
action:@selector(_retryButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
_retryButton.hidden = YES;
[self addSubview:_retryButton];
// ---- Share button ----
_shareButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_shareButton setImage:[UIImage imageNamed:@"icon_share"]
forState:UIControlStateNormal];
[_shareButton addTarget:self
action:@selector(_shareButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
_shareButton.hidden = YES;
[self addSubview:_shareButton];
// ---- Loading progress view ----
_loadingProgressView = [[WRLoadingProgressView alloc] initWithFrame:CGRectZero];
_loadingProgressView.hidden = YES;
[self addSubview:_loadingProgressView];
// ---- Bookmark button ----
_bookmarkButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_bookmarkButton addTarget:self
action:@selector(_bookmarkButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self addSubview:_bookmarkButton];
// ---- Font size buttons ----
_fontSizeUpButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_fontSizeUpButton setTitle:@"A+" forState:UIControlStateNormal];
[_fontSizeUpButton addTarget:self
action:@selector(_fontSizeUpTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self addSubview:_fontSizeUpButton];
_fontSizeDownButton = [QMUIButton buttonWithType:UIButtonTypeSystem];
[_fontSizeDownButton setTitle:@"A-" forState:UIControlStateNormal];
[_fontSizeDownButton addTarget:self
action:@selector(_fontSizeDownTapped:)
forControlEvents:UIControlEventTouchUpInside];
[self addSubview:_fontSizeDownButton];
// ---- Friend reviews button ----
_friendReviewsButton = [[WRFriendReviewsButton alloc] initWithFrame:CGRectZero];
[_friendReviewsButton addTarget:self
action:@selector(_friendReviewsTapped:)
forControlEvents:UIControlEventTouchUpInside];
_friendReviewsButton.hidden = YES;
[self addSubview:_friendReviewsButton];
}
// ============================================================================
#pragma mark - Gesture Recognizers
// ============================================================================
- (void)_setupGestureRecognizers {
// Single tap: link detection, selection dismissal, toolbar toggle.
_singleTapGR = [[UITapGestureRecognizer alloc]
initWithTarget:self action:@selector(_handleSingleTap:)];
_singleTapGR.numberOfTapsRequired = 1;
[self addGestureRecognizer:_singleTapGR];
// Long press: initiate text selection.
_longPressGR = [[UILongPressGestureRecognizer alloc]
initWithTarget:self action:@selector(_handleLongPress:)];
_longPressGR.minimumPressDuration = 0.5;
[self addGestureRecognizer:_longPressGR];
// Pan: extend selection after long press.
_panGR = [[UIPanGestureRecognizer alloc]
initWithTarget:self action:@selector(_handlePan:)];
_panGR.enabled = NO; // Enabled only when selecting.
[self addGestureRecognizer:_panGR];
// Long press should fail before single tap fires.
[_singleTapGR requireGestureRecognizerToFail:_longPressGR];
}
// ============================================================================
#pragma mark - Layout
// ============================================================================
- (void)layoutSubviews {
[super layoutSubviews];
// Position the header label at the top edge with padding.
CGFloat headerHeight = 20.0;
_headerLabel.frame = CGRectMake(16.0, 8.0,
CGRectGetWidth(self.bounds) - 32.0,
headerHeight);
// Center the activity indicator.
_activityIndicator.center = CGPointMake(CGRectGetMidX(self.bounds),
CGRectGetMidY(self.bounds));
// Position the friend reviews button at bottom-right.
CGSize reviewSize = CGSizeMake(60.0, 30.0);
_friendReviewsButton.frame =
CGRectMake(CGRectGetWidth(self.bounds) - reviewSize.width - 16.0,
CGRectGetHeight(self.bounds) - reviewSize.height - 40.0,
reviewSize.width, reviewSize.height);
}
// ============================================================================
#pragma mark - CoreText Drawing (drawRect:)
// ============================================================================
///
/// This is the heart of the page view. It draws the chapter text directly
/// into the CGContext using CoreText. No UILabel or UITextView is involved.
///
/// The flow is:
/// 1. Get the current graphics context.
/// 2. Flip the coordinate system for CoreText (origin at bottom-left).
/// 3. Call WRCoreTextLayoutFrame to draw the attributed string runs.
/// 4. Draw inline images at their attachment positions.
/// 5. Draw highlight/underline overlays.
///
- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
if (!ctx) return;
// If there is no chapter data or layout frame, just clear and return.
if (!self.chapterData || !self.layoutFrame) {
CGContextClearRect(ctx, rect);
return;
}
// ---- Step 1: Fill background ----
UIColor *bgColor = self.backgroundColor ?: [UIColor whiteColor];
CGContextSetFillColorWithColor(ctx, bgColor.CGColor);
CGContextFillRect(ctx, rect);
// ---- Step 2: Flip coordinate system for CoreText ----
// CoreText uses a bottom-left origin; UIKit uses top-left.
CGContextSetTextMatrix(ctx, CGAffineTransformIdentity);
CGContextTranslateCTM(ctx, 0.0, CGRectGetHeight(self.bounds));
CGContextScaleCTM(ctx, 1.0, -1.0);
// ---- Step 3: Draw the layout frame ----
// This calls WRCoreTextLayoutFrame -drawInContext:image:size:inRect:position:
// which iterates over CTLine objects, draws glyphs, and positions images.
CGSize pageSize = self.bounds.size;
CGPoint drawOrigin = CGPointMake(0.0, 0.0); // Could include margins.
CGRect drawRect = UIEdgeInsetsInsetRect(self.bounds,
self.chapterData.contentInsets);
[self.layoutFrame drawInContext:ctx
image:self.backgroundImageView.image
size:pageSize
inRect:drawRect
position:drawOrigin];
// ---- Step 4: Draw highlights and underlines ----
[self _drawHighlightsInContext:ctx];
// ---- Step 5: Draw selection handles if selecting ----
if (self.isSelecting) {
[self _drawSelectionInContext:ctx];
}
}
/// Draws highlight rectangles and underline paths for annotations,
/// bookmarks, and the current selection.
- (void)_drawHighlightsInContext:(CGContextRef)ctx {
for (NSDictionary *entry in self.highlightRects) {
CGRect hlRect = [entry[@"rect"] CGRectValue];
UIColor *color = entry[@"color"] ?: [UIColor yellowColor];
BOOL isUnderline = [entry[@"underline"] boolValue];
CGContextSetFillColorWithColor(ctx,
[color colorWithAlphaComponent:0.3].CGColor);
CGContextFillRect(ctx, hlRect);
if (isUnderline) {
CGFloat y = CGRectGetMaxY(hlRect);
CGContextSetStrokeColorWithColor(ctx, color.CGColor);
CGContextSetLineWidth(ctx, 1.0);
CGContextMoveToPoint(ctx, CGRectGetMinX(hlRect), y);
CGContextAddLineToPoint(ctx, CGRectGetMaxX(hlRect), y);
CGContextStrokePath(ctx);
}
}
}
/// Draws the selection highlight between selectionStartIndex and
/// selectionEndIndex using CTLineGetOffsetForStringIndex.
- (void)_drawSelectionInContext:(CGContextRef)ctx {
if (_selectionStartIndex == NSNotFound || _selectionEndIndex == NSNotFound) {
return;
}
// Clamp range.
NSInteger lo = MIN(_selectionStartIndex, _selectionEndIndex);
NSInteger hi = MAX(_selectionStartIndex, _selectionEndIndex);
NSRange selRange = NSMakeRange((NSUInteger)lo, (NSUInteger)(hi - lo));
// Ask the layout frame for the rects covering this range.
NSArray<NSValue *> *rects = [self.layoutFrame rectsWithinRange:selRange];
UIColor *selColor = [UIColor colorWithRed:0.2 green:0.5 blue:1.0 alpha:0.3];
for (NSValue *val in rects) {
CGRect r = [val CGRectValue];
CGContextSetFillColorWithColor(ctx, selColor.CGColor);
CGContextFillRect(ctx, r);
}
}
// ============================================================================
#pragma mark - Drawing API (called externally)
// ============================================================================
/// External entry point for drawing. Delegates to the layout frame.
- (void)drawInContext:(CGContextRef)context
withData:(WRChapterData *)chapterData
size:(CGSize)size
inRect:(CGRect)rect
position:(CGPoint)position {
// Store references so -drawRect: can use them.
self.chapterData = chapterData;
// Trigger a redraw.
[self setNeedsDisplay];
}
// ============================================================================
#pragma mark - Text Selection via CoreText Hit Testing
// ============================================================================
/// Converts a view-coordinate point to a string index in the attributed string
/// using CTLineGetStringIndexForPosition.
- (NSInteger)stringIndexForPoint:(CGPoint)point {
if (!self.layoutFrame) return NSNotFound;
// Flip Y for CoreText.
CGPoint ctPoint = WRSFlipPointForCoreText(point, CGRectGetHeight(self.bounds));
// Walk the CTLine objects in the layout frame to find the line at this Y,
// then use CTLineGetStringIndexForPosition to get the character index.
NSInteger index = [self.layoutFrame stringIndexForPoint:ctPoint];
return index;
}
/// Returns the range of the line containing the given string index,
/// using CTLineGetOffsetForStringIndex to find line boundaries.
- (NSRange)lineRangeForStringIndex:(NSInteger)index {
if (!self.layoutFrame || index == NSNotFound) {
return NSMakeRange(NSNotFound, 0);
}
return [self.layoutFrame lineRangeForStringIndex:index];
}
/// Clears the current text selection and disables the pan gesture.
- (void)clearSelection {
_selectionStartIndex = NSNotFound;
_selectionEndIndex = NSNotFound;
_isSelecting = NO;
_panGR.enabled = NO;
[self setNeedsDisplay];
}
// ============================================================================
#pragma mark - Gesture Handlers
// ============================================================================
- (void)_handleSingleTap:(UITapGestureRecognizer *)gr {
CGPoint pt = [gr locationInView:self];
// If currently selecting, dismiss selection.
if (self.isSelecting) {
[self clearSelection];
return;
}
// Check if the tap hits a link in the layout frame.
NSInteger idx = [self stringIndexForPoint:pt];
if (idx != NSNotFound) {
NSURL *linkURL = [self.layoutFrame linkURLAtIndex:idx];
if (linkURL) {
if ([self.delegate respondsToSelector:@selector(pageView:didTapLinkWithURL:)]) {
[self.delegate pageView:self didTapLinkWithURL:linkURL];
}
return;
}
}
// Otherwise, toggle toolbar / delegate the tap.
// (In the real app, this toggles the reader chrome.)
}
- (void)_handleLongPress:(UILongPressGestureRecognizer *)gr {
if (gr.state == UIGestureRecognizerStateBegan) {
CGPoint pt = [gr locationInView:self];
NSInteger idx = [self stringIndexForPoint:pt];
if (idx != NSNotFound) {
_selectionStartIndex = idx;
_selectionEndIndex = idx;
_isSelecting = YES;
_panGR.enabled = YES;
[self setNeedsDisplay];
if ([self.delegate respondsToSelector:@selector(pageView:didBeginSelectionAtPoint:)]) {
[self.delegate pageView:self didBeginSelectionAtPoint:pt];
}
}
}
}
- (void)_handlePan:(UIPanGestureRecognizer *)gr {
if (!self.isSelecting) return;
CGPoint pt = [gr locationInView:self];
NSInteger idx = [self stringIndexForPoint:pt];
if (idx != NSNotFound && idx != _selectionEndIndex) {
_selectionEndIndex = idx;
[self setNeedsDisplay];
// Notify delegate of selection range change.
NSInteger lo = MIN(_selectionStartIndex, _selectionEndIndex);
NSInteger hi = MAX(_selectionStartIndex, _selectionEndIndex);
NSRange selRange = NSMakeRange((NSUInteger)lo, (NSUInteger)(hi - lo));
if ([self.delegate respondsToSelector:@selector(pageView:selectionDidChangeWithRange:)]) {
[self.delegate pageView:self selectionDidChangeWithRange:selRange];
}
}
}
// ============================================================================
#pragma mark - Accessibility
// ============================================================================
/// Provides a plain-text representation of the page for VoiceOver.
- (nullable NSString *)accessibilityValue {
if (!self.chapterData) return nil;
NSAttributedString *attrStr = self.chapterData.typesetAttributedString;
if (!attrStr) return nil;
// Return the plain text for the current page range.
NSRange pageRange = [self.chapterData rangeOfPage:self.pageIndex];
return WRPlainStringFromRange(attrStr, pageRange);
}
/// Supports VoiceOver scroll gestures to flip pages.
- (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction {
// Post a page-change notification for the WRPageViewController to handle.
NSString *dirStr = nil;
switch (direction) {
case UIAccessibilityScrollDirectionLeft:
dirStr = @"next";
break;
case UIAccessibilityScrollDirectionRight:
dirStr = @"previous";
break;
default:
return NO;
}
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewAccessibilityScroll"
object:self
userInfo:@{@"direction": dirStr}];
return YES;
}
// ============================================================================
#pragma mark - Friend Reviews
// ============================================================================
- (void)friendReviewsCount:(NSUInteger)count {
self.friendReviewsButton.hidden = (count == 0);
[self.friendReviewsButton setReviewCount:count];
}
// ============================================================================
#pragma mark - Loading / Error States
// ============================================================================
- (void)showLoadingWithProgress:(float)progress {
self.activityIndicator.hidden = NO;
[self.activityIndicator startAnimating];
if (progress > 0.0 && progress < 1.0) {
self.loadingProgressView.hidden = NO;
self.loadingProgressView.progress = progress;
} else {
self.loadingProgressView.hidden = YES;
}
// Start a timeout timer — if loading takes too long, show an error.
[_loadingTimeoutTimer invalidate];
_loadingTimeoutTimer =
[NSTimer scheduledTimerWithTimeInterval:15.0
target:self
selector:@selector(_loadingDidTimeout)
userInfo:nil
repeats:NO];
}
- (void)hideLoading {
[_loadingTimeoutTimer invalidate];
_loadingTimeoutTimer = nil;
[self.activityIndicator stopAnimating];
self.activityIndicator.hidden = YES;
self.loadingProgressView.hidden = YES;
}
- (void)showErrorWithMessage:(NSString *)message {
[self hideLoading];
self.statusLabel.text = message;
self.statusLabel.hidden = NO;
self.retryButton.hidden = NO;
}
- (void)_loadingDidTimeout {
[self showErrorWithMessage:@"加载超时,请重试"]; // "Loading timed out, please retry"
}
// ============================================================================
#pragma mark - Button Actions
// ============================================================================
- (void)_retryButtonTapped:(QMUIButton *)sender {
self.statusLabel.hidden = YES;
self.retryButton.hidden = YES;
if ([self.delegate respondsToSelector:@selector(pageViewNeedsReload:)]) {
[self.delegate pageViewNeedsReload:self];
}
}
- (void)_shareButtonTapped:(QMUIButton *)sender {
// Share current page content.
// Handled by delegate or responder chain.
}
- (void)_bookmarkButtonTapped:(QMUIButton *)sender {
// Toggle bookmark for current page.
sender.selected = !sender.selected;
}
- (void)_fontSizeUpTapped:(QMUIButton *)sender {
// Increase font size — triggers re-typeset.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewFontSizeChange"
object:self
userInfo:@{@"delta": @(1)}];
}
- (void)_fontSizeDownTapped:(QMUIButton *)sender {
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewFontSizeChange"
object:self
userInfo:@{@"delta": @(-1)}];
}
- (void)_friendReviewsTapped:(QMUIButton *)sender {
if ([self.delegate respondsToSelector:@selector(pageView:didTapFriendReviewsWithCount:)]) {
[self.delegate pageView:self
didTapFriendReviewsWithCount:sender.tag];
}
}
// ============================================================================
#pragma mark - Auto-Read Timer
// ============================================================================
/// Starts the auto-read timer that periodically advances the page.
- (void)startAutoReadWithInterval:(NSTimeInterval)interval {
[_autoReadTimer invalidate];
_autoReadTimer =
[NSTimer scheduledTimerWithTimeInterval:interval
target:self
selector:@selector(_autoReadTick)
userInfo:nil
repeats:YES];
}
- (void)stopAutoRead {
[_autoReadTimer invalidate];
_autoReadTimer = nil;
}
- (void)_autoReadTick {
// Post notification for the page view controller to advance.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewAutoReadAdvance"
object:self];
}
@end
@@ -0,0 +1,175 @@
//
// WRPageViewController.h
// WeRead (微信读书)
//
// Reverse-engineered header reconstruction.
// Based on UIPageViewController. Supports UIPageCurl (simulation) and
// Scroll (slide) page turning styles. Includes fault detection and
// patching mechanisms for known UIPageViewController bugs.
//
#import <UIKit/UIKit.h>
@class WRPageView;
@class WRChapterData;
NS_ASSUME_NONNULL_BEGIN
// ============================================================================
#pragma mark - Page Flipping Style
// ============================================================================
/// The visual style used when transitioning between pages.
typedef NS_ENUM(NSInteger, WRPageFlippingStyle) {
WRPageFlippingStyleCurl = 0, // UIPageCurl simulation (paper fold)
WRPageFlippingStyleSlide = 1, // UIScrollView-based horizontal slide
WRPageFlippingStyleFade = 2, // Crossfade transition
WRPageFlippingStyleNone = 3, // Instant switch, no animation
};
// ============================================================================
#pragma mark - WRPageViewControllerDelegate
// ============================================================================
@protocol WRPageViewControllerDelegate <NSObject>
@optional
/// Called when the page view controller transitions to a new page.
- (void)pageViewController:(WRPageViewController *)pageViewController
didFinishAnimating:(BOOL)finished
previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers
transitionCompleted:(BOOL)completed;
/// Called before a page transition begins.
- (void)pageViewController:(WRPageViewController *)pageViewController
willTransitionToViewControllers:(NSArray<UIViewController *> *)pendingViewControllers;
/// Called to determine the spine location for interface orientation changes.
- (UIPageViewControllerSpineLocation)pageViewController:(WRPageViewController *)pageViewController
spineLocationForInterfaceOrientation:(UIInterfaceOrientation)orientation;
/// Called when the current page index changes.
- (void)pageViewController:(WRPageViewController *)pageViewController
didChangePageIndex:(NSUInteger)pageIndex;
@end
// ============================================================================
#pragma mark - WRPageViewControllerDataSource
// ============================================================================
@protocol WRPageViewControllerDataSource <NSObject>
/// Returns the view controller before the given one (for right-to-left paging).
- (nullable UIViewController *)pageViewController:(WRPageViewController *)pageViewController
viewControllerBeforeViewController:(UIViewController *)viewController;
/// Returns the view controller after the given one (for left-to-right paging).
- (nullable UIViewController *)pageViewController:(WRPageViewController *)pageViewController
viewControllerAfterViewController:(UIViewController *)viewController;
@end
// ============================================================================
#pragma mark - WRPageViewController
// ============================================================================
@interface WRPageViewController : UIViewController
// ---- Delegates ----
@property (nonatomic, weak, nullable) id<WRPageViewControllerDelegate> pageDelegate;
@property (nonatomic, weak, nullable) id<WRPageViewControllerDataSource> pageDataSource;
// ---- Configuration ----
/// The current page flipping style.
@property (nonatomic, assign) WRPageFlippingStyle pageFlippingStyle;
/// Whether the user can interact to change pages.
@property (nonatomic, assign) BOOL pagingEnabled;
/// The underlying UIPageViewController used for page curl and scroll styles.
@property (nonatomic, strong, readonly) UIPageViewController *uiPageViewController;
// ---- State ----
/// The currently visible page index (0-based within the current chapter).
@property (nonatomic, assign, readonly) NSUInteger currentPageIndex;
/// Whether a page transition is currently in progress.
@property (nonatomic, assign, readonly) BOOL isTransitioning;
// ============================================================================
#pragma mark - Initialization
// ============================================================================
/// Designated initializer.
/// @param delegate The page delegate.
/// @param pageType The UIPageViewControllerTransitionStyle (curl or scroll).
/// @param flippingStyle The WRPageFlippingStyle to use.
- (instancetype)initWithDelegate:(id<WRPageViewControllerDelegate>)delegate
withPageType:(UIPageViewControllerTransitionStyle)pageType
pageFlippingStyle:(WRPageFlippingStyle)flippingStyle;
// ============================================================================
#pragma mark - Page Navigation
// ============================================================================
/// Sets the current page index, optionally animated.
- (void)setCurrentPageIndex:(NSUInteger)pageIndex animated:(BOOL)animated;
/// Advances to the next page. Returns YES if successful, NO if at the end.
- (BOOL)goToNextPageAnimated:(BOOL)animated;
/// Goes back to the previous page. Returns YES if successful, NO if at the beginning.
- (BOOL)goToPreviousPageAnimated:(BOOL)animated;
/// Replaces the currently displayed view controllers.
- (void)setViewControllers:(NSArray<UIViewController *> *)viewControllers
direction:(UIPageViewControllerNavigationDirection)direction
animated:(BOOL)animated
completion:(void (^ __nullable)(BOOL finished))completion;
// ============================================================================
#pragma mark - Fault Detection & Patching
// ============================================================================
///
/// Detects a navigation direction crash in UIPageViewController's
/// queuingScrollView:didScrollWithAnimation: callback.
///
/// This addresses a known Apple bug where the internal
/// UIPageViewControllerQueuingScrollView can enter an inconsistent state
/// and crash with an invalid navigation direction.
///
/// @param pageVC The UIPageViewController to check.
/// @param queuingScrollView The internal queuing scroll view (if accessible).
/// @param methodDidScroll Whether this is called from the didScroll callback.
/// @param force Force the detection even if already patched.
/// @param logDetail Whether to log detailed diagnostic info.
/// @param logCrashReason Whether to log the crash reason string.
/// @return YES if a fault was detected (and hopefully patched).
///
+ (BOOL)detectNavigationDirectionCrashWithPageViewController:(UIPageViewController *)pageVC
queuingScrollView:(UIScrollView *)queuingScrollView
inMethodDidScrollWithAnimation:(BOOL)methodDidScroll
force:(BOOL)force
logDetail:(BOOL)logDetail
logCrashReason:(BOOL)logCrashReason;
///
/// Patches the navigation direction fault by swizzling or resetting
/// the internal state of UIPageViewController.
+ (void)patchNavigationDirectionFault;
///
/// Patches the "no view controller managing page view" fault.
/// This occurs when UIPageViewController loses track of its child VCs.
+ (void)patchNoViewControllerManagingPageViewFault;
///
/// Patches the UIPageCurl animation fault that can cause visual glitches
/// or crashes during rapid page flipping.
+ (void)patchUIPageCurlFault;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,602 @@
//
// WRPageViewController.m
// WeRead (微信读书)
//
// Detailed pseudo-code reconstruction of the page view controller.
// Wraps UIPageViewController with fault detection and patching for
// known Apple bugs in the page curl and scroll transition styles.
//
#import "WRPageViewController.h"
#import <objc/runtime.h>
// ============================================================================
#pragma mark - Constants
// ============================================================================
/// Notification posted when a page transition completes.
static NSString *const kWRPageDidTransitionNotification =
@"WRPageViewControllerDidTransition";
/// UserDefaults key to track whether the nav-direction fault has been patched.
static NSString *const kNavDirectionPatchedKey =
@"WRPageVC_NavDirectionPatched";
// ============================================================================
#pragma mark - Private Forward Declarations
// ============================================================================
@interface WRPageViewController () <UIPageViewControllerDelegate,
UIPageViewControllerDataSource,
UIGestureRecognizerDelegate>
@property (nonatomic, strong) UIPageViewController *uiPageViewController;
@property (nonatomic, assign) NSUInteger currentPageIndex;
@property (nonatomic, assign) BOOL isTransitioning;
/// Pending completion block for -setViewControllers:direction:animated:completion:.
@property (nonatomic, copy, nullable) void (^pendingTransitionCompletion)(BOOL);
@end
// ============================================================================
#pragma mark - Fault Patching State
// ============================================================================
/// Static flag: whether the navigation direction fault has been detected
/// in this process lifetime.
static BOOL s_NavDirectionFaultDetected = NO;
/// Static flag: whether the page curl fault has been patched.
static BOOL s_PageCurlFaultPatched = NO;
// ============================================================================
#pragma mark - WRPageViewController Implementation
// ============================================================================
@implementation WRPageViewController
// ============================================================================
#pragma mark - Initialization
// ============================================================================
- (instancetype)initWithDelegate:(id<WRPageViewControllerDelegate>)delegate
withPageType:(UIPageViewControllerTransitionStyle)pageType
pageFlippingStyle:(WRPageFlippingStyle)flippingStyle {
self = [super initWithNibName:nil bundle:nil];
if (self) {
_pageDelegate = delegate;
_pageFlippingStyle = flippingStyle;
_pagingEnabled = YES;
_currentPageIndex = 0;
_isTransitioning = NO;
// Determine the UIPageViewController transition style.
UIPageViewControllerTransitionStyle uiStyle;
switch (flippingStyle) {
case WRPageFlippingStyleCurl:
uiStyle = UIPageViewControllerTransitionStylePageCurl;
break;
case WRPageFlippingStyleSlide:
case WRPageFlippingStyleFade:
case WRPageFlippingStyleNone:
default:
uiStyle = UIPageViewControllerTransitionStyleScroll;
break;
}
// Create the underlying UIPageViewController.
NSDictionary *options = @{
UIPageViewControllerOptionInterPageSpacingKey: @(20.0),
};
_uiPageViewController =
[[UIPageViewController alloc] initWithTransitionStyle:uiStyle
navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal
options:options];
_uiPageViewController.delegate = self;
_uiPageViewController.dataSource = self;
// Apply known fault patches proactively.
[WRPageViewController patchNavigationDirectionFault];
if (flippingStyle == WRPageFlippingStyleCurl) {
[WRPageViewController patchUIPageCurlFault];
}
}
return self;
}
- (void)viewDidLoad {
[super viewDidLoad];
// Embed the UIPageViewController as a child.
[self addChildViewController:self.uiPageViewController];
self.uiPageViewController.view.frame = self.view.bounds;
self.uiPageViewController.view.autoresizingMask =
UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[self.view addSubview:self.uiPageViewController.view];
[self.uiPageViewController didMoveToParentViewController:self];
// Configure gesture recognizers.
[self _configureGestureRecognizers];
}
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
self.uiPageViewController.view.frame = self.view.bounds;
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
// ============================================================================
#pragma mark - Gesture Recognizer Configuration
// ============================================================================
/// Configures the page view controller's gesture recognizers.
/// In scroll mode, the internal UIScrollView handles paging.
/// In curl mode, the tap-to-flip gesture is added.
- (void)_configureGestureRecognizers {
if (self.pageFlippingStyle == WRPageFlippingStyleCurl) {
// Add tap zones for curl: left 1/4 and right 1/4 of the screen.
UITapGestureRecognizer *leftTap =
[[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(_handleLeftTap:)];
leftTap.delegate = self;
[self.view addGestureRecognizer:leftTap];
UITapGestureRecognizer *rightTap =
[[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(_handleRightTap:)];
rightTap.delegate = self;
[self.view addGestureRecognizer:rightTap];
}
}
- (void)_handleLeftTap:(UITapGestureRecognizer *)gr {
[self goToPreviousPageAnimated:YES];
}
- (void)_handleRightTap:(UITapGestureRecognizer *)gr {
[self goToNextPageAnimated:YES];
}
// ============================================================================
#pragma mark - Page Navigation
// ============================================================================
- (void)setCurrentPageIndex:(NSUInteger)pageIndex animated:(BOOL)animated {
if (pageIndex == _currentPageIndex) return;
UIPageViewControllerNavigationDirection direction =
(pageIndex > _currentPageIndex)
? UIPageViewControllerNavigationDirectionForward
: UIPageViewControllerNavigationDirectionReverse;
_currentPageIndex = pageIndex;
// Notify delegate.
if ([self.pageDelegate respondsToSelector:
@selector(pageViewController:didChangePageIndex:)]) {
[self.pageDelegate pageViewController:self didChangePageIndex:pageIndex];
}
}
- (BOOL)goToNextPageAnimated:(BOOL)animated {
if (!self.pagingEnabled || self.isTransitioning) return NO;
// Ask the data source for the next view controller.
UIViewController *currentVC = self.uiPageViewController.viewControllers.firstObject;
if (!currentVC) return NO;
UIViewController *nextVC =
[self.pageDataSource pageViewController:self
viewControllerAfterViewController:currentVC];
if (!nextVC) return NO; // At the end.
self.isTransitioning = YES;
__weak typeof(self) weakSelf = self;
[self.uiPageViewController setViewControllers:@[nextVC]
direction:UIPageViewControllerNavigationDirectionForward
animated:animated
completion:^(BOOL finished) {
__strong typeof(weakSelf) strongSelf = weakSelf;
strongSelf.isTransitioning = NO;
if (finished) {
strongSelf.currentPageIndex++;
}
}];
return YES;
}
- (BOOL)goToPreviousPageAnimated:(BOOL)animated {
if (!self.pagingEnabled || self.isTransitioning) return NO;
UIViewController *currentVC = self.uiPageViewController.viewControllers.firstObject;
if (!currentVC) return NO;
UIViewController *prevVC =
[self.pageDataSource pageViewController:self
viewControllerBeforeViewController:currentVC];
if (!prevVC) return NO; // At the beginning.
self.isTransitioning = YES;
__weak typeof(self) weakSelf = self;
[self.uiPageViewController setViewControllers:@[prevVC]
direction:UIPageViewControllerNavigationDirectionReverse
animated:animated
completion:^(BOOL finished) {
__strong typeof(weakSelf) strongSelf = weakSelf;
strongSelf.isTransitioning = NO;
if (finished && strongSelf.currentPageIndex > 0) {
strongSelf.currentPageIndex--;
}
}];
return YES;
}
- (void)setViewControllers:(NSArray<UIViewController *> *)viewControllers
direction:(UIPageViewControllerNavigationDirection)direction
animated:(BOOL)animated
completion:(void (^ __nullable)(BOOL finished))completion {
self.pendingTransitionCompletion = completion;
__weak typeof(self) weakSelf = self;
[self.uiPageViewController setViewControllers:viewControllers
direction:direction
animated:animated
completion:^(BOOL finished) {
__strong typeof(weakSelf) strongSelf = weakSelf;
strongSelf.isTransitioning = NO;
if (strongSelf.pendingTransitionCompletion) {
strongSelf.pendingTransitionCompletion(finished);
strongSelf.pendingTransitionCompletion = nil;
}
}];
}
// ============================================================================
#pragma mark - UIPageViewControllerDataSource
// ============================================================================
- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController
viewControllerBeforeViewController:(UIViewController *)viewController {
// Delegate to our data source.
if ([self.pageDataSource respondsToSelector:
@selector(pageViewController:viewControllerBeforeViewController:)]) {
return [self.pageDataSource pageViewController:self
viewControllerBeforeViewController:viewController];
}
return nil;
}
- (UIViewController *)pageViewController:(UIPageViewController *)pageViewController
viewControllerAfterViewController:(UIViewController *)viewController {
if ([self.pageDataSource respondsToSelector:
@selector(pageViewController:viewControllerAfterViewController:)]) {
return [self.pageDataSource pageViewController:self
viewControllerAfterViewController:viewController];
}
return nil;
}
// ============================================================================
#pragma mark - UIPageViewControllerDelegate
// ============================================================================
- (void)pageViewController:(UIPageViewController *)pageViewController
willTransitionToViewControllers:(NSArray<UIViewController *> *)pendingViewControllers {
self.isTransitioning = YES;
if ([self.pageDelegate respondsToSelector:
@selector(pageViewController:willTransitionToViewControllers:)]) {
[self.pageDelegate pageViewController:self
willTransitionToViewControllers:pendingViewControllers];
}
}
- (void)pageViewController:(UIPageViewController *)pageViewController
didFinishAnimating:(BOOL)finished
previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers
transitionCompleted:(BOOL)completed {
self.isTransitioning = NO;
if (completed) {
// Detect the navigation direction fault.
// This is called after every completed transition to check if
// UIPageViewController's internal state is still consistent.
[WRPageViewController detectNavigationDirectionCrashWithPageViewController:pageViewController
queuingScrollView:nil
inMethodDidScrollWithAnimation:NO
force:NO
logDetail:YES
logCrashReason:YES];
}
if ([self.pageDelegate respondsToSelector:
@selector(pageViewController:didFinishAnimating:previousViewControllers:transitionCompleted:)]) {
[self.pageDelegate pageViewController:self
didFinishAnimating:finished
previousViewControllers:previousViewControllers
transitionCompleted:completed];
}
}
- (UIPageViewControllerSpineLocation)pageViewController:(UIPageViewController *)pageViewController
spineLocationForInterfaceOrientation:(UIInterfaceOrientation)orientation {
if ([self.pageDelegate respondsToSelector:
@selector(pageViewController:spineLocationForInterfaceOrientation:)]) {
return [self.pageDelegate pageViewController:self
spineLocationForInterfaceOrientation:orientation];
}
return UIPageViewControllerSpineLocationMin;
}
// ============================================================================
#pragma mark - Fault Detection & Patching
// ============================================================================
///
/// Detects the navigation direction crash in UIPageViewController.
///
/// BACKGROUND:
/// UIPageViewController uses an internal "QueuingScrollView" (a private
/// UIScrollView subclass) to manage page transitions. During rapid swiping
/// or when the app enters the background during a transition, the internal
/// state machine can enter an inconsistent state where:
/// - The navigation direction is set to an invalid value.
/// - The queuing scroll view's contentOffset is inconsistent with the
/// current view controller set.
/// - A subsequent -scrollViewDidScroll: callback accesses deallocated
/// or nil view controllers.
///
/// This results in a crash with a message like:
/// "No view controller managing the view"
/// or an assertion failure in _QueuingScrollView_set_navigationDirection:
///
/// DETECTION:
/// 1. Check if the UIPageViewController's internal _queuingScrollView exists.
/// 2. Verify its contentOffset is within expected bounds.
/// 3. Verify the number of view controllers in the queue matches expectations.
/// 4. If any check fails, set s_NavDirectionFaultDetected = YES.
///
/// PATCHING:
/// If a fault is detected, we:
/// 1. Reset the queuing scroll view's contentOffset to a known good state.
/// 2. Remove and re-add the current view controllers to force a state reset.
/// 3. Log the fault for crash analytics.
///
+ (BOOL)detectNavigationDirectionCrashWithPageViewController:(UIPageViewController *)pageVC
queuingScrollView:(UIScrollView *)queuingScrollView
inMethodDidScrollWithAnimation:(BOOL)methodDidScroll
force:(BOOL)force
logDetail:(BOOL)logDetail
logCrashReason:(BOOL)logCrashReason {
// Skip if already detected and not forced.
if (s_NavDirectionFaultDetected && !force) return NO;
// ---- Access the private _queuingScrollView ----
UIScrollView *scrollView = queuingScrollView;
if (!scrollView) {
// Try to access via KVC (private API).
@try {
scrollView = [pageVC valueForKey:@"_queuingScrollView"];
} @catch (NSException *e) {
if (logCrashReason) {
NSLog(@"[WRPageVC] Could not access _queuingScrollView: %@", e);
}
return NO;
}
}
if (!scrollView) return NO;
// ---- Check 1: Content offset bounds ----
CGFloat contentWidth = scrollView.contentSize.width;
CGFloat offsetX = scrollView.contentOffset.x;
CGFloat frameWidth = scrollView.bounds.size.width;
// The content offset should be a multiple of the frame width
// (one page at a time). If it's in between, the state is inconsistent.
CGFloat pageOffset = offsetX / frameWidth;
CGFloat fractional = fabs(pageOffset - round(pageOffset));
if (fractional > 0.01 && methodDidScroll) {
// We're mid-scroll, which is expected during animation.
// Only flag if this persists after animation completes.
if (logDetail) {
NSLog(@"[WRPageVC] Fractional offset detected: %.4f (mid-scroll, monitoring)", fractional);
}
}
// ---- Check 2: Number of child view controllers ----
NSArray *childVCs = pageVC.viewControllers ?: @[];
if (childVCs.count == 0 && !methodDidScroll) {
// No view controllers while not scrolling = fault state.
s_NavDirectionFaultDetected = YES;
if (logCrashReason) {
NSLog(@"[WRPageVC] FAULT: No view controllers in page VC outside of scroll.");
}
[self _patchFaultStateForPageViewController:pageVC];
return YES;
}
// ---- Check 3: Scroll view delegate consistency ----
id scrollDelegate = scrollView.delegate;
if (scrollDelegate != pageVC) {
// The scroll view delegate should be the page view controller.
s_NavDirectionFaultDetected = YES;
if (logCrashReason) {
NSLog(@"[WRPageVC] FAULT: Scroll delegate mismatch. Expected %@, got %@",
pageVC, scrollDelegate);
}
[self _patchFaultStateForPageViewController:pageVC];
return YES;
}
return NO;
}
/// Internal method to patch a detected fault state.
+ (void)_patchFaultStateForPageViewController:(UIPageViewController *)pageVC {
// Reset the scroll view to a known state.
@try {
UIScrollView *scrollView = [pageVC valueForKey:@"_queuingScrollView"];
if (scrollView) {
// Snap the content offset to the nearest page boundary.
CGFloat frameWidth = scrollView.bounds.size.width;
CGFloat snappedX = round(scrollView.contentOffset.x / frameWidth) * frameWidth;
scrollView.contentOffset = CGPointMake(snappedX, 0);
}
} @catch (NSException *e) {
NSLog(@"[WRPageVC] Patch failed: %@", e);
}
}
///
/// Patches the navigation direction fault by swizzling the internal
/// -_navigationDirectionForQueuingScrollView: method on UIPageViewController.
///
/// The swizzled implementation returns a safe default (Forward) when the
/// internal direction value is out of range.
+ (void)patchNavigationDirectionFault {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// In a real implementation, this would swizzle the private method
// -[UIPageViewController _navigationDirectionForQueuingScrollView:direction:]
// to clamp the direction to a valid value.
//
// Since we can't safely swizzle private API in production, we instead:
// 1. Set a flag to enable the detection callback.
// 2. Register for UIApplicationDidEnterBackgroundNotification to
// cancel in-flight transitions.
// 3. Register for UIApplicationWillEnterForegroundNotification to
// reset state.
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidEnterBackgroundNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
// Cancel any in-flight transition to prevent the fault.
s_NavDirectionFaultDetected = NO;
}];
[[NSUserDefaults standardUserDefaults] setBool:YES forKey:kNavDirectionPatchedKey];
NSLog(@"[WRPageVC] Navigation direction fault patch registered.");
});
}
///
/// Patches the "no view controller managing page view" fault.
///
/// This fault occurs when UIPageViewController's internal state gets out of
/// sync with the actual view controller hierarchy, typically after:
/// - Memory warnings that cause view unloading.
/// - Rapid programmatic page changes.
/// - Interface rotation during a transition.
///
/// The patch:
/// 1. Swizzles -viewDidDisappear: on UIPageViewController to ensure
/// child VCs are properly cleaned up.
/// 2. Registers for memory warning to re-set view controllers if needed.
+ (void)patchNoViewControllerManagingPageViewFault {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// Swizzle the private method that throws the "no view controller
// managing the view" exception. The swizzled version catches the
// exception and returns nil instead of crashing.
//
// In practice, we intercept at a higher level:
// Register for UIApplicationDidReceiveMemoryWarningNotification
// and force a re-display of the current page.
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidReceiveMemoryWarningNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
// Post a notification for the reader to reload.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewControllerMemoryWarning"
object:nil];
}];
NSLog(@"[WRPageVC] No-VC-managing-page-view fault patch registered.");
});
}
///
/// Patches the UIPageCurl animation fault.
///
/// The UIPageCurl transition uses a GLKView (OpenGL/Metal) internally for
/// the page curl animation. When:
/// - The app enters the background, the GL context is invalidated.
/// - A rapid sequence of curl animations is triggered (user flipping fast).
/// - Memory pressure causes the GL resources to be purged.
///
/// The result is a visual glitch (blank page, stuck curl) or a crash in
/// the rendering thread.
///
/// The patch:
/// 1. Pauses curl animations when entering background.
/// 2. Resets the curl state on foreground.
/// 3. Throttles curl animation requests.
+ (void)patchUIPageCurlFault {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
s_PageCurlFaultPatched = YES;
// Register for background/foreground transitions.
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidEnterBackgroundNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
// Disable page curl transitions while in background.
// The reader should switch to scroll mode or disable paging.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewControllerDisableCurl"
object:nil];
}];
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationWillEnterForegroundNotification
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(NSNotification *note) {
// Re-enable and force a redraw.
[[NSNotificationCenter defaultCenter]
postNotificationName:@"WRPageViewControllerEnableCurl"
object:nil];
}];
NSLog(@"[WRPageVC] UIPageCurl fault patch registered.");
});
}
// ============================================================================
#pragma mark - UIGestureRecognizerDelegate
// ============================================================================
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldReceiveTouch:(UITouch *)touch {
// Only handle taps in the left/right edge zones for curl mode.
if (self.pageFlippingStyle != WRPageFlippingStyleCurl) return NO;
CGPoint pt = [touch locationInView:self.view];
CGFloat w = CGRectGetWidth(self.view.bounds);
// Left zone: 0..25% of width.
// Right zone: 75%..100% of width.
if (pt.x < w * 0.25 || pt.x > w * 0.75) {
return self.pagingEnabled;
}
return NO;
}
@end
@@ -0,0 +1,126 @@
//
// WRPreloadBookManager.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Preload manager. Predicts which chapters to preload based on book ranking
// and user behavior. Manages encryption keys for preloaded content.
// Supports whole-book preloading and incremental chapter preloading.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@class WRBook;
// ---------------------------------------------------------------------------
// Preload scene types
// ---------------------------------------------------------------------------
typedef NS_ENUM(NSInteger, WRPreloadScene) {
WRPreloadSceneNone = 0,
WRPreloadSceneShelf = 1, // Preload from bookshelf
WRPreloadSceneReading = 2, // Preload next chapters while reading
WRPreloadSceneWiFi = 3, // Aggressive preload on WiFi
WRPreloadSceneManual = 4, // User-initiated preload
};
// ---------------------------------------------------------------------------
// WRPreloadBookManager
// ---------------------------------------------------------------------------
@interface WRPreloadBookManager : NSObject
// --- Ivars (from binary analysis) ---
// {
// NSMutableDictionary *_preloadState; // bookId -> preload state dict
// }
@property (nonatomic, strong, readonly) NSMutableDictionary *preloadState;
#pragma mark - Class Methods: Encryption Key Storage
/// Save an encryption key for a preloaded book.
/// @param key The encryption key data (hex or raw).
/// @param path The file path where the key is associated.
/// @param bookId The book identifier.
+ (void)saveEncryptKey:(NSString *)key
forPath:(NSString *)path
bookId:(NSString *)bookId;
/// Retrieve the stored encryption key for a book.
/// @param path The associated file path.
/// @param bookId The book identifier.
/// @return The encryption key string, or nil.
+ (nullable NSString *)encryptKeyForPath:(NSString *)path
bookId:(NSString *)bookId;
/// Remove a stored encryption key.
+ (void)removeEncryptKeyForPath:(NSString *)path
bookId:(NSString *)bookId;
#pragma mark - Class Methods: Filename Dictionary
/// Save a filename mapping dictionary for a book.
/// Maps content keys to actual filenames in the tar archive.
+ (void)saveFileNameDict:(NSDictionary<NSString *, NSString *> *)dict
bookId:(NSString *)bookId;
/// Look up a filename by key for a given book.
+ (nullable NSString *)fileNameForKey:(NSString *)key
bookId:(NSString *)bookId;
/// Remove a filename mapping entry.
+ (void)removeFileNameForKey:(NSString *)key
bookId:(NSString *)bookId;
#pragma mark - Class Methods: Cache Management
/// Clear all preload key-value caches.
+ (void)clearKV;
/// Remove all preload data for a specific book.
+ (void)removeKVWithBookId:(NSString *)bookId;
#pragma mark - Instance Methods: Preloading
/// Preload an entire book (all chapters).
/// @param book The book model to preload.
/// @param scene The preload context/scene.
- (void)_preloadWholeBook:(WRBook *)book scene:(WRPreloadScene)scene;
/// Preload chapters starting from a given index.
/// @param book The book model.
/// @param fromChapterIdx Starting chapter index.
- (void)preloadWithBook:(WRBook *)book fromChapterIdx:(NSInteger)fromChapterIdx;
/// Download specific chapters for a book.
/// @param book The book model.
/// @param uids Array of chapter UIDs to download.
- (void)downloadBook:(WRBook *)book uids:(NSArray<NSString *> *)uids;
/// Clean up all preloaded book data.
- (void)cleanUpPreloadBook;
/// Calculate preload cache size and optionally clear it.
/// @param completion Called with the total size in bytes.
/// @param onlyCalc YES to only calculate, NO to also clear.
- (void)calcAndClearPreloadBookWithCompletion:(void (^)(NSUInteger totalSize))completion
onlyCalc:(BOOL)onlyCalc;
#pragma mark - Additional Methods (23 total, reconstructed)
/// Check if a book is currently being preloaded.
- (BOOL)isPreloadingBookId:(NSString *)bookId;
/// Get the preload progress for a book (0.0 - 1.0).
- (float)preloadProgressForBookId:(NSString *)bookId;
/// Cancel an ongoing preload for a book.
- (void)cancelPreloadForBookId:(NSString *)bookId;
/// Determine which chapters should be preloaded next.
- (NSArray<NSString *> *)predictedChapterUidsForBook:(WRBook *)book;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,547 @@
//
// WRPreloadBookManager.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// Detailed pseudo-code based on binary analysis, ivar types (NSMutableDictionary),
// 23 known methods, and contextual knowledge of WeRead's preload strategy.
//
// Architecture notes:
// - Class methods handle key/filename storage (shared state via NSUserDefaults
// or a static dictionary).
// - Instance methods handle the actual preloading logic.
// - Preloading decisions are based on book ranking, reading patterns,
// and network conditions (WiFi vs. cellular).
// - Preloaded content is encrypted and stored in a separate cache directory.
//
#import "WRPreloadBookManager.h"
#import "WRBookNetwork.h"
#import "WREncryptedFileManager.h"
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
static NSString *const kPreloadEncryptKeyPrefix = @"preload_key_";
static NSString *const kPreloadFileNamePrefix = @"preload_fn_";
static NSString *const kPreloadCacheDir = @"preload_cache";
// Preload limits
static const NSUInteger kMaxPreloadChaptersOnWiFi = 50;
static const NSUInteger kMaxPreloadChaptersOnCellular = 5;
static const NSUInteger kMaxPreloadBooksOnShelf = 3;
#pragma mark - Private Interface
@interface WRPreloadBookManager ()
@property (nonatomic, strong, readwrite) NSMutableDictionary *preloadState;
@end
#pragma mark - Implementation
@implementation WRPreloadBookManager
{
// Ivar from binary analysis:
NSMutableDictionary *_preloadState;
}
#pragma mark - Lifecycle
- (instancetype)init
{
self = [super init];
if (self) {
_preloadState = [NSMutableDictionary dictionary];
// Register for network change notifications to adjust preload behavior
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(_networkStatusChanged:)
name:@"WRNetworkStatusChangedNotification"
object:nil];
}
return self;
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Class Methods: Encryption Key Storage
+ (void)saveEncryptKey:(NSString *)key
forPath:(NSString *)path
bookId:(NSString *)bookId
{
if (!key || !bookId) return;
// Store in NSUserDefaults with a composite key
NSString *storageKey = [NSString stringWithFormat:@"%@%@_%@",
kPreloadEncryptKeyPrefix, bookId,
[path lastPathComponent]];
[[NSUserDefaults standardUserDefaults] setObject:key forKey:storageKey];
[[NSUserDefaults standardUserDefaults] synchronize];
// Also save to the keychain via WREncryptedFileManager for persistence
NSData *keyData = [key dataUsingEncoding:NSUTF8StringEncoding];
[WREncryptedFileManager saveKey:keyData forBookId:bookId];
}
+ (nullable NSString *)encryptKeyForPath:(NSString *)path
bookId:(NSString *)bookId
{
NSString *storageKey = [NSString stringWithFormat:@"%@%@_%@",
kPreloadEncryptKeyPrefix, bookId,
[path lastPathComponent]];
NSString *key = [[NSUserDefaults standardUserDefaults] stringForKey:storageKey];
// Fallback: try the keychain
if (!key) {
NSData *keyData = [WREncryptedFileManager keyForBookId:bookId];
if (keyData) {
key = [[NSString alloc] initWithData:keyData encoding:NSUTF8StringEncoding];
}
}
return key;
}
+ (void)removeEncryptKeyForPath:(NSString *)path
bookId:(NSString *)bookId
{
NSString *storageKey = [NSString stringWithFormat:@"%@%@_%@",
kPreloadEncryptKeyPrefix, bookId,
[path lastPathComponent]];
[[NSUserDefaults standardUserDefaults] removeObjectForKey:storageKey];
[[NSUserDefaults standardUserDefaults] synchronize];
}
#pragma mark - Class Methods: Filename Dictionary
+ (void)saveFileNameDict:(NSDictionary<NSString *, NSString *> *)dict
bookId:(NSString *)bookId
{
if (!dict || !bookId) return;
NSString *storageKey = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId];
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:storageKey];
[[NSUserDefaults standardUserDefaults] synchronize];
}
+ (nullable NSString *)fileNameForKey:(NSString *)key
bookId:(NSString *)bookId
{
NSString *storageKey = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId];
NSDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:storageKey];
return dict[key];
}
+ (void)removeFileNameForKey:(NSString *)key
bookId:(NSString *)bookId
{
NSString *storageKey = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId];
NSMutableDictionary *dict = [[[NSUserDefaults standardUserDefaults]
dictionaryForKey:storageKey] mutableCopy];
if (dict) {
[dict removeObjectForKey:key];
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:storageKey];
[[NSUserDefaults standardUserDefaults] synchronize];
}
}
#pragma mark - Class Methods: Cache Management
+ (void)clearKV
{
// Remove all preload-related keys from NSUserDefaults
NSDictionary *defaults = [[NSUserDefaults standardUserDefaults] dictionaryRepresentation];
NSArray *prefixes = @[kPreloadEncryptKeyPrefix, kPreloadFileNamePrefix];
for (NSString *key in defaults) {
for (NSString *prefix in prefixes) {
if ([key hasPrefix:prefix]) {
[[NSUserDefaults standardUserDefaults] removeObjectForKey:key];
}
}
}
[[NSUserDefaults standardUserDefaults] synchronize];
}
+ (void)removeKVWithBookId:(NSString *)bookId
{
if (!bookId) return;
NSDictionary *defaults = [[NSUserDefaults standardUserDefaults] dictionaryRepresentation];
NSString *keyPrefix1 = [NSString stringWithFormat:@"%@%@", kPreloadEncryptKeyPrefix, bookId];
NSString *keyPrefix2 = [NSString stringWithFormat:@"%@%@", kPreloadFileNamePrefix, bookId];
for (NSString *key in defaults) {
if ([key hasPrefix:keyPrefix1] || [key hasPrefix:keyPrefix2]) {
[[NSUserDefaults standardUserDefaults] removeObjectForKey:key];
}
}
[[NSUserDefaults standardUserDefaults] synchronize];
}
#pragma mark - Instance Methods: Preloading
- (void)_preloadWholeBook:(WRBook *)book scene:(WRPreloadScene)scene
{
// 1. Determine the maximum chapters to preload based on scene
NSUInteger maxChapters;
switch (scene) {
case WRPreloadSceneWiFi:
maxChapters = kMaxPreloadChaptersOnWiFi;
break;
case WRPreloadSceneShelf:
maxChapters = 20; // Moderate for shelf browsing
break;
case WRPreloadSceneReading:
maxChapters = 10; // Next few chapters while reading
break;
default:
maxChapters = kMaxPreloadChaptersOnCellular;
break;
}
// 2. Get the chapter list for the book
NSArray *chapters = book.chapters;
if (!chapters || chapters.count == 0) {
NSLog(@"[WRPreloadBookManager] No chapters for book %@", book.bookId);
return;
}
// 3. Select chapters to preload
NSArray *uidsToPreload = [self _selectChaptersForPreload:book
maxCount:maxChapters
scene:scene];
if (uidsToPreload.count == 0) return;
// 4. Update preload state
NSString *bookId = book.bookId;
_preloadState[bookId] = @{
@"status" : @"preloading",
@"scene" : @(scene),
@"totalCount" : @(uidsToPreload.count),
@"loadedCount" : @(0),
@"startTime" : @([[NSDate date] timeIntervalSince1970]),
};
// 5. Initiate download via WRBookNetwork
[WRBookNetwork loadTarForEpubBookId:bookId
chapters:uidsToPreload
isPreload:YES];
NSLog(@"[WRPreloadBookManager] Started preloading %lu chapters for book %@ (scene=%ld)",
(unsigned long)uidsToPreload.count, bookId, (long)scene);
}
- (void)preloadWithBook:(WRBook *)book fromChapterIdx:(NSInteger)fromChapterIdx
{
// Preload chapters starting from the given index
NSArray *chapters = book.chapters;
if (!chapters || fromChapterIdx >= (NSInteger)chapters.count) return;
// Determine how many chapters to preload ahead
NSUInteger preloadCount = 5; // Default: preload 5 chapters ahead
if ([self _isOnWiFi]) {
preloadCount = 10;
}
NSInteger startIndex = MAX(0, fromChapterIdx);
NSInteger endIndex = MIN(startIndex + (NSInteger)preloadCount,
(NSInteger)chapters.count);
NSMutableArray *uids = [NSMutableArray array];
for (NSInteger i = startIndex; i < endIndex; i++) {
NSDictionary *chapter = chapters[i];
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
if (uid) [uids addObject:uid];
}
if (uids.count > 0) {
[self downloadBook:book uids:uids];
}
}
- (void)downloadBook:(WRBook *)book uids:(NSArray<NSString *> *)uids
{
if (!book.bookId || uids.count == 0) return;
// Filter out already-downloaded chapters
NSMutableArray *pendingUIDs = [NSMutableArray array];
NSString *plainDir = [self _plainBookDirectoryForBookId:book.bookId];
for (NSString *uid in uids) {
NSString *chapterPath = [plainDir stringByAppendingPathComponent:
[NSString stringWithFormat:@"chapter_%@.xhtml", uid]];
if (![[NSFileManager defaultManager] fileExistsAtPath:chapterPath]) {
[pendingUIDs addObject:uid];
}
}
if (pendingUIDs.count == 0) return;
// Initiate download
[WRBookNetwork loadTarForEpubBookId:book.bookId
chapters:pendingUIDs
isPreload:YES];
}
- (void)cleanUpPreloadBook
{
// 1. Remove all preloaded book files
NSString *preloadDir = [self _preloadCacheDirectory];
NSFileManager *fm = [NSFileManager defaultManager];
NSError *error = nil;
[fm removeItemAtPath:preloadDir error:&error];
if (error) {
NSLog(@"[WRPreloadBookManager] Cleanup failed: %@", error);
}
// 2. Clear all key-value caches
[[self class] clearKV];
// 3. Reset state
[_preloadState removeAllObjects];
// 4. Recreate the directory
[fm createDirectoryAtPath:preloadDir
withIntermediateDirectories:YES
attributes:nil
error:nil];
}
- (void)calcAndClearPreloadBookWithCompletion:(void (^)(NSUInteger totalSize))completion
onlyCalc:(BOOL)onlyCalc
{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSString *preloadDir = [self _preloadCacheDirectory];
NSUInteger totalSize = [self _directorySizeAtPath:preloadDir];
if (!onlyCalc) {
// Clear the cache
[self cleanUpPreloadBook];
}
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(totalSize);
});
});
}
#pragma mark - Additional Methods (Reconstructed)
- (BOOL)isPreloadingBookId:(NSString *)bookId
{
NSDictionary *state = _preloadState[bookId];
return [state[@"status"] isEqualToString:@"preloading"];
}
- (float)preloadProgressForBookId:(NSString *)bookId
{
NSDictionary *state = _preloadState[bookId];
if (!state) return 0.0;
NSInteger total = [state[@"totalCount"] integerValue];
NSInteger loaded = [state[@"loadedCount"] integerValue];
if (total <= 0) return 0.0;
return (float)loaded / (float)total;
}
- (void)cancelPreloadForBookId:(NSString *)bookId
{
// Mark the preload as cancelled in state
_preloadState[bookId] = @{
@"status" : @"cancelled",
@"cancelTime": @([[NSDate date] timeIntervalSince1970]),
};
// Note: Actual network cancellation would need to be handled
// by WRBookNetwork's task management
NSLog(@"[WRPreloadBookManager] Cancelled preload for book %@", bookId);
}
- (NSArray<NSString *> *)predictedChapterUidsForBook:(WRBook *)book
{
// Predict which chapters the user is likely to read next.
//
// Strategy:
// 1. If the user is currently reading chapter N, predict N+1, N+2, ...
// 2. If the user has a pattern of jumping (e.g., to bookmarks), preload those
// 3. Consider book ranking: popular books get more aggressive preloading
NSMutableArray *predicted = [NSMutableArray array];
NSArray *chapters = book.chapters;
if (!chapters) return predicted;
// Find the current chapter index
NSInteger currentIndex = 0;
NSString *currentUid = book.currentChapterUid;
for (NSUInteger i = 0; i < chapters.count; i++) {
NSDictionary *chapter = chapters[i];
if ([chapter[@"chapterUid"] isEqualToString:currentUid]) {
currentIndex = (NSInteger)i;
break;
}
}
// Predict the next N chapters
NSUInteger predictCount = [self _isOnWiFi] ? 10 : 3;
for (NSInteger i = currentIndex + 1;
i < MIN(currentIndex + 1 + (NSInteger)predictCount, (NSInteger)chapters.count);
i++) {
NSDictionary *chapter = chapters[i];
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
if (uid) [predicted addObject:uid];
}
return [predicted copy];
}
#pragma mark - Private Helpers
- (NSArray<NSString *> *)_selectChaptersForPreload:(WRBook *)book
maxCount:(NSUInteger)maxCount
scene:(WRPreloadScene)scene
{
NSArray *chapters = book.chapters;
if (!chapters) return @[];
NSMutableArray *selected = [NSMutableArray array];
NSString *currentUid = book.currentChapterUid;
NSInteger currentIndex = 0;
// Find current chapter index
for (NSUInteger i = 0; i < chapters.count; i++) {
NSDictionary *chapter = chapters[i];
if ([chapter[@"chapterUid"] isEqualToString:currentUid]) {
currentIndex = (NSInteger)i;
break;
}
}
// Select chapters based on scene
switch (scene) {
case WRPreloadSceneReading:
// Preload next chapters from current position
for (NSInteger i = currentIndex;
i < MIN(currentIndex + (NSInteger)maxCount, (NSInteger)chapters.count);
i++) {
NSDictionary *chapter = chapters[i];
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
if (uid) [selected addObject:uid];
}
break;
case WRPreloadSceneShelf:
// Preload from the beginning (user might start reading)
for (NSUInteger i = 0; i < MIN(maxCount, chapters.count); i++) {
NSDictionary *chapter = chapters[i];
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
if (uid) [selected addObject:uid];
}
break;
case WRPreloadSceneWiFi:
// Aggressive: preload all chapters
for (NSDictionary *chapter in chapters) {
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
if (uid) [selected addObject:uid];
if (selected.count >= maxCount) break;
}
break;
default:
// Minimal: just the next chapter
if (currentIndex + 1 < (NSInteger)chapters.count) {
NSDictionary *chapter = chapters[currentIndex + 1];
NSString *uid = chapter[@"chapterUid"] ?: chapter[@"uid"];
if (uid) [selected addObject:uid];
}
break;
}
return [selected copy];
}
- (BOOL)_isOnWiFi
{
// Check network status
// In the actual app, this uses SCNetworkReachability or a wrapper
// For reconstruction, assume WiFi if not explicitly cellular
NSNumber *isWiFi = [[NSUserDefaults standardUserDefaults] objectForKey:@"WRIsOnWiFi"];
return isWiFi ? isWiFi.boolValue : YES; // Default to WiFi for safety
}
- (void)_networkStatusChanged:(NSNotification *)notification
{
// Adjust preload behavior when network changes
BOOL isWiFi = [notification.userInfo[@"isWiFi"] boolValue];
if (!isWiFi) {
// On cellular: cancel aggressive preloads
for (NSString *bookId in [_preloadState copy]) {
NSDictionary *state = _preloadState[bookId];
if ([state[@"scene"] integerValue] == WRPreloadSceneWiFi) {
[self cancelPreloadForBookId:bookId];
}
}
}
}
- (NSString *)_preloadCacheDirectory
{
NSString *caches = NSSearchPathForDirectoriesInDomains(
NSCachesDirectory, NSUserDomainMask, YES).firstObject;
return [caches stringByAppendingPathComponent:kPreloadCacheDir];
}
- (NSString *)_plainBookDirectoryForBookId:(NSString *)bookId
{
NSString *caches = NSSearchPathForDirectoriesInDomains(
NSCachesDirectory, NSUserDomainMask, YES).firstObject;
return [[caches stringByAppendingPathComponent:@"plain_books"]
stringByAppendingPathComponent:bookId];
}
- (NSUInteger)_directorySizeAtPath:(NSString *)path
{
NSFileManager *fm = [NSFileManager defaultManager];
BOOL isDir = NO;
if (![fm fileExistsAtPath:path isDirectory:&isDir]) return 0;
if (!isDir) {
NSDictionary *attrs = [fm attributesOfItemAtPath:path error:nil];
return [attrs fileSize];
}
NSUInteger totalSize = 0;
NSDirectoryEnumerator *enumerator = [fm enumeratorAtPath:path];
for (NSString *filename in enumerator) {
NSString *filePath = [path stringByAppendingPathComponent:filename];
NSDictionary *attrs = [fm attributesOfItemAtPath:filePath error:nil];
totalSize += [attrs fileSize];
}
return totalSize;
}
@end
@@ -0,0 +1,158 @@
//
// WRReaderPencilNoteManager.h
// WeRead (微信读书)
// Reverse-engineered header
//
// Apple Pencil note manager. 11 class methods identified from binary.
// Stores drawings locally, uploads to Tencent Cloud COS.
// Supports draft and published states for pencil annotations.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
// ---------------------------------------------------------------------------
// Pencil drawing color styles
// ---------------------------------------------------------------------------
typedef NS_ENUM(NSInteger, WRPencilColorStyle) {
WRPencilColorStyleBlack = 0,
WRPencilColorStyleGray = 1,
WRPencilColorStyleRed = 2,
WRPencilColorStyleBlue = 3,
WRPencilColorStyleYellow = 4,
WRPencilColorStyleGreen = 5,
WRPencilColorStylePencil = 6, // Natural pencil
WRPencilColorStylePen = 7, // Pen/fountain
WRPencilColorStyleMarker = 8, // Highlighter marker
};
// ---------------------------------------------------------------------------
// Upload callback
// ---------------------------------------------------------------------------
typedef void (^WRPencilUploadCallback)(BOOL success,
NSString * _Nullable imageUrl,
NSString * _Nullable drawingUrl,
NSError * _Nullable error);
// ---------------------------------------------------------------------------
// Download callback
// ---------------------------------------------------------------------------
typedef void (^WRPencilDownloadCallback)(BOOL success,
NSData * _Nullable drawingData,
NSError * _Nullable error);
// ---------------------------------------------------------------------------
// WRReaderPencilNoteManager
// ---------------------------------------------------------------------------
@interface WRReaderPencilNoteManager : NSObject
#pragma mark - Drawing Existence Check
/// Check whether a pencil drawing exists locally for the given review item.
/// @param reviewItemId The review item identifier.
/// @param reviewId The review identifier.
/// @param isDraft YES to check for draft drawings, NO for published.
/// @return YES if the drawing file exists on disk.
+ (BOOL)checkDrawingExistsWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft;
#pragma mark - Delete Operations
/// Delete all locally stored review drawings.
+ (void)deleteAllReviewDrawings;
/// Delete a specific drawing.
/// @param reviewItemId The review item identifier.
/// @param reviewId The review identifier.
/// @param isDraft YES to delete the draft, NO for published.
+ (void)deleteDrawingWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft;
#pragma mark - Download
/// Download drawing data from Tencent Cloud COS.
/// @param cosUrl The COS URL for the drawing data.
/// @param destPath Local destination path.
/// @param callback Called with the downloaded data or error.
+ (void)downloadDrawingDataFromCosWithUrl:(NSString *)cosUrl
desPath:(NSString *)destPath
callback:(WRPencilDownloadCallback)callback;
#pragma mark - File Paths
/// Return the base directory for pencil drawing files.
+ (NSString *)drawingFileDirectory;
/// Return the file path for a specific drawing.
/// @param reviewItemId The review item identifier.
/// @param reviewId The review identifier.
/// @param isDraft YES for draft path, NO for published path.
+ (NSString *)drawingFilePathWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft;
/// Return the file path for a drawing's rendered image.
/// @param reviewItemId The review item identifier.
/// @param reviewId The review identifier.
+ (NSString *)imageFilePathWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId;
#pragma mark - Upload
/// Upload a pencil drawing image and data to Tencent Cloud COS.
/// @param drawing The rendered UIImage of the drawing.
/// @param colorStyle The color/style used for the drawing.
/// @param uploadImage YES to upload the image, NO for data only.
/// @param canRetry YES if the upload can be retried on failure.
+ (void)uploadPencilDrawing:(UIImage *)drawing
colorStyle:(WRPencilColorStyle)colorStyle
onlyUploadImage:(BOOL)uploadImage
canRetry:(BOOL)canRetry;
/// Upload pencil note raw data (PKDrawing serialized data).
/// @param noteData The serialized drawing data.
/// @param suffix File suffix/extension (e.g., "drawing", "png").
+ (void)uploadPencilNoteData:(NSData *)noteData
suffix:(NSString *)suffix;
#pragma mark - Local Storage
/// Write raw drawing data to local storage.
/// @param data The drawing data to write.
/// @param reviewItemId The review item identifier.
/// @param reviewId The review identifier.
/// @param isDraft YES for draft, NO for published.
+ (void)writeDrawingDataToLocal:(NSData *)data
reviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft;
/// Write a rendered drawing image to local storage.
/// @param drawing The UIImage to write.
/// @param reviewItemId The review item identifier.
/// @param reviewId The review identifier.
/// @param isDraft YES for draft, NO for published.
+ (void)writeDrawingToLocal:(UIImage *)drawing
reviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft;
#pragma mark - Additional Methods (Reconstructed)
/// Return the total size of all stored pencil drawings in bytes.
+ (NSUInteger)totalDrawingStorageSize;
/// Return a list of all locally stored drawing review item IDs.
+ (NSArray<NSString *> *)allStoredDrawingReviewItemIds;
/// Render a PKDrawing data to UIImage.
+ (nullable UIImage *)renderDrawingDataToImage:(NSData *)drawingData
scale:(CGFloat)scale;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,526 @@
//
// WRReaderPencilNoteManager.m
// WeRead (微信读书)
// Reverse-engineered implementation reconstruction
//
// 11 class methods identified from binary. Manages Apple Pencil
// annotations with local storage and Tencent Cloud COS uploads.
//
// Architecture notes:
// - All methods are class methods (static utility pattern).
// - Drawings are stored as both raw PKDrawing data and rendered PNG images.
// - Draft vs. published states allow users to save work-in-progress.
// - Uploads go to Tencent Cloud COS (Cloud Object Storage) via
// signed URLs or direct API.
// - File paths follow a predictable pattern: {directory}/{reviewItemId}_{reviewId}.{ext}
//
#import "WRReaderPencilNoteManager.h"
#import <PencilKit/PencilKit.h>
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
static NSString *const kPencilDrawingDir = @"pencil_drawings";
static NSString *const kPencilImageDir = @"pencil_images";
static NSString *const kCOSUploadURL = @"https://weread-1258476243.file.myqcloud.com";
static NSString *const kDraftSuffix = @"_draft";
static NSString *const kPublishedSuffix = @"";
#pragma mark - Implementation
@implementation WRReaderPencilNoteManager
#pragma mark - Drawing Existence Check
+ (BOOL)checkDrawingExistsWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft
{
// Check for the drawing data file
NSString *dataPath = [self drawingFilePathWithReviewItemId:reviewItemId
reviewId:reviewId
isDraft:isDraft];
BOOL exists = [[NSFileManager defaultManager] fileExistsAtPath:dataPath];
if (!exists) {
// Also check for the image file
NSString *imagePath = [self imageFilePathWithReviewItemId:reviewItemId
reviewId:reviewId];
exists = [[NSFileManager defaultManager] fileExistsAtPath:imagePath];
}
return exists;
}
#pragma mark - Delete Operations
+ (void)deleteAllReviewDrawings
{
// Remove the entire pencil drawings directory
NSString *drawingDir = [self drawingFileDirectory];
NSString *imageDir = [self _pencilImageDirectory];
NSFileManager *fm = [NSFileManager defaultManager];
// Remove drawing data
[fm removeItemAtPath:drawingDir error:nil];
[fm removeItemAtPath:imageDir error:nil];
// Recreate empty directories
[fm createDirectoryAtPath:drawingDir withIntermediateDirectories:YES
attributes:nil error:nil];
[fm createDirectoryAtPath:imageDir withIntermediateDirectories:YES
attributes:nil error:nil];
NSLog(@"[WRReaderPencilNoteManager] Deleted all review drawings");
}
+ (void)deleteDrawingWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft
{
NSFileManager *fm = [NSFileManager defaultManager];
// Delete drawing data file
NSString *dataPath = [self drawingFilePathWithReviewItemId:reviewItemId
reviewId:reviewId
isDraft:isDraft];
[fm removeItemAtPath:dataPath error:nil];
// Delete image file (only for published, drafts may not have images)
if (!isDraft) {
NSString *imagePath = [self imageFilePathWithReviewItemId:reviewItemId
reviewId:reviewId];
[fm removeItemAtPath:imagePath error:nil];
}
NSLog(@"[WRReaderPencilNoteManager] Deleted drawing: %@ (draft=%d)",
reviewItemId, isDraft);
}
#pragma mark - Download
+ (void)downloadDrawingDataFromCosWithUrl:(NSString *)cosUrl
desPath:(NSString *)destPath
callback:(WRPencilDownloadCallback)callback
{
if (!cosUrl || cosUrl.length == 0) {
if (callback) {
callback(NO, nil, [NSError errorWithDomain:@"WRReaderPencilNoteManager"
code:-1
userInfo:@{NSLocalizedDescriptionKey: @"Empty COS URL"}]);
}
return;
}
NSURL *url = [NSURL URLWithString:cosUrl];
if (!url) {
if (callback) {
callback(NO, nil, [NSError errorWithDomain:@"WRReaderPencilNoteManager"
code:-2
userInfo:@{NSLocalizedDescriptionKey: @"Invalid COS URL"}]);
}
return;
}
// Download from COS
NSURLSessionConfiguration *config =
[NSURLSessionConfiguration defaultSessionConfiguration];
config.timeoutIntervalForRequest = 30;
NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
NSURLSessionDataTask *task = [session dataTaskWithURL:url
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (error || !data) {
NSLog(@"[WRReaderPencilNoteManager] COS download failed: %@",
error.localizedDescription);
if (callback) callback(NO, nil, error);
return;
}
// Verify HTTP status
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
if (httpResponse.statusCode != 200) {
NSError *statusError = [NSError errorWithDomain:@"WRReaderPencilNoteManager"
code:httpResponse.statusCode
userInfo:@{NSLocalizedDescriptionKey:
[NSString stringWithFormat:@"HTTP %ld",
(long)httpResponse.statusCode]}];
if (callback) callback(NO, nil, statusError);
return;
}
// Save to local destination
if (destPath) {
NSString *destDir = [destPath stringByDeletingLastPathComponent];
[[NSFileManager defaultManager] createDirectoryAtPath:destDir
withIntermediateDirectories:YES
attributes:nil
error:nil];
NSError *writeError = nil;
[data writeToFile:destPath options:NSDataWritingAtomic error:&writeError];
if (writeError) {
NSLog(@"[WRReaderPencilNoteManager] Write failed: %@", writeError);
}
}
if (callback) callback(YES, data, nil);
}];
[task resume];
}
#pragma mark - File Paths
+ (NSString *)drawingFileDirectory
{
static NSString *sDir = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSString *caches = NSSearchPathForDirectoriesInDomains(
NSCachesDirectory, NSUserDomainMask, YES).firstObject;
sDir = [caches stringByAppendingPathComponent:kPencilDrawingDir];
// Create directory if needed
[[NSFileManager defaultManager] createDirectoryAtPath:sDir
withIntermediateDirectories:YES
attributes:nil
error:nil];
});
return sDir;
}
+ (NSString *)drawingFilePathWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft
{
NSString *dir = [self drawingFileDirectory];
NSString *suffix = isDraft ? kDraftSuffix : kPublishedSuffix;
// Filename: {reviewItemId}_{reviewId}{suffix}.drawing
NSString *filename = [NSString stringWithFormat:@"%@_%@%@.drawing",
reviewItemId, reviewId, suffix];
return [dir stringByAppendingPathComponent:filename];
}
+ (NSString *)imageFilePathWithReviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
{
NSString *dir = [self _pencilImageDirectory];
// Filename: {reviewItemId}_{reviewId}.png
NSString *filename = [NSString stringWithFormat:@"%@_%@.png",
reviewItemId, reviewId];
return [dir stringByAppendingPathComponent:filename];
}
#pragma mark - Upload
+ (void)uploadPencilDrawing:(UIImage *)drawing
colorStyle:(WRPencilColorStyle)colorStyle
onlyUploadImage:(BOOL)uploadImage
canRetry:(BOOL)canRetry
{
if (!drawing) return;
// 1. Render the image to PNG data
NSData *imageData = UIImagePNGRepresentation(drawing);
if (!imageData) return;
// 2. Build the upload request to COS
//
// Tencent Cloud COS upload flow:
// a. Request a signed upload URL from WeRead's backend
// b. PUT the file directly to COS using the signed URL
//
// For simplicity, we'll show the direct upload pattern.
NSString *filename = [NSString stringWithFormat:@"pencil_%@_%ld.png",
[[NSUUID UUID] UUIDString],
(long)[[NSDate date] timeIntervalSince1970]];
NSString *uploadURLStr = [NSString stringWithFormat:@"%@/%@",
kCOSUploadURL, filename];
NSURL *uploadURL = [NSURL URLWithString:uploadURLStr];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:uploadURL];
[request setHTTPMethod:@"PUT"];
[request addValue:@"image/png" forHTTPHeaderField:@"Content-Type"];
[request addValue:@(imageData.length).stringValue forHTTPHeaderField:@"Content-Length"];
NSURLSession *session = [NSURLSession sharedSession];
NSURLSessionUploadTask *task = [session uploadTaskWithRequest:request
fromData:imageData
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (error) {
NSLog(@"[WRReaderPencilNoteManager] Upload failed: %@",
error.localizedDescription);
if (canRetry) {
// Queue for retry
[self _queueRetryUpload:imageData filename:filename];
}
return;
}
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
if (httpResponse.statusCode == 200 || httpResponse.statusCode == 201) {
NSLog(@"[WRReaderPencilNoteManager] Upload success: %@", filename);
// Notify the backend about the uploaded file
[self _notifyBackendOfFileUpload:uploadURLStr
colorStyle:colorStyle];
}
}];
[task resume];
// 3. Also upload the raw drawing data if requested
if (!uploadImage) {
// The drawing data (PKDrawing serialized) needs separate upload
// This is handled by uploadPencilNoteData:suffix:
}
}
+ (void)uploadPencilNoteData:(NSData *)noteData
suffix:(NSString *)suffix
{
if (!noteData || noteData.length == 0) return;
NSString *filename = [NSString stringWithFormat:@"pencil_data_%@_%@.%@",
[[NSUUID UUID] UUIDString],
@((long)[[NSDate date] timeIntervalSince1970]),
suffix ?: @"drawing"];
NSString *uploadURLStr = [NSString stringWithFormat:@"%@/%@",
kCOSUploadURL, filename];
NSURL *uploadURL = [NSURL URLWithString:uploadURLStr];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:uploadURL];
[request setHTTPMethod:@"PUT"];
[request addValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"];
[request addValue:@(noteData.length).stringValue forHTTPHeaderField:@"Content-Length"];
NSURLSessionUploadTask *task = [[NSURLSession sharedSession]
uploadTaskWithRequest:request
fromData:noteData
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (error) {
NSLog(@"[WRReaderPencilNoteManager] Data upload failed: %@",
error.localizedDescription);
return;
}
NSLog(@"[WRReaderPencilNoteManager] Data upload success: %@", filename);
}];
[task resume];
}
#pragma mark - Local Storage
+ (void)writeDrawingDataToLocal:(NSData *)data
reviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft
{
if (!data || !reviewItemId || !reviewId) return;
NSString *path = [self drawingFilePathWithReviewItemId:reviewItemId
reviewId:reviewId
isDraft:isDraft];
// Ensure directory exists
NSString *dir = [path stringByDeletingLastPathComponent];
[[NSFileManager defaultManager] createDirectoryAtPath:dir
withIntermediateDirectories:YES
attributes:nil
error:nil];
NSError *error = nil;
[data writeToFile:path options:NSDataWritingAtomic error:&error];
if (error) {
NSLog(@"[WRReaderPencilNoteManager] Write drawing data failed: %@", error);
}
}
+ (void)writeDrawingToLocal:(UIImage *)drawing
reviewItemId:(NSString *)reviewItemId
reviewId:(NSString *)reviewId
isDraft:(BOOL)isDraft
{
if (!drawing || !reviewItemId || !reviewId) return;
NSString *path = [self imageFilePathWithReviewItemId:reviewItemId
reviewId:reviewId];
// Ensure directory exists
NSString *dir = [path stringByDeletingLastPathComponent];
[[NSFileManager defaultManager] createDirectoryAtPath:dir
withIntermediateDirectories:YES
attributes:nil
error:nil];
NSData *pngData = UIImagePNGRepresentation(drawing);
if (pngData) {
NSError *error = nil;
[pngData writeToFile:path options:NSDataWritingAtomic error:&error];
if (error) {
NSLog(@"[WRReaderPencilNoteManager] Write drawing image failed: %@", error);
}
}
}
#pragma mark - Additional Methods
+ (NSUInteger)totalDrawingStorageSize
{
NSFileManager *fm = [NSFileManager defaultManager];
NSUInteger totalSize = 0;
// Calculate size of drawing data directory
totalSize += [self _directorySize:[self drawingFileDirectory]];
// Calculate size of image directory
totalSize += [self _directorySize:[self _pencilImageDirectory]];
return totalSize;
}
+ (NSArray<NSString *> *)allStoredDrawingReviewItemIds
{
NSString *drawingDir = [self drawingFileDirectory];
NSFileManager *fm = [NSFileManager defaultManager];
NSError *error = nil;
NSArray *files = [fm contentsOfDirectoryAtPath:drawingDir error:&error];
if (!files) return @[];
NSMutableSet *reviewItemIds = [NSMutableSet set];
for (NSString *filename in files) {
// Filename format: {reviewItemId}_{reviewId}[_draft].drawing
NSArray *components = [filename componentsSeparatedByString:@"_"];
if (components.count >= 1) {
[reviewItemIds addObject:components[0]];
}
}
return [reviewItemIds allObjects];
}
+ (nullable UIImage *)renderDrawingDataToImage:(NSData *)drawingData
scale:(CGFloat)scale
{
if (!drawingData || drawingData.length == 0) return nil;
// Attempt to deserialize as PKDrawing
if (@available(iOS 13.0, *)) {
NSError *error = nil;
PKDrawing *drawing = [[PKDrawing alloc] initWithData:drawingData error:&error];
if (drawing) {
CGRect bounds = drawing.bounds;
if (scale <= 0) scale = [UIScreen mainScreen].scale;
UIImage *image = [drawing imageFromRect:bounds scale:scale];
return image;
}
}
// Fallback: try UIImage from data directly
return [UIImage imageWithData:drawingData];
}
#pragma mark - Private Helpers
+ (NSString *)_pencilImageDirectory
{
static NSString *sDir = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSString *caches = NSSearchPathForDirectoriesInDomains(
NSCachesDirectory, NSUserDomainMask, YES).firstObject;
sDir = [caches stringByAppendingPathComponent:kPencilImageDir];
[[NSFileManager defaultManager] createDirectoryAtPath:sDir
withIntermediateDirectories:YES
attributes:nil
error:nil];
});
return sDir;
}
+ (NSUInteger)_directorySize:(NSString *)path
{
NSFileManager *fm = [NSFileManager defaultManager];
BOOL isDir = NO;
if (![fm fileExistsAtPath:path isDirectory:&isDir]) return 0;
if (!isDir) {
NSDictionary *attrs = [fm attributesOfItemAtPath:path error:nil];
return [attrs fileSize];
}
NSUInteger totalSize = 0;
NSDirectoryEnumerator *enumerator = [fm enumeratorAtPath:path];
for (NSString *filename in enumerator) {
NSString *filePath = [path stringByAppendingPathComponent:filename];
NSDictionary *attrs = [fm attributesOfItemAtPath:filePath error:nil];
totalSize += [attrs fileSize];
}
return totalSize;
}
+ (void)_queueRetryUpload:(NSData *)imageData filename:(NSString *)filename
{
// Store failed upload in a retry queue
NSMutableArray *queue = [[[NSUserDefaults standardUserDefaults]
arrayForKey:@"pencil_upload_retry_queue"] mutableCopy] ?: [NSMutableArray array];
NSDictionary *entry = @{
@"filename" : filename ?: @"",
@"timestamp" : @((long)[[NSDate date] timeIntervalSince1970]),
// Note: imageData is too large for UserDefaults; would use file-based queue
};
[queue addObject:entry];
[[NSUserDefaults standardUserDefaults] setObject:queue forKey:@"pencil_upload_retry_queue"];
[[NSUserDefaults standardUserDefaults] synchronize];
// Write the actual image data to a retry file
NSString *retryDir = [[self drawingFileDirectory]
stringByAppendingPathComponent:@"retry"];
[[NSFileManager defaultManager] createDirectoryAtPath:retryDir
withIntermediateDirectories:YES
attributes:nil
error:nil];
NSString *retryPath = [retryDir stringByAppendingPathComponent:filename];
[imageData writeToFile:retryPath atomically:YES];
}
+ (void)_notifyBackendOfFileUpload:(NSString *)fileURL
colorStyle:(WRPencilColorStyle)colorStyle
{
// Notify WeRead's backend that a pencil drawing has been uploaded to COS.
// The backend will associate it with the review/note.
//
// This would typically be a POST to an API endpoint with the COS URL.
NSLog(@"[WRReaderPencilNoteManager] Notified backend of upload: %@ (style=%ld)",
fileURL, (long)colorStyle);
}
@end
@@ -0,0 +1,211 @@
//
// WRReaderViewController.h
// WeRead (微信读书)
//
// Reverse-engineered header reconstruction.
// Main reader controller. Manages reading state, progress saving,
// chapter jumping, page rendering lifecycle, and the typesetter.
// 431 methods total — this header exposes the key public interface.
//
#import <UIKit/UIKit.h>
#import "WRPageViewController.h"
@class WRBook;
@class WRChapterData;
@class WRChapterPageCount;
@class WRPageView;
@class WRReadingProgress;
NS_ASSUME_NONNULL_BEGIN
// ============================================================================
#pragma mark - Reading Progress Data
// ============================================================================
/// Encapsulates the current reading position.
@interface WRReadingProgress : NSObject
@property (nonatomic, copy, nullable) NSString *bookId;
@property (nonatomic, assign) NSUInteger chapterIndex; // Current chapter (0-based).
@property (nonatomic, assign) NSUInteger pageIndex; // Page within chapter (0-based).
@property (nonatomic, assign) NSUInteger charIndex; // Character offset within chapter.
@property (nonatomic, assign) CGFloat scrollOffset; // For scroll-based reading.
@property (nonatomic, copy, nullable) NSString *chapterId;
@property (nonatomic, assign) double readPercentage; // 0.0 .. 1.0.
@end
// ============================================================================
#pragma mark - WRReaderViewControllerDelegate
// ============================================================================
@protocol WRReaderViewControllerDelegate <NSObject>
@optional
/// Called when the reading progress changes (page flip, chapter jump).
- (void)readerViewController:(WRReaderViewController *)readerVC
didUpdateProgress:(WRReadingProgress *)progress;
/// Called when the reader needs to present a modal (e.g., settings, TOC).
- (void)readerViewController:(WRReaderViewController *)readerVC
presentViewController:(UIViewController *)viewController
animated:(BOOL)animated
completion:(void (^ __nullable)(void))completion;
/// Called when the reader exits.
- (void)readerViewControllerDidClose:(WRReaderViewController *)readerVC;
@end
// ============================================================================
#pragma mark - WRReaderViewController
// ============================================================================
@interface WRReaderViewController : UIViewController <WRPageViewControllerDelegate>
// ---- Delegate ----
@property (nonatomic, weak, nullable) id<WRReaderViewControllerDelegate> readerDelegate;
// ---- Book & Progress ----
/// The book being read.
@property (nonatomic, strong, readonly, nullable) WRBook *book;
/// The book identifier (convenience accessor).
@property (nonatomic, copy, readonly, nullable) NSString *bookId;
/// Current reading progress.
@property (nonatomic, strong, nullable) WRReadingProgress *readingProgress;
// ---- Chapter State ----
/// The chapter data for the currently loaded chapter.
@property (nonatomic, strong, nullable) WRChapterData *currentChapterData;
/// The page count calculator for the current chapter.
@property (nonatomic, strong, nullable) WRChapterPageCount *currentChapterPageCount;
/// The total number of chapters in the book.
@property (nonatomic, assign, readonly) NSUInteger totalChapters;
// ---- Reader Mode ----
/// Whether the reader is in doodle/drawing mode (for handwritten notes).
@property (nonatomic, assign, readonly) BOOL doodleMode;
/// Whether auto-read is active.
@property (nonatomic, assign, readonly) BOOL autoReadEnabled;
// ---- Typesetter ----
/// Whether the typesetter is currently recomposing (re-layout).
@property (nonatomic, assign, readonly) BOOL isRecomposing;
// ============================================================================
#pragma mark - Initialization
// ============================================================================
/// Full initialization with all options.
/// @param book The book model object.
/// @param progress Initial reading progress.
/// @param forceUseInitialProgress If YES, ignore any saved progress and use the given one.
/// @param doodleMode Start in doodle mode.
/// @param autoRead Start with auto-read enabled.
- (instancetype)initWithBook:(WRBook *)book
progress:(WRReadingProgress * __nullable)progress
forceUseInitialProgress:(BOOL)forceUseInitialProgress
doodleMode:(BOOL)doodleMode
autoRead:(BOOL)autoRead;
/// Convenience init with just a book ID (loads book data from cache/server).
- (instancetype)initWithBookId:(NSString *)bookId;
// ============================================================================
#pragma mark - Lifecycle
// ============================================================================
- (void)viewDidLoad;
// ============================================================================
#pragma mark - Page Rendering
// ============================================================================
/// Renders the page view for the given progress data. This is the main
/// entry point for displaying a page:
/// 1. Retrieves or computes the chapter data.
/// 2. Computes pagination for the current page size and typesetter settings.
/// 3. Creates/updates the WRPageView with the layout frame for the page range.
/// 4. Updates the page view controller's child.
///
/// @param pageView The page view to render into.
/// @param progressData The reading progress (chapter + page index).
/// @param source A string identifying the caller (for logging).
- (void)renderPageView:(WRPageView *)pageView
progressData:(WRReadingProgress *)progressData
source:(NSString *)source;
/// Recomposes (re-typesets) the current page view.
/// Called after font size, line spacing, or theme changes.
/// @param source A string identifying the caller.
- (void)recomposeCurrentPageViewWithSource:(NSString *)source;
/// Reloads all page views with new progress data.
/// Called after a full chapter change or settings change.
/// @param progressData The new reading progress.
/// @param source A string identifying the caller.
- (void)reloadPageViewsWithProgressData:(WRReadingProgress *)progressData
source:(NSString *)source;
// ============================================================================
#pragma mark - Chapter Navigation
// ============================================================================
/// Jumps to a specific chapter and position.
/// @param chapterIdx The chapter index (0-based).
/// @param position The page index within the chapter.
/// @param positionOfFile The character offset within the chapter (for precise positioning).
- (void)gotoChapterIdx:(NSUInteger)chapterIdx
position:(NSUInteger)position
positionOfFile:(NSUInteger)positionOfFile;
/// Advances to the next chapter. Resets page index to 0.
- (void)jumpReadingToNextChapter;
/// Goes back to the previous chapter. Sets page index to the last page.
- (void)jumpReadingToPreChapter;
// ============================================================================
#pragma mark - Page Flip Callback
// ============================================================================
/// Called by WRPageViewController when a page flip completes.
/// Updates progress, saves state, and triggers prefetching of adjacent chapters.
- (void)didFlipPage;
// ============================================================================
#pragma mark - Progress Persistence
// ============================================================================
/// Saves the current reading progress.
/// @param isAsync If YES, save asynchronously (non-blocking). If NO, save synchronously.
- (void)_saveReadingProgressAndIsAsync:(BOOL)isAsync;
// ============================================================================
#pragma mark - Typesetter
// ============================================================================
/// Modifies the typesetter attributes (font, size, spacing, etc.) and
/// triggers a full re-typeset.
/// @param block A block that receives a mutable dictionary of typesetter
/// attributes and modifies them in place.
- (void)changeTypesetterAttributesWithBlock:(void (^ __nonnull)(NSMutableDictionary *attrs))block;
// ============================================================================
#pragma mark - Pagination
// ============================================================================
/// Initializes or re-initializes the chapter page count calculator.
/// Called when the chapter changes, the view size changes, or typesetter
/// settings change.
- (void)initChapterPageCount;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,876 @@
//
// WRReaderViewController.m
// WeRead (微信读书)
//
// Detailed pseudo-code reconstruction of the main reader controller.
// 431 methods total. This file covers the key methods for page rendering,
// chapter navigation, progress saving, and typesetter management.
//
#import "WRReaderViewController.h"
#import "WRPageViewController.h"
#import "WRPageView.h"
#import "WRChapterData.h"
#import "WRChapterPageCount.h"
#import "WRCoreTextLayouter.h"
#import "WRCoreTextLayoutFrame.h"
#import "WRBook.h"
// ============================================================================
#pragma mark - Constants
// ============================================================================
static NSString *const kReadingProgressKeyPrefix = @"weread_progress_";
static NSString *const kChapterCachePrefix = @"weread_chapter_";
static NSString *const kPageCountCachePrefix = @"weread_pagecount_";
static NSString *const kReaderDidFlipPage = @"WRReaderDidFlipPage";
static NSString *const kReaderChapterLoaded = @"WRReaderChapterLoaded";
static NSString *const kReaderProgressSaved = @"WRReaderProgressSaved";
// ============================================================================
#pragma mark - WRReadingProgress
// ============================================================================
@implementation WRReadingProgress
- (instancetype)init {
self = [super init];
if (self) {
_chapterIndex = 0;
_pageIndex = 0;
_charIndex = 0;
_scrollOffset = 0.0;
_readPercentage = 0.0;
}
return self;
}
- (instancetype)copyWithZone:(NSZone *)zone {
WRReadingProgress *copy = [[WRReadingProgress alloc] init];
copy.bookId = self.bookId;
copy.chapterIndex = self.chapterIndex;
copy.pageIndex = self.pageIndex;
copy.charIndex = self.charIndex;
copy.scrollOffset = self.scrollOffset;
copy.chapterId = self.chapterId;
copy.readPercentage = self.readPercentage;
return copy;
}
@end
// ============================================================================
#pragma mark - WRReaderViewController ()
// ============================================================================
@interface WRReaderViewController ()
@property (nonatomic, strong) WRBook *book;
@property (nonatomic, copy) NSString *bookId;
@property (nonatomic, strong) WRPageViewController *pageViewController;
@property (nonatomic, assign) BOOL doodleMode;
@property (nonatomic, assign) BOOL autoReadEnabled;
@property (nonatomic, assign) BOOL isRecomposing;
@property (nonatomic, assign) BOOL forceUseInitialProgress;
@property (nonatomic, strong) WRReadingProgress *initialProgress;
@property (nonatomic, strong) NSMutableDictionary<NSNumber *, WRChapterData *> *chapterDataCache;
@property (nonatomic, strong) NSMutableDictionary<NSString *, WRChapterPageCount *> *pageCountCache;
@property (nonatomic, strong) NSMutableArray<WRPageView *> *activePageViews;
@property (nonatomic, strong) dispatch_queue_t chapterLoadQueue;
@property (nonatomic, assign) BOOL isLoadingChapter;
@property (nonatomic, assign) NSUInteger loadRetryCount;
@property (nonatomic, assign) NSUInteger maxRetryCount;
@property (nonatomic, strong) NSTimer *autoReadTimer;
@property (nonatomic, strong) NSTimer *progressSaveTimer;
@end
// ============================================================================
#pragma mark - WRReaderViewController Implementation
// ============================================================================
@implementation WRReaderViewController
// ============================================================================
#pragma mark - Initialization
// ============================================================================
- (instancetype)initWithBook:(WRBook *)book
progress:(WRReadingProgress *)progress
forceUseInitialProgress:(BOOL)forceUseInitialProgress
doodleMode:(BOOL)doodleMode
autoRead:(BOOL)autoRead {
self = [super initWithNibName:nil bundle:nil];
if (self) {
_book = book;
_bookId = book.bookId;
_initialProgress = progress;
_forceUseInitialProgress = forceUseInitialProgress;
_doodleMode = doodleMode;
_autoReadEnabled = autoRead;
_maxRetryCount = 3;
_loadRetryCount = 0;
_chapterDataCache = [NSMutableDictionary dictionary];
_pageCountCache = [NSMutableDictionary dictionary];
_activePageViews = [NSMutableArray array];
_chapterLoadQueue = dispatch_queue_create(
"com.weread.chapterload", DISPATCH_QUEUE_SERIAL);
if (forceUseInitialProgress && progress) {
_readingProgress = [progress copy];
} else {
_readingProgress = [self _loadSavedProgress] ?: progress;
}
if (!_readingProgress) {
_readingProgress = [[WRReadingProgress alloc] init];
_readingProgress.bookId = _bookId;
}
[[NSNotificationCenter defaultCenter]
addObserver:self selector:@selector(_handleMemoryWarning:)
name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
[[NSNotificationCenter defaultCenter]
addObserver:self selector:@selector(_handleAutoReadAdvance:)
name:@"WRPageViewAutoReadAdvance" object:nil];
[[NSNotificationCenter defaultCenter]
addObserver:self selector:@selector(_handleFontSizeChange:)
name:@"WRPageViewFontSizeChange" object:nil];
}
return self;
}
- (instancetype)initWithBookId:(NSString *)bookId {
self = [super initWithNibName:nil bundle:nil];
if (self) {
_bookId = bookId;
_maxRetryCount = 3;
_chapterDataCache = [NSMutableDictionary dictionary];
_pageCountCache = [NSMutableDictionary dictionary];
_activePageViews = [NSMutableArray array];
_chapterLoadQueue = dispatch_queue_create(
"com.weread.chapterload", DISPATCH_QUEUE_SERIAL);
[self _loadBookDataWithBookId:bookId];
}
return self;
}
- (void)dealloc {
[_autoReadTimer invalidate];
[_progressSaveTimer invalidate];
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
// ============================================================================
#pragma mark - View Lifecycle
// ============================================================================
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
// Create and embed the page view controller.
WRPageFlippingStyle flipStyle = [self _userPreferredFlipStyle];
UIPageViewControllerTransitionStyle uiStyle =
(flipStyle == WRPageFlippingStyleCurl)
? UIPageViewControllerTransitionStylePageCurl
: UIPageViewControllerTransitionStyleScroll;
self.pageViewController =
[[WRPageViewController alloc] initWithDelegate:self
withPageType:uiStyle
pageFlippingStyle:flipStyle];
[self addChildViewController:self.pageViewController];
self.pageViewController.view.frame = self.view.bounds;
self.pageViewController.view.autoresizingMask =
UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[self.view addSubview:self.pageViewController.view];
[self.pageViewController didMoveToParentViewController:self];
// Load the initial chapter.
[self _loadChapterAtIndex:self.readingProgress.chapterIndex
completion:^(WRChapterData *chapterData, NSError *error) {
if (chapterData) {
[self _displayChapter:chapterData
atPageIndex:self.readingProgress.pageIndex
animated:NO];
} else {
[self _showChapterLoadError:error];
}
}];
// Start the progress save timer (every 30 seconds).
_progressSaveTimer =
[NSTimer scheduledTimerWithTimeInterval:30.0
target:self
selector:@selector(_periodicProgressSave)
userInfo:nil
repeats:YES];
if (_autoReadEnabled) {
[self _startAutoRead];
}
}
// ============================================================================
#pragma mark - Page Rendering
// ============================================================================
///
/// The main rendering pipeline:
/// 1. Look up or compute WRChapterData for the progress's chapter.
/// 2. Compute pagination via WRChapterPageCount.
/// 3. Get the character range for the target page.
/// 4. Create a WRCoreTextLayoutFrame for that range.
/// 5. Assign the layout frame to the WRPageView.
/// 6. Trigger -setNeedsDisplay on the page view.
///
- (void)renderPageView:(WRPageView *)pageView
progressData:(WRReadingProgress *)progressData
source:(NSString *)source {
if (!pageView || !progressData) return;
NSLog(@"[WRReader] renderPageView source=%@ ch=%lu page=%lu",
source, (unsigned long)progressData.chapterIndex,
(unsigned long)progressData.pageIndex);
// Step 1: Get or compute chapter data.
WRChapterData *chapterData = [self _chapterDataForIndex:progressData.chapterIndex];
if (!chapterData) {
[pageView showLoadingWithProgress:0.0];
return;
}
// Step 2: Compute pagination.
WRChapterPageCount *pageCount = [self _pageCountForChapterData:chapterData];
if (!pageCount || pageCount.totalPages == 0) {
[pageView showErrorWithMessage:@"页面计算失败"];
return;
}
// Step 3: Get the page range.
NSUInteger pageIdx = progressData.pageIndex;
if (pageIdx >= pageCount.totalPages) {
pageIdx = pageCount.totalPages - 1;
}
NSRange pageRange = [pageCount rangeForPageAtIndex:pageIdx];
if (pageRange.location == NSNotFound) {
[pageView showErrorWithMessage:@"页面范围无效"];
return;
}
// Step 4: Create layout frame for this page range.
WRCoreTextLayouter *layouter = chapterData.layouter;
WRCoreTextLayoutFrame *layoutFrame =
[layouter layoutFrameForRange:pageRange
size:self.view.bounds.size
insets:chapterData.contentInsets];
// Step 5: Assign to page view.
pageView.chapterData = chapterData;
pageView.layoutFrame = layoutFrame;
pageView.pageIndex = pageIdx;
// Step 6: Trigger redraw.
[pageView setNeedsDisplay];
// Step 7: Update friend reviews button.
[pageView friendReviewsCount:0];
// Step 8: Prefetch adjacent chapters.
[self _prefetchAdjacentChaptersForIndex:progressData.chapterIndex];
}
/// Recomposes the current page view after a typesetter change.
- (void)recomposeCurrentPageViewWithSource:(NSString *)source {
self.isRecomposing = YES;
NSLog(@"[WRReader] recomposeCurrentPageView source=%@", source);
[self.pageCountCache removeAllObjects];
WRChapterData *chapterData = self.currentChapterData;
if (chapterData) {
[self _reTypesetChapterData:chapterData];
}
[self renderPageView:self.activePageViews.firstObject
progressData:self.readingProgress
source:source];
self.isRecomposing = NO;
}
/// Reloads all page views after a full settings change.
- (void)reloadPageViewsWithProgressData:(WRReadingProgress *)progressData
source:(NSString *)source {
[self.chapterDataCache removeAllObjects];
[self.pageCountCache removeAllObjects];
self.readingProgress = progressData;
[self _loadChapterAtIndex:progressData.chapterIndex
completion:^(WRChapterData *chapterData, NSError *error) {
if (chapterData) {
[self _displayChapter:chapterData
atPageIndex:progressData.pageIndex
animated:NO];
}
}];
}
// ============================================================================
#pragma mark - Chapter Navigation
// ============================================================================
/// Jumps to a specific chapter and position.
- (void)gotoChapterIdx:(NSUInteger)chapterIdx
position:(NSUInteger)position
positionOfFile:(NSUInteger)positionOfFile {
NSLog(@"[WRReader] gotoChapterIdx:%lu position:%lu posFile:%lu",
(unsigned long)chapterIdx, (unsigned long)position,
(unsigned long)positionOfFile);
self.readingProgress.chapterIndex = chapterIdx;
self.readingProgress.pageIndex = position;
self.readingProgress.charIndex = positionOfFile;
// If the chapter is already cached, display it directly.
WRChapterData *cachedData = self.chapterDataCache[@(chapterIdx)];
if (cachedData) {
self.currentChapterData = cachedData;
[self _displayChapter:cachedData atPageIndex:position animated:YES];
return;
}
// Otherwise, load asynchronously.
[self _loadChapterAtIndex:chapterIdx
completion:^(WRChapterData *chapterData, NSError *error) {
if (chapterData) {
if (positionOfFile > 0) {
WRChapterPageCount *pageCount = [self _pageCountForChapterData:chapterData];
NSUInteger pageIdx = [pageCount pageIndexForCharacterIndex:positionOfFile];
self.readingProgress.pageIndex = pageIdx;
}
[self _displayChapter:chapterData
atPageIndex:self.readingProgress.pageIndex
animated:YES];
} else {
[self _showChapterLoadError:error];
}
}];
}
/// Advances to the next chapter.
- (void)jumpReadingToNextChapter {
NSUInteger nextIdx = self.readingProgress.chapterIndex + 1;
if (nextIdx >= self.totalChapters) return;
[self gotoChapterIdx:nextIdx position:0 positionOfFile:0];
}
/// Goes back to the previous chapter.
- (void)jumpReadingToPreChapter {
if (self.readingProgress.chapterIndex == 0) return;
NSUInteger prevIdx = self.readingProgress.chapterIndex - 1;
[self gotoChapterIdx:prevIdx position:0 positionOfFile:0];
}
// ============================================================================
#pragma mark - Page Flip Callback
// ============================================================================
/// Called by WRPageViewController when a page flip animation completes.
- (void)didFlipPage {
// Step 1: Update reading progress.
NSUInteger newPageIndex = self.pageViewController.currentPageIndex;
self.readingProgress.pageIndex = newPageIndex;
WRChapterPageCount *pageCount = self.currentChapterPageCount;
if (pageCount) {
NSRange pageRange = [pageCount rangeForPageAtIndex:newPageIndex];
if (pageRange.location != NSNotFound) {
self.readingProgress.charIndex = pageRange.location;
}
}
// Step 2: Calculate read percentage.
[self _updateReadPercentage];
// Step 3: Save progress (async).
[self _saveReadingProgressAndIsAsync:YES];
// Step 4: Notify delegate.
if ([self.readerDelegate respondsToSelector:
@selector(readerViewController:didUpdateProgress:)]) {
[self.readerDelegate readerViewController:self
didUpdateProgress:self.readingProgress];
}
// Step 5: Prefetch adjacent chapters.
[self _prefetchAdjacentChaptersForIndex:self.readingProgress.chapterIndex];
// Step 6: Post notification.
[[NSNotificationCenter defaultCenter]
postNotificationName:kReaderDidFlipPage
object:self
userInfo:@{@"progress": self.readingProgress}];
}
// ============================================================================
#pragma mark - Progress Persistence
// ============================================================================
/// Saves the reading progress to NSUserDefaults.
- (void)_saveReadingProgressAndIsAsync:(BOOL)isAsync {
WRReadingProgress *progress = self.readingProgress;
if (!progress || !progress.bookId) return;
NSString *key = [kReadingProgressKeyPrefix stringByAppendingString:progress.bookId];
NSDictionary *dict = @{
@"bookId": progress.bookId ?: @"",
@"chapterIndex": @(progress.chapterIndex),
@"pageIndex": @(progress.pageIndex),
@"charIndex": @(progress.charIndex),
@"scrollOffset": @(progress.scrollOffset),
@"chapterId": progress.chapterId ?: @"",
@"readPercentage": @(progress.readPercentage),
@"timestamp": @([[NSDate date] timeIntervalSince1970]),
};
if (isAsync) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:key];
[[NSUserDefaults standardUserDefaults] synchronize];
dispatch_async(dispatch_get_main_queue(), ^{
[[NSNotificationCenter defaultCenter]
postNotificationName:kReaderProgressSaved object:self];
});
});
} else {
[[NSUserDefaults standardUserDefaults] setObject:dict forKey:key];
[[NSUserDefaults standardUserDefaults] synchronize];
}
}
/// Loads the saved progress from NSUserDefaults.
- (WRReadingProgress * __nullable)_loadSavedProgress {
NSString *key = [kReadingProgressKeyPrefix stringByAppendingString:self.bookId ?: @""];
NSDictionary *dict = [[NSUserDefaults standardUserDefaults] dictionaryForKey:key];
if (!dict) return nil;
WRReadingProgress *progress = [[WRReadingProgress alloc] init];
progress.bookId = dict[@"bookId"];
progress.chapterIndex = [dict[@"chapterIndex"] unsignedIntegerValue];
progress.pageIndex = [dict[@"pageIndex"] unsignedIntegerValue];
progress.charIndex = [dict[@"charIndex"] unsignedIntegerValue];
progress.scrollOffset = [dict[@"scrollOffset"] doubleValue];
progress.chapterId = dict[@"chapterId"];
progress.readPercentage = [dict[@"readPercentage"] doubleValue];
return progress;
}
// ============================================================================
#pragma mark - Typesetter
// ============================================================================
/// Modifies typesetter attributes and triggers re-typeset.
- (void)changeTypesetterAttributesWithBlock:(void (^ __nonnull)(NSMutableDictionary *attrs))block {
NSMutableDictionary *attrs = [self _currentTypesetterAttributes].mutableCopy;
block(attrs);
[self _saveTypesetterAttributes:attrs];
[self.pageCountCache removeAllObjects];
WRCoreTextLayouter *layouter = self.currentChapterData.layouter;
[layouter updateAttributes:attrs];
[self recomposeCurrentPageViewWithSource:@"typesetterChange"];
}
/// Reads the current typesetter attributes from UserDefaults.
- (NSDictionary *)_currentTypesetterAttributes {
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
return @{
@"fontSize": @([defaults floatForKey:@"WRTypesetterFontSize"] ?: 18.0),
@"lineSpacing": @([defaults floatForKey:@"WRTypesetterLineSpacing"] ?: 1.5),
@"fontFamily": [defaults stringForKey:@"WRTypesetterFontFamily"] ?: @"PingFang SC",
@"paragraphSpacing": @([defaults floatForKey:@"WRTypesetterParaSpacing"] ?: 8.0),
};
}
/// Saves typesetter attributes to UserDefaults.
- (void)_saveTypesetterAttributes:(NSDictionary *)attrs {
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
[defaults setFloat:[attrs[@"fontSize"] floatValue] forKey:@"WRTypesetterFontSize"];
[defaults setFloat:[attrs[@"lineSpacing"] floatValue] forKey:@"WRTypesetterLineSpacing"];
[defaults setObject:attrs[@"fontFamily"] forKey:@"WRTypesetterFontFamily"];
[defaults setFloat:[attrs[@"paragraphSpacing"] floatValue] forKey:@"WRTypesetterParaSpacing"];
[defaults synchronize];
}
// ============================================================================
#pragma mark - Pagination
// ============================================================================
/// Initializes or re-initializes the chapter page count calculator.
- (void)initChapterPageCount {
WRChapterData *chapterData = self.currentChapterData;
if (!chapterData) return;
// Generate the cache key based on book ID and current typesetter settings.
NSString *cacheKey = [WRChapterPageCount currentCacheKeyWithBookId:self.bookId];
// Check cache first.
WRChapterPageCount *cached = self.pageCountCache[cacheKey];
if (cached) {
self.currentChapterPageCount = cached;
return;
}
// Create a new page count calculator.
WRChapterPageCount *pageCount = [[WRChapterPageCount alloc] init];
pageCount.bookId = self.bookId;
pageCount.chapterId = chapterData.chapterId;
// Compute page ranges by simulating CoreText typesetting.
[pageCount recalculatePageRangesForAttributedString:chapterData.typesetAttributedString
drawingSize:self.view.bounds.size
margins:chapterData.contentInsets];
// Cache the result.
self.pageCountCache[cacheKey] = pageCount;
self.currentChapterPageCount = pageCount;
}
// ============================================================================
#pragma mark - Internal Chapter Loading
// ============================================================================
/// Loads a chapter by index, with caching and retry logic.
- (void)_loadChapterAtIndex:(NSUInteger)index
completion:(void (^)(WRChapterData *, NSError *))completion {
// Check cache first.
WRChapterData *cached = self.chapterDataCache[@(index)];
if (cached) {
if (completion) completion(cached, nil);
return;
}
// Prevent duplicate loads.
if (self.isLoadingChapter) return;
self.isLoadingChapter = YES;
__weak typeof(self) weakSelf = self;
dispatch_async(self.chapterLoadQueue, ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;
// In the real implementation, this:
// 1. Fetches the chapter HTML/content from the server or local cache.
// 2. Parses the HTML into an NSAttributedString.
// 3. Runs WRCoreTextLayouter to typeset the chapter.
// 4. Stores the result in the cache.
// For this reconstruction, we simulate the flow:
NSError *error = nil;
WRChapterData *chapterData = [strongSelf _fetchChapterDataForIndex:index
error:&error];
dispatch_async(dispatch_get_main_queue(), ^{
strongSelf.isLoadingChapter = NO;
if (chapterData) {
strongSelf.chapterDataCache[@(index)] = chapterData;
strongSelf.loadRetryCount = 0;
if (completion) completion(chapterData, nil);
} else {
// Retry logic.
if (strongSelf.loadRetryCount < strongSelf.maxRetryCount) {
strongSelf.loadRetryCount++;
[strongSelf _loadChapterAtIndex:index completion:completion];
} else {
strongSelf.loadRetryCount = 0;
if (completion) completion(nil, error);
}
}
});
});
}
/// Fetches and typesets a chapter (placeholder for the real network/cache logic).
- (WRChapterData * __nullable)_fetchChapterDataForIndex:(NSUInteger)index
error:(NSError **)errorOut {
// In the real app, this method:
// 1. Checks local SQLite/LevelDB cache for the chapter content.
// 2. If not cached, makes an API request to the WeRead server.
// 3. Receives chapter HTML (potentially encrypted/obfuscated).
// 4. Decrypts and parses the HTML into an NSAttributedString.
// 5. Creates a WRCoreTextLayouter with the current typesetter attributes.
// 6. Runs the layouter to compute line breaks and page breaks.
// 7. Returns the populated WRChapterData.
// Placeholder: return nil to simulate a network fetch that needs to happen
// in the real binary.
if (errorOut) {
*errorOut = [NSError errorWithDomain:@"com.weread.reader"
code:-1
userInfo:@{NSLocalizedDescriptionKey: @"Not implemented in reconstruction"}];
}
return nil;
}
/// Returns the chapter data for the given index (from cache or nil).
- (WRChapterData * __nullable)_chapterDataForIndex:(NSUInteger)index {
return self.chapterDataCache[@(index)];
}
/// Returns the page count for the given chapter data, computing it if needed.
- (WRChapterPageCount * __nullable)_pageCountForChapterData:(WRChapterData *)chapterData {
if (!chapterData) return nil;
NSString *cacheKey = [WRChapterPageCount currentCacheKeyWithBookId:self.bookId];
WRChapterPageCount *cached = self.pageCountCache[cacheKey];
if (cached) return cached;
// Compute now.
[self initChapterPageCount];
return self.currentChapterPageCount;
}
/// Re-typesets a chapter data object (after font/spacing changes).
- (void)_reTypesetChapterData:(WRChapterData *)chapterData {
if (!chapterData.sourceAttributedString) return;
// Re-create the layouter with updated attributes.
NSDictionary *attrs = [self _currentTypesetterAttributes];
WRCoreTextLayouter *layouter = [[WRCoreTextLayouter alloc]
initWithAttributedString:chapterData.sourceAttributedString
attributes:attrs];
chapterData.layouter = layouter;
// Re-generate the typeset attributed string.
chapterData.typesetAttributedString = [layouter typesetAttributedString];
// Re-generate page ranges.
[self initChapterPageCount];
}
// ============================================================================
#pragma mark - Chapter Prefetching
// ============================================================================
/// Prefetches the next and previous chapters so they're ready when the
/// user flips to them.
- (void)_prefetchAdjacentChaptersForIndex:(NSUInteger)index {
// Prefetch next chapter.
if (index + 1 < self.totalChapters) {
NSUInteger nextIdx = index + 1;
if (!self.chapterDataCache[@(nextIdx)]) {
dispatch_async(self.chapterLoadQueue, ^{
[self _fetchChapterDataForIndex:nextIdx error:NULL];
});
}
}
// Prefetch previous chapter.
if (index > 0) {
NSUInteger prevIdx = index - 1;
if (!self.chapterDataCache[@(prevIdx)]) {
dispatch_async(self.chapterLoadQueue, ^{
[self _fetchChapterDataForIndex:prevIdx error:NULL];
});
}
}
}
// ============================================================================
#pragma mark - Display Helpers
// ============================================================================
/// Displays a chapter at the given page index.
- (void)_displayChapter:(WRChapterData *)chapterData
atPageIndex:(NSUInteger)pageIndex
animated:(BOOL)animated {
self.currentChapterData = chapterData;
[self initChapterPageCount];
// Create a page view for the initial display.
WRPageView *pageView = [[WRPageView alloc] initWithFrame:self.view.bounds];
[self.activePageViews removeAllObjects];
[self.activePageViews addObject:pageView];
// Render the page.
WRReadingProgress *progress = [self.readingProgress copy];
progress.pageIndex = pageIndex;
[self renderPageView:pageView progressData:progress source:@"displayChapter"];
// Set the page view controller's initial view controller.
UIViewController *pageContentVC = [[UIViewController alloc] init];
pageContentVC.view = pageView;
[self.pageViewController setViewControllers:@[pageContentVC]
direction:UIPageViewControllerNavigationDirectionForward
animated:animated
completion:nil];
}
/// Shows an error state when chapter loading fails.
- (void)_showChapterLoadError:(NSError *)error {
NSLog(@"[WRReader] Chapter load error: %@", error.localizedDescription);
// In the real app, this shows a toast or error overlay.
}
/// Calculates and updates the overall read percentage.
- (void)_updateReadPercentage {
NSUInteger totalChapters = self.totalChapters;
if (totalChapters == 0) return;
NSUInteger currentChapter = self.readingProgress.chapterIndex;
WRChapterPageCount *pageCount = self.currentChapterPageCount;
NSUInteger totalPages = pageCount.totalPages;
NSUInteger currentPage = self.readingProgress.pageIndex;
// Calculate: (chaptersCompleted + currentPage/totalPages) / totalChapters
double chapterProgress = (totalPages > 0)
? (double)currentPage / (double)totalPages
: 0.0;
double overall = ((double)currentChapter + chapterProgress) / (double)totalChapters;
self.readingProgress.readPercentage = MIN(MAX(overall, 0.0), 1.0);
}
// ============================================================================
#pragma mark - Auto-Read
// ============================================================================
/// Starts the auto-read timer.
- (void)_startAutoRead {
[_autoReadTimer invalidate];
_autoReadTimer =
[NSTimer scheduledTimerWithTimeInterval:5.0
target:self
selector:@selector(_autoReadTick)
userInfo:nil
repeats:YES];
}
/// Stops auto-read.
- (void)_stopAutoRead {
[_autoReadTimer invalidate];
_autoReadTimer = nil;
}
/// Called each auto-read tick to advance the page.
- (void)_autoReadTick {
BOOL advanced = [self.pageViewController goToNextPageAnimated:YES];
if (!advanced) {
// At the end of the chapter, try to go to the next chapter.
if (self.readingProgress.chapterIndex + 1 < self.totalChapters) {
[self jumpReadingToNextChapter];
} else {
// At the end of the book, stop auto-read.
[self _stopAutoRead];
}
}
}
// ============================================================================
#pragma mark - Notification Handlers
// ============================================================================
/// Handles memory warning by purging non-current chapter data from cache.
- (void)_handleMemoryWarning:(NSNotification *)note {
NSLog(@"[WRReader] Memory warning received, purging chapter cache.");
NSUInteger currentIdx = self.readingProgress.chapterIndex;
WRChapterData *currentData = self.chapterDataCache[@(currentIdx)];
[self.chapterDataCache removeAllObjects];
if (currentData) {
self.chapterDataCache[@(currentIdx)] = currentData;
}
[self.pageCountCache removeAllObjects];
}
/// Handles the auto-read advance notification from WRPageView.
- (void)_handleAutoReadAdvance:(NSNotification *)note {
if (!_autoReadEnabled) return;
[self _autoReadTick];
}
/// Handles font size change notification from WRPageView.
- (void)_handleFontSizeChange:(NSNotification *)note {
NSInteger delta = [note.userInfo[@"delta"] integerValue];
if (delta == 0) return;
[self changeTypesetterAttributesWithBlock:^(NSMutableDictionary *attrs) {
CGFloat currentSize = [attrs[@"fontSize"] floatValue];
CGFloat newSize = currentSize + (CGFloat)delta;
newSize = MAX(12.0, MIN(36.0, newSize)); // Clamp to reasonable range.
attrs[@"fontSize"] = @(newSize);
}];
}
// ============================================================================
#pragma mark - Book Data Loading
// ============================================================================
/// Loads book metadata from cache or server.
- (void)_loadBookDataWithBookId:(NSString *)bookId {
// In the real app, this makes an API call to fetch book metadata:
// - Title, author, cover image URL
// - Chapter list (IDs, titles)
// - User's reading progress (if synced)
// - Trial/free chapter limits
//
// The response populates self.book and self.readingProgress.
}
// ============================================================================
#pragma mark - User Preferences
// ============================================================================
/// Returns the user's preferred page flipping style from UserDefaults.
- (WRPageFlippingStyle)_userPreferredFlipStyle {
NSInteger style = [[NSUserDefaults standardUserDefaults]
integerForKey:@"WRReaderFlipStyle"];
return (WRPageFlippingStyle)style;
}
/// Periodic progress save callback.
- (void)_periodicProgressSave {
[self _saveReadingProgressAndIsAsync:YES];
}
// ============================================================================
#pragma mark - WRPageViewControllerDelegate
// ============================================================================
/// Called when the page view controller finishes a page transition.
- (void)pageViewController:(WRPageViewController *)pageViewController
didFinishAnimating:(BOOL)finished
previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers
transitionCompleted:(BOOL)completed {
if (completed) {
[self didFlipPage];
}
}
/// Called before a page transition begins.
- (void)pageViewController:(WRPageViewController *)pageViewController
willTransitionToViewControllers:(NSArray<UIViewController *> *)pendingViewControllers {
// Pre-render the upcoming page view.
if (pendingViewControllers.count > 0) {
// The pending VC's page view needs its layout frame set.
// This happens in renderPageView: when the transition completes.
}
}
// ============================================================================
#pragma mark - Total Chapters (computed)
// ============================================================================
/// Returns the total number of chapters in the book.
- (NSUInteger)totalChapters {
// In the real app, this comes from the book model.
return self.book.chapterCount ?: 0;
}
@end
@@ -0,0 +1,439 @@
+[WRBookNetwork _removeTranslateHtml:]
+[WRBookNetwork addMileStone:callback:]
+[WRBookNetwork addReview:shareToWechat:audioArticleId:outlineContent:audioColumnId:callback:]
+[WRBookNetwork automaticallyMarkFinishReadingWithBookId:callback:]
+[WRBookNetwork chaptersInfoFromFile:checkTranslate:]
+[WRBookNetwork checkFMCards:callback:]_block_invoke
+[WRBookNetwork clearPreloadKVWithBookId:chapterUid:zipPath:]
+[WRBookNetwork dislikeReviewById:isDislike:withParams:callback:]
+[WRBookNetwork fetchLockInfoWithBookId:callback:]
+[WRBookNetwork fileContentWithChapter:book:shouldRemoveHtmlTags:filterTranslateContent:]
+[WRBookNetwork fileContentWithChapter:book:shouldRemoveHtmlTags:filterTranslateContent:]_block_invoke
+[WRBookNetwork handleUnzipErrorWithPath:plainBookDirectory:]
+[WRBookNetwork handleUnzipWithBookId:zipPath:encryptKey:plainBookDirectory:]
+[WRBookNetwork handleUnzipWithBookId:zipPath:encryptKey:plainBookDirectory:]_block_invoke
+[WRBookNetwork likeReviewById:isUnlike:withParams:callback:]
+[WRBookNetwork loadArticleBookDetailWithBookId:callback:]
+[WRBookNetwork loadBookDetailPodcastsWithBookId:withSynckey:withListType:withFilterType:withMaxIdx:withCount:]
+[WRBookNetwork loadBookInfoWithBookId:source:callback:]
+[WRBookNetwork loadBookLectureAuthors:callback:]
+[WRBookNetwork loadBookReadDetailInfoWithBookId:callback:]
+[WRBookNetwork loadBookReadInfoWithBookId:callback:]
+[WRBookNetwork loadBookReadInfoWithBookIdAndVid:vid:callback:]
+[WRBookNetwork loadBookmarkListWithBookId:syncKey:callback:]
+[WRBookNetwork loadChapterContentWithParam:callback:]
+[WRBookNetwork loadChapterContentWithParam:callback:]_block_invoke
+[WRBookNetwork loadExchangeRecordListWithParams:callback:]_block_invoke
+[WRBookNetwork loadFMCardsWithBookId:withSynckey:withListType:withFilterType:withMaxIdx:withCount:]
+[WRBookNetwork loadFriendMarkWithBookId:synckey:]_block_invoke
+[WRBookNetwork loadPodcastsWithSynckey:withBookId:withCount:withMaxIdx:callback:]
+[WRBookNetwork loadReadTimeWelfareActionWithBookId:opt:secretKey:firstEnter:]
+[WRBookNetwork loadRelatedBooksForReviewDetail:callback:]
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_2
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_5
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_6
+[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:]_block_invoke_7
+[WRBookNetwork loadTarForEpubBookId:chapters:isPreload:]
+[WRBookNetwork loadTopicReviewlist:callback:]
+[WRBookNetwork markReadingStatus:bookIds:isCancel:callback:]
+[WRBookNetwork markReadingStatus:withBookId:isCancel:withFinishInfo:callback:]
+[WRBookNetwork pollingChapterTranslateStatusWithBookId:isFreeTrialActive:referenceLocationDict:chapterTranslations:from:]
+[WRBookNetwork postReviewHideWithBookId:hide:callback:]
+[WRBookNetwork processChapterInfosFromChapterDownload:bookId:]
+[WRBookNetwork processEncryptedBookFileAtPath:encryptKey:book:chapterUid:isFromReview:]
+[WRBookNetwork repostReview:reposted:callback:]
+[WRBookNetwork resetChapterPaidIfNeededWithBookId:chapterUid:]
+[WRBookNetwork rewardReviewForId:price:timestamp:callback:]
+[WRBookNetwork savePreloadInfoWithDownloadParam:chaptersStr:timeFlag:tmpFilePath:encryptKey:]
+[WRBookNetwork searchResultsForBook:chapterUid:searchString:posBeg:posEnd:mode:callback:]
+[WRBookNetwork searchResultsForLocalBook:chapterUid:searchString:posBeg:posEnd:mode:callback:]
+[WRBookNetwork setFinishReading:withBookId:callback:]
+[WRBookNetwork setIsStartReading:withBookId:callback:]
+[WRBookNetwork storeTarEpubImageToDiskWithBookId:chapterUid:untarDirectory:]
+[WRBookNetwork uploadBookProgressAndReadingTime:callback:offlineCallback:]
+[WRBookNetworkReporter verifyZipWithBook:zipPath:fileSize:unzippedFiles:]
+[WRChapterData addUnderLineToAttributedString:range:itemId:style:color:]
+[WRChapterData freeTrialChapterCutOffStringLocaionWithAttributedString:book:]
+[WRChapterDownloadManger imageManagerForEpubBookWithBookId:]
+[WRChapterDownloadManger loadChapterContentNetworkWithParam:callback:]
+[WRChapterPageCount currentCacheKeyWithBookId:]
+[WRChapterPageCount rangeValueWithPageInfo:]
+[WRCoreTextLayouter convertHansToHantWithAttributedString:]_block_invoke
+[WREncryptedFileManager decryptContentsOfFile:forBookId:isFileLost:]
+[WREncryptedFileManager encryptFileForBookId:originalEncryptKey:atPath:toPath:]
+[WREncryptedFileManager keyForBookId:]
+[WREpubPositionConverter transformToNumberHtmlEntitesWithOriginHtmlEitites:]_block_invoke
+[WREpubTypesetter attributeStringWithFilePath:priority:insertArticleToolAttachment:insertBookChapterToolAttachment:insertRecommendView:book:chapter:pageFlippingStyle:renderErrorReason:isStyleFileNotFound:options:]
+[WREpubTypesetter tryReportTranslationError:bookId:chapter:isTranslationStyleNotFound:isTranslationContentNotFound:isTranslateTagButNoTranslateStyle:]
+[WRMarkContentStore markContentListForBookId:chapterUid:offset:count:]
+[WRMarkContentStore syncMarkContentListForBookId:chapterUid:]
+[WRMarketNetwork loadCategoryBooks:param:callback:]
+[WRPreloadBookManager clearKV]
+[WRPreloadBookManager encryptKeyForPath:bookId:]
+[WRPreloadBookManager fileNameForKey:bookId:]
+[WRPreloadBookManager removeEncryptKeyForPath:bookId:]
+[WRPreloadBookManager removeFileNameForKey:bookId:]
+[WRPreloadBookManager removeKVWithBookId:]
+[WRPreloadBookManager saveEncryptKey:forPath:bookId:]
+[WRPreloadBookManager saveFileNameDict:bookId:]
+[WRReaderBackgroundAdapter updateDownloadedBackground]_block_invoke
+[WRReaderBackgroundAdapter updateDownloadedBackground]_block_invoke_2
+[WRReaderBitmapColorHelper checkImageShouldAddBackgound:imageUrl:completedBlock:]
+[WRReaderBitmapColorHelper checkWhiteBlackImageAndTransformToTransparent:completedBlock:]
+[WRReaderBitmapColorHelper whiteToTransparentWithImage:completedBlock:]
+[WRReaderBookBorrowUtils redeemShortTimeReadWithTargetView:bookId:successBlock:]
+[WRReaderBookBorrowUtils redeemShortTimeReadWithTargetView:bookId:successBlock:]_block_invoke
+[WRReaderBookBorrowUtils showBorrowConfirmationPanelWithBorrowInfo:confirmBlock:cancelBlock:willShowBlock:]
+[WRReaderBookBorrowUtils showBorrowConfirmationPanelWithBorrowInfo:confirmBlock:cancelBlock:willShowBlock:]_block_invoke
+[WRReaderBookBorrowUtils showBorrowConfirmationPanelWithBorrowInfo:confirmBlock:cancelBlock:willShowBlock:]_block_invoke_3
+[WRReaderCatalogSearchManager appendSearchHistoryWithBookId:keyword:]
+[WRReaderCatalogSearchManager purgeSearchHistoryWithBookId:]
+[WRReaderCatalogSearchManager searchHistoryWithBookId:]
+[WRReaderCht2sManager autoConvertToCht2sStatusWithBook:dataTranslateMode:scene:hasRights:]
+[WRReaderCht2sManager canConvertCht2sWithBookId:]
+[WRReaderCht2sManager checkCht2sBookShouldSwitchLanguageForBookId:chapterUid:]
+[WRReaderContentNavigationManager addRangeValueToOutlineItem:]
+[WRReaderContentNavigationManager findParentNodesFromChapterOutlineLeafItems:]_block_invoke
+[WRReaderContentNavigationManager rangeForOutlineItem:]
+[WRReaderPencilNoteManager authCosForPencilDataWithSuffix:]_block_invoke
+[WRReaderPencilNoteManager checkDrawingExistsWithReviewItemId:reviewId:isDraft:]
+[WRReaderPencilNoteManager deleteAllReviewDrawings]
+[WRReaderPencilNoteManager deleteDrawingWithReviewItemId:reviewId:isDraft:]
+[WRReaderPencilNoteManager downloadDrawingDataFromCosWithUrl:desPath:callback:]
+[WRReaderPencilNoteManager downloadDrawingDataFromCosWithUrl:desPath:callback:]_block_invoke
+[WRReaderPencilNoteManager downloadDrawingWithReviewItemId:reviewId:drawingUrl:dataBlock:]_block_invoke_2
+[WRReaderPencilNoteManager downloadDrawingWithReviewItemId:reviewId:drawingUrl:dataBlock:]_block_invoke_3
+[WRReaderPencilNoteManager drawingFileDirectory]
+[WRReaderPencilNoteManager drawingFilePathWithReviewItemId:reviewId:isDraft:]
+[WRReaderPencilNoteManager imageFilePathWithReviewItemId:reviewId:]
+[WRReaderPencilNoteManager readDrawingWithReview:dataBlock:drawingUrlBlock:]_block_invoke
+[WRReaderPencilNoteManager readDrawingWithReview:dataBlock:drawingUrlBlock:]_block_invoke_2
+[WRReaderPencilNoteManager readDrawingWithReview:dataBlock:drawingUrlBlock:]_block_invoke_3
+[WRReaderPencilNoteManager uploadPencilDrawing:colorStyle:onlyUploadImage:canRetry:]
+[WRReaderPencilNoteManager uploadPencilDrawing:colorStyle:onlyUploadImage:canRetry:]_block_invoke
+[WRReaderPencilNoteManager uploadPencilNoteData:suffix:]
+[WRReaderPencilNoteManager uploadPencilNoteData:suffix:]_block_invoke
+[WRReaderPencilNoteManager uploadPencilNoteData:suffix:]_block_invoke_3
+[WRReaderPencilNoteManager writeDrawingDataToLocal:reviewItemId:reviewId:isDraft:]
+[WRReaderPencilNoteManager writeDrawingToLocal:reviewItemId:reviewId:isDraft:]
+[WRReaderProgress localProgressForBook:]
+[WRReaderTranslationManager canSwitchTranslationWithBookId:]
+[WRReaderTranslationManager chapterDataCacheKeyWithBookId:chapterUid:]
+[WRReaderTranslationManager isPlayChineseWithBookId:chapterUid:]
+[WRReaderTranslationManager tryPollingConvertStatusWithBook:isForTranslate:referenceLocationDict:isFreeTrialActive:chapterTranslations:updateTipsBlock:chapterTranslateCompletion:stopPollingBlock:errorBlock:isBatch:from:]_block_invoke
+[WRReaderTranslationManager updatePlayTranslation:forBookId:]
+[WRReaderTranslationManager wordCountWithBookId:chapterUid:]
+[WRReaderUnderlineStyleButtonManager bookmarkColorForColorStyle:underlineStyle:]
-[DTCoreTextGlyphRun newPathWithGlyphs]
-[DTHTMLAttributedStringBuilder _buildString]
-[DTHTMLAttributedStringBuilder _registerTagEndHandlers]_block_invoke_5
-[DTHTMLAttributedStringBuilder _registerTagStartHandlers]_block_invoke_11
-[DTHTMLAttributedStringBuilder parser:didStartElement:attributes:position:]
-[DTHTMLAttributedStringBuilder parser:foundCDATA:]
-[DTHTMLAttributedStringBuilder parser:foundCharacters:position:]
-[DTHTMLAttributedStringBuilder parserDidEndDocument:]
-[DTHTMLElement applyStyleDictionary:isLatinLanguageBook:]
-[DTHTMLElement attributedString]
-[DTHTMLElement interpretAttributes]
-[WRChapter isChapterContentDownloaded]
-[WRChapterData addAutoReadUnderLineInRange:style:color:]
-[WRChapterData addHighlightInRange:key:itemId:color:]
-[WRChapterData addReviewUnderlineInRange:itemId:type:]
-[WRChapterData addTempReviewHighlightInRange:itemId:]
-[WRChapterData addTempReviewHighlightInRange:itemId:color:]
-[WRChapterData deleteReviewUnderlineInRange:type:]
-[WRChapterData freeTrialChapterCutOffRealStringLocation]
-[WRChapterData generateOutlineContents]
-[WRChapterData markFreeTrialChapterCutOffStringLocation:]
-[WRChapterData rangeOfPage:]
-[WRChapterDownloadManger _preloadChapterContentWithBook:type:bookRank:]
-[WRChapterDownloadManger addDownloadTaskCount]
-[WRChapterDownloadManger createDownloadTaskSignalWithParam:callback:]_block_invoke
-[WRChapterDownloadManger createDownloadTaskSignalWithParam:callback:]_block_invoke_3
-[WRChapterDownloadManger downloadComicsChaptersIfNotExistWithParam:withCallback:]
-[WRChapterDownloadManger loadChapterContentWithParam:callback:]
-[WRChapterDownloadManger networkChanged]
-[WRChapterDownloadManger preloadShelfBooksForType:]
-[WRChapterDownloadManger removeDownloadTaskCount]
-[WRChapterDownloadManger resetChapterDownloadTaskCount]
-[WRChapterRecommendAlbumsView handleTouchAlbum:]
-[WRChapterRecommendAlbumsView handleTouchAlbum:]_block_invoke
-[WRChapterRecommendAlbumsView handleTouchAlbum:]_block_invoke_2
-[WRChapterRecommendAlbumsView initWithDataChangeSignal:]_block_invoke
-[WRCoreTextLayoutFrame avoidPageBreakInsideByRemovingLastLinesIfNeeded]
-[WRCoreTextLayoutFrame drawInContext:image:size:inRect:position:]
-[WRCoreTextLayoutFrame getRenderHeight]
-[WRCoreTextLayoutFrame lines]
-[WRCoreTextLayouter pageBackgroundImageAtRange:themeBgColor:]
-[WRCoreTextLayouter resizedImageForImagePath:rect:position:sizePattern:darkMode:themeBgColor:]
-[WREpubParser epubController:didFailWithError:]
-[WREpubPositionConverter indicesInFile:forRowColumnPairs:stringIndices:string:fileIndexOffset:stringIndexOffset:]
-[WREpubPositionConverter initIndices]
-[WREpubPositionConverter initWithFilePaths:attributedStrings:offset:isContainIntroFlyleaf:]
-[WREpubPositionConverter stringRangeFromFileRange:]
-[WRMarketPopToBookShelfTransition animateTransition:]_block_invoke_2
-[WRPreloadBookManager _preloadWholeBook:scene:]
-[WRPreloadBookManager _preloadWholeBook:scene:]_block_invoke
-[WRPreloadBookManager booksDirectoryInfoForNonVIPWithOnlyCalc:]
-[WRPreloadBookManager booksDirectoryInfoForVIPWithOnlyCalc:]
-[WRPreloadBookManager calcAndClearPreloadBookWithCompletion:onlyCalc:]
-[WRPreloadBookManager cleanUpPreloadBook]
-[WRPreloadBookManager cleanUpPreloadBook]_block_invoke
-[WRPreloadBookManager cleanUpPreloadBook]_block_invoke_2
-[WRPreloadBookManager downloadBook:uids:]
-[WRPreloadBookManager downloadBook:uids:]_block_invoke
-[WRPreloadBookManager downloadingUidsWithBookId:allChapters:fromChapterIdx:]
-[WRPreloadBookManager isBookCanPreloadForNonPayingVIP:]
-[WRPreloadBookManager isBookCanPreloadForPayingVIP:outReasons:]
-[WRPreloadBookManager preloadWithBook:fromChapterIdx:]
-[WRPreloadBookManager preloadWithBook:fromChapterIdx:]_block_invoke
-[WRPreloadBookManager refreshSetting]
-[WRPreloadBookManager remainingDiskSizeInMB]
-[WRPreloadBookManager resetBookDataWithBookId:]
-[WRPreloadBookManager stopCleanUpPreloadBookTask]
-[WRPreloadBookManager unzipBookCacheContentWithBookId:]
-[WRPreloadBookManager unzipBookCacheContentWithBookId:]_block_invoke
-[WRReaderAIKnowledgeViewController observeValueForKeyPath:ofObject:change:context:]
-[WRReaderAuthorReviewDetailViewController handleCommentButtonEvent:]
-[WRReaderAuthorReviewDetailViewController handleComposeButtonEvent:]_block_invoke
-[WRReaderAuthorReviewDetailViewController handleDeleteComment:]_block_invoke_2
-[WRReaderAuthorReviewDetailViewController handlePraiseButtonEvent:]
-[WRReaderAuthorReviewDetailViewController handlePraiseButtonEvent:]_block_invoke
-[WRReaderAuthorReviewDetailViewController handlePraiseButtonEvent:]_block_invoke_3
-[WRReaderAuthorReviewDetailViewController keyboardWillChangeFrameWithUserInfo:]_block_invoke
-[WRReaderAuthorReviewDetailViewController tableView:cellForRowAtIndexPath:]
-[WRReaderAuthorReviewListViewController handleAvatarButtonEvent:]
-[WRReaderAuthorReviewListViewController handlePraiseButtonEvent:]
-[WRReaderAuthorReviewListViewController handlePraiseButtonEvent:]_block_invoke
-[WRReaderAuthorReviewListViewController handlePraiseButtonEvent:]_block_invoke_3
-[WRReaderAuthorReviewListViewController tableView:cellForRowAtIndexPath:]
-[WRReaderAutoPlayer calcPageDuration]
-[WRReaderAutoPlayer clauseFinished:]
-[WRReaderAutoPlayer didBecomeActive]
-[WRReaderAutoPlayer initWithDelegate:chapterData:forVerticalReader:]
-[WRReaderAutoPlayer pause]
-[WRReaderAutoPlayer play]
-[WRReaderAutoPlayer setCurrentClauseIndex:]
-[WRReaderAutoPlayer setCurrentLocation:]
-[WRReaderAutoPlayer setSpeed:]
-[WRReaderAutoPlayer setStatus:]
-[WRReaderAutoPlayer setTmpClauseIndex:]
-[WRReaderAutoPlayer setTmpLocation:]
-[WRReaderAutoPlayer setupTimer]
-[WRReaderAutoPlayer stop]
-[WRReaderAutoPlayer willResignActive]
-[WRReaderBackground(WRReaderBackgroundAdapter_Private) downloadWithProgressHandler:completionHandler:]_block_invoke
-[WRReaderBookmarkViewController addBookmarkInCurrentReferenceArea]
-[WRReaderBookmarkViewController deleteBookmarkWithItemId:callback:]
-[WRReaderBrightnessChangeView brightSliderChanged:]
-[WRReaderBrightnessChangeView initReaderBackgroundViewUI]_block_invoke
-[WRReaderBrightnessChangeView initReaderBackgroundViewUI]_block_invoke_2
-[WRReaderBrightnessChangeView sizeThatFits:]
-[WRReaderCatalogContainerViewController pageController:didShowPageAtIndex:]_block_invoke
-[WRReaderCatalogContainerViewController pageController:titleConfigurationAtIndex:]
-[WRReaderCatalogContainerViewController pageController:viewControllerAtIndex:]
-[WRReaderCatalogContainerViewController selectCatalogViewController]
-[WRReaderCatalogContainerViewController selectOutlineViewController]
-[WRReaderCatalogContainerViewController selectSearchContentViewController]
-[WRReaderCatalogDataSource initWithBookId:type:delegate:]_block_invoke
-[WRReaderCatalogViewController shouldRefreshCurrentAndBackupIndexPathCell]
-[WRReaderChangeFirstIndentTableViewCell handleTapItemView:]
-[WRReaderChangeOrientationView tableView:cellForRowAtIndexPath:]
-[WRReaderChangePageTurningStyleView tableView:cellForRowAtIndexPath:]
-[WRReaderCht2sManager updatePlayCht2s:forBookId:]
-[WRReaderComicsViewModel chapterReviewForReviewId:]
-[WRReaderComicsViewModel contentForChapterUid:]
-[WRReaderComicsViewModel imageForURL:]_block_invoke_2
-[WRReaderComicsViewModel markAsLiked:forChapterUid:]
-[WRReaderComicsViewModel markAsLiked:forReview:]
-[WRReaderComicsViewModel preloadNextChapterIfNeededWithCurrentChapterUid:]_block_invoke
-[WRReaderComicsViewModel refreshAvailableRangeWithVisibleSectionRange:]
-[WRReaderContentNavigationManager didLoadDataWithSusseed:]_block_invoke
-[WRReaderContentNavigationManager findCurrentOutlineItemWithUniqId:chapterUid:]
-[WRReaderContentNavigationManager findNextOutlineItemInRanges:loadMoreChapterUids:]
-[WRReaderContentNavigationManager findPrevOutlineItemInRanges:loadMoreChapterUids:]
-[WRReaderContentNavigationManager hasNextOutlineItemInRanges:]
-[WRReaderContentNavigationManager hasNextOutlineItem]
-[WRReaderContentNavigationManager hasPrevOutlineItemInRanges:]
-[WRReaderContentNavigationManager hasPrevOutlineItem]
-[WRReaderContentNavigationManager initSearchDataSourceWithBook:]
-[WRReaderContentNavigationManager isCurrentOutlineItemVisible]
-[WRReaderContentNavigationManager jumpToNextOutlineItemWithPreviousLoadMoreChapterUids:]
-[WRReaderContentNavigationManager jumpToNextSearchItem]
-[WRReaderContentNavigationManager jumpToOutlineItem:]
-[WRReaderContentNavigationManager jumpToOutlineItemWithUniqId:chapterUid:highlight:]
-[WRReaderContentNavigationManager jumpToPrevOutlineItemWithPreviousLoadMoreChapterUids:]
-[WRReaderContentNavigationManager jumpToPrevSearchItem]
-[WRReaderContentNavigationManager jumpToSearchItem:]
-[WRReaderContentNavigationManager nextOutlineItemFromMemoryWithLoadMoreChapterUids:]
-[WRReaderContentNavigationManager prevOutlineItemFromMemoryWithLoadMoreChapterUids:]
-[WRReaderContentNavigationManager searchFrontAndBehindWithKeyword:chapterUid:position:addCurrentItem:callback:]
-[WRReaderContentNavigationManager searchFrontAndBehindWithKeyword:chapterUid:position:addCurrentItem:callback:]_block_invoke
-[WRReaderContentNavigationManager setCurrentSearchItem:]
-[WRReaderContentNavigationManager setupOutlineWithInitialDict:]
-[WRReaderContentNavigationManager updateOutlineDataWithBookId:]
-[WRReaderDictionaryViewController collectionView:cellForItemAtIndexPath:]
-[WRReaderDictionaryViewController observeValueForKeyPath:ofObject:change:context:]
-[WRReaderDictionaryViewController refreshData]_block_invoke_4
-[WRReaderEndOfTrialButton renderWithLabelDictionary:]
-[WRReaderEndOfTrialView handleCampaignButtonTapWithItem:]
-[WRReaderEndOfTrialView handleCampaignButtonTapWithItem:]_block_invoke
-[WRReaderEndOfTrialView parseRightsViewData]
-[WRReaderFloatReviewsViewController initHeaderToolViewIfNeeded]_block_invoke_2
-[WRReaderFloatReviewsViewController renderWithHyperlinksInfo:]
-[WRReaderFloatReviewsViewController setSelectedText:hyperlinksInfo:]
-[WRReaderFontSizeChangeView autoTestHandleChangeValueWithSlider:index:]
-[WRReaderFontSizeChangeView sizeThatFits:]
-[WRReaderFontSizeChangeView updateFontFamilyChangeButtonFont]
-[WRReaderHotUnderlinesViewController updateAppearanceForSectionHeaderView:inTableView:section:isPinned:]
-[WRReaderLastPageActionButtonContainerView createFontViewWithFinishIndex:iterationBlock:]
-[WRReaderLastPageBadgeView handleCheckButtonDownEvent:]
-[WRReaderLastPageBadgeView handleCheckButtonUpEvent:]
-[WRReaderLastPageBadgeView handleCheckButtonUpEvent:]_block_invoke_3
-[WRReaderLastPageBadgeView handleCheckButtonUpOutSideEvent:]
-[WRReaderLastPageViewController refreshFinishedBookInfo]_block_invoke
-[WRReaderLastPageViewController rootViewDidChangeIntrinsicSize:]
-[WRReaderLastPageViewController showing]
-[WRReaderMilestoneLabelView renderWithAttributedStrings:]
-[WRReaderMistakeViewController viewDidLoad]_block_invoke
-[WRReaderMistakeViewController viewDidLoad]_block_invoke_2
-[WRReaderModalView headerViewHeight]
-[WRReaderMoreOperationController renderWithBook:scene:]
-[WRReaderNoteViewModel initWithBookId:]
-[WRReaderOutlineViewController handleJumpToClickContentWithParams:]
-[WRReaderOutlineViewController initWithBookId:chapterUid:]_block_invoke
-[WRReaderOutlineViewController initWithBookId:chapterUid:]_block_invoke_2
-[WRReaderOutlineViewController viewDidLoad]
-[WRReaderPanelSelectionView panelHeaderHeight]
-[WRReaderPanelSelectionView panelTitle]
-[WRReaderPanelSelectionView tableView:cellForRowAtIndexPath:]
-[WRReaderPanelSelectionView tableView:didSelectRowAtIndexPath:]
-[WRReaderPanelSelectionView tableView:numberOfRowsInSection:]
-[WRReaderPencilNoteBaseView handleCanvasViewDrawingDidChange:]
-[WRReaderPencilNoteReviewManager editPencilReview:secretMode:imageDict:drawingUrl:didEditBlock:]_block_invoke
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_2
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_4
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_5
-[WRReaderPencilNoteReviewManager tryResendAllDraftDrawingReviews]_block_invoke_6
-[WRReaderPencilNoteReviewManager uploadDrawing:thenEditReview:secretMode:readerVC:didUploadBlock:didEditReviewBlock:didWriteReviewBlock:]_block_invoke
-[WRReaderPencilNoteReviewManager writeReview:secretMode:readerVC:imageDict:drawingUrl:didWriteBlock:]
-[WRReaderPencilNoteReviewManager writeReview:secretMode:readerVC:imageDict:drawingUrl:didWriteBlock:]_block_invoke
-[WRReaderPencilNoteReviewManager writeReview:secretMode:readerVC:imageDict:drawingUrl:didWriteBlock:]_block_invoke_2
-[WRReaderPencilNoteViewController addPageReview:]
-[WRReaderPencilNoteViewController addRangeReview:]
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview:pageNoteView:]
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview:pageNoteView:]_block_invoke
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview:pageNoteView:]_block_invoke_2
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview]
-[WRReaderPencilNoteViewController checkUpdatePencilPageReview]_block_invoke
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]_block_invoke
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]_block_invoke_2
-[WRReaderPencilNoteViewController checkUpdatePencilRangeReview]_block_invoke_3
-[WRReaderPencilNoteViewController checkUpdateRangeReviewDrawingAsDraft]
-[WRReaderPencilNoteViewController dealloc]
-[WRReaderPencilNoteViewController deleteEmptyDrawingReview:]_block_invoke_2
-[WRReaderPencilNoteViewController editPencilReview:secretMode:imageDict:drawingUrl:]_block_invoke
-[WRReaderPencilNoteViewController editRangeReviewForColorStyleSilentWithReview:]_block_invoke
-[WRReaderPencilNoteViewController editRangeReviewForColorStyleSilentWithReview:]_block_invoke_2
-[WRReaderPencilNoteViewController handleBookmarkUpdatedNotification:]
-[WRReaderPencilNoteViewController handleCloseRangeReviewEditWithShowAlert:]
-[WRReaderPencilNoteViewController handleCloseRangeReviewEditWithShowAlert:]_block_invoke
-[WRReaderPencilNoteViewController handleDeleteRangeReview]_block_invoke_2
-[WRReaderPencilNoteViewController init]
-[WRReaderPencilNoteViewController resetContextWithRangeReviews:chapterIdx:defaultSecretMode:]
-[WRReaderPencilNoteViewController setupPageNoteView]_block_invoke
-[WRReaderPencilNoteViewController setupRangeNoteView]
-[WRReaderPencilNoteViewController setupRangeNoteView]_block_invoke
-[WRReaderPencilNoteViewController switchToRangeReview:curRange:curColorStyle:]
-[WRReaderPencilPageNoteListView addCanvasViewDrawingDidChangeBlockWithNoteView:]_block_invoke
-[WRReaderPencilPageNoteListView autoSaveEditingDrawingToLocalAsDraft]
-[WRReaderPencilPageNoteListView handleUpdatePageNoteReviewsWithBlock:]
-[WRReaderProgressChangeView handleNextButtonClick:]
-[WRReaderProgressChangeView handlePreButtonClick:]
-[WRReaderProgressChangeView sizeThatFits:]
-[WRReaderProgressData initWithBook:forPDF:]
-[WRReaderProgressData toCGIJsonDict]
-[WRReaderReviewsRNViewController handleWriteReviewButtonEvent:]
-[WRReaderSegmentViewController handleNotesOutputComputerButton]_block_invoke_2
-[WRReaderSegmentViewController initNoteControllerIfNeeded]
-[WRReaderSinglePurchaseEndTrailBookContentView renderData]
-[WRReaderTextSelector updateTurningPageTimerWithGesture:]
-[WRReaderTimer handleReadingTime:]
-[WRReaderTimer setActive:]
-[WRReaderTimer startTimer]
-[WRReaderUnderlineStyleButtonManager updateSelectedStyleButtonUI]
-[WRReaderViewController _saveReadingProgressAndIsAsync:]
-[WRReaderViewController addObservers]_block_invoke
-[WRReaderViewController addObservers]_block_invoke_2
-[WRReaderViewController animateAfterWritePageReview]_block_invoke
-[WRReaderViewController autoTestGetCurrentScreenContentString]
-[WRReaderViewController calculateReadingTimeAndUploadIfNeeded]
-[WRReaderViewController calculateReadingTimeFromLastReadDate]
-[WRReaderViewController canReadFromLocalWithChapterData:]
-[WRReaderViewController changeTypesetterAttributesWithBlock:]
-[WRReaderViewController checkLocationInCurrentPage:stringLocation:chapterIdx:]
-[WRReaderViewController detectExcessPageViews]
-[WRReaderViewController didEnterBackGround]
-[WRReaderViewController didEnterForeGround]
-[WRReaderViewController didFlipPage]
-[WRReaderViewController didReceiveMemoryWarning]
-[WRReaderViewController firstlyWillRenderPageView:progressData:source:]
-[WRReaderViewController gotoChapterIdx:filePosition:pageOfFlyleaf:shouldAutoJumpToCover:]
-[WRReaderViewController gotoChapterIdx:position:positionOfFile:]
-[WRReaderViewController handleBackProgressButtonClick:]
-[WRReaderViewController handleLastPageGesture:]
-[WRReaderViewController handleLastPageTapGesture:]
-[WRReaderViewController handleSizeChanged]
-[WRReaderViewController initChapterPageCount]
-[WRReaderViewController initChapterPageCount]_block_invoke
-[WRReaderViewController initChapterPageCount]_block_invoke_3
-[WRReaderViewController initWithBook:progress:forceUseInitialProgress:doodleMode:autoRead:]
-[WRReaderViewController initWithBookId:]
-[WRReaderViewController invokeVaildReadingProgress]
-[WRReaderViewController isLastPageWithChapterIdx:page:]
-[WRReaderViewController isReviewChapterFirstPageWithChapterData:page:]
-[WRReaderViewController isReviewChapterInMiddlePageWithChapterData:page:]
-[WRReaderViewController isReviewChapterLastPageWithChapterData:page:]
-[WRReaderViewController jumpReadingToNeigbourChapterForward:]
-[WRReaderViewController jumpReadingToNextChapter]
-[WRReaderViewController jumpReadingToPreChapter]
-[WRReaderViewController jumpToChapterUid:chapterOffset:shouldAutoJumpToCover:jumpToCloseChapterIdxIfNotExists:jumpFromStartProgress:completion:]
-[WRReaderViewController jumpToChapterUid:chapterOffset:shouldAutoJumpToCover:jumpToCloseChapterIdxIfNotExists:jumpFromStartProgress:completion:]_block_invoke
-[WRReaderViewController jumpToChapterUid:page:]
-[WRReaderViewController loadCurrentPagePencilNotes]
-[WRReaderViewController pageView:didClickPencilNote:]
-[WRReaderViewController recomposeAndClearCache:]
-[WRReaderViewController recomposeCurrentPageViewWithSource:]
-[WRReaderViewController recomposeLoadedPageView:]
-[WRReaderViewController refreshWithBook:]
-[WRReaderViewController reloadCurrentPageView]
-[WRReaderViewController reloadPageViewsWithProgressData:source:]
-[WRReaderViewController removeAllPageViewLinkBookPaperHighlight]
-[WRReaderViewController removeExcessPageViews:pageViewsToKeep:]
-[WRReaderViewController renderErrorPageView:error:chapter:andChapterFormat:progressData:source:]
-[WRReaderViewController renderLoadingContentPageView:progressData:source:]
-[WRReaderViewController renderLoadingContentPageView:progressData:source:]_block_invoke
-[WRReaderViewController renderPageView:nextToChapterIdx:page:]
-[WRReaderViewController renderPageView:previousToChapterIdx:page:]
-[WRReaderViewController renderPageView:progressData:source:]
-[WRReaderViewController savePreviousPagePencilNote]
-[WRReaderViewController scrollToLastReadingProgressIfNeeded]
-[WRReaderViewController setBaseReaderViewModel:]_block_invoke_2
-[WRReaderViewController showMileStoreRemind]
-[WRReaderViewController startShowingReadingPageView]
-[WRReaderViewController syncAuthorFlyleafDatas]
-[WRReaderViewController syncBookInfoAndChapterInfoWithCallback:]_block_invoke
-[WRReaderViewController syncFlyleapDataWithBook:]
-[WRReaderViewController tryToJumpToAuthorFlyLeafPage]
-[WRReaderViewController tryToJumpToAuthorFlyLeafPage]_block_invoke
-[WRReaderViewController turnToPlayingFileLocation:stringLocation:chapterIdx:]
-[WRReaderViewController uploadReadingProgressWithCallback:]
-[WRReaderViewController viewDidLoad]
-[WRReaderViewController viewWillDisappear:]
-[WRReaderViewController willPopInNavigationControllerWithAnimated:]
-[WRReaderViewModel loadChapterDataWithChapterIdx:reviewVid:tryRead:callback:]
-[WRReaderViewModel updateInfoAfterAutoPaidChapter:]
@@ -0,0 +1,320 @@
DTCoreText
DTCoreTextFontCollection
DTCoreTextFontDescriptor
DTCoreTextGlyphRun
DTCoreTextLayoutFrame
DTCoreTextLayoutFrameAccessibilityElementGenerator
DTCoreTextLayoutLine
DTCoreTextLayouter
DTCoreTextParagraphStyle
DTHTMLAttributedStringBuilder
DTHTMLElement
DTHTMLParser
DTHTMLParserDelegate
DTHTMLParserNode
DTHTMLParserTextNode
WRBookMarketManager
WRBookMarketRankListDataSource
WRBookMarketSearchData
WRBookMarketSearchDataSource
WRBookMarketUIHelper
WRBookMarketViewController
WRBookNetwork
WRBookNetworkReporter
WRBookmark
WRChapter
WRChapterAnchor
WRChapterData
WRChapterDownloadManger
WRChapterDownloadParam
WRChapterListener
WRChapterPageCount
WRChapterReadData
WRChapterRecommendAlbumsView
WRChapterRecommendAlbumsViewCell
WRChapterReviewsDownLoadModel
WRCoreTextFloatStatement
WRCoreTextImageAttributedStringFormatter
WRCoreTextLayoutFrame
WRCoreTextLayouter
WREncryptedFileManager
WREpubPage
WREpubParser
WREpubPositionConverter
WREpubTypesetter
WRMarkContent
WRMarkContentChapter
WRMarkContentInfoViewModel
WRMarkContentStore
WRMarkContentViewModel
WRMarketIcon
WRMarketNetwork
WRMarketPopToBookShelfTransition
WRMarketPopToDiscoverTransition
WRMarketPushToSearchTransition
WRMarketSearchModel
WRMarketSearchScopeTab
WRPageAVAttachment
WRPageAudioAttachment
WRPageAudioView
WRPageBookAttachment
WRPageBookView
WRPageChapterRewardAttachment
WRPageChapterRewardView
WRPageChapterToolAttachment
WRPageChapterToolAttachmentDelegate
WRPageChapterToolAttachmentHelper
WRPageChapterToolDelegate
WRPageChapterToolModel
WRPageChapterToolShareTipsView
WRPageChapterToolStore
WRPageCodeView
WRPageCodeViewController
WRPageComicView
WRPageCountReportManager
WRPageCoverAttachment
WRPageCoverAttachmentDelegate
WRPageCoverOrPageFlyleafAttachmentDelegate
WRPageData
WRPageElement
WRPageFlowCollectionViewLayout
WRPageFlyleafAttachment
WRPageHighlight
WRPageHighlightView
WRPageHyperlinksAttachment
WRPageHyperlinksAttachmentDelegate
WRPageIframeAttachment
WRPageIframeView
WRPageImageAttachment
WRPageImageAttachmentDelegate
WRPageImageAttachmentPreviewDelegate
WRPageImageAttachmentPreviewViewController
WRPageImageView
WRPageMark
WRPageMarkDelegate
WRPageMarkView
WRPagePreAttachment
WRPageRemindView
WRPageSearchHighlight
WRPageSliderAttachment
WRPageTableAttachment
WRPageTextView
WRPageTextViewDelegate
WRPageTurnModeControl
WRPageUnderline
WRPageVideoView
WRPageView
WRPageViewController
WRPageViewControllerDelegate
WRPageViewDelegate
WRPageViewRichElementStub
WRPreloadBookManager
WRPreloadBookSettingHeaderView
WRPreloadBookSettingViewController
WRPreloadStatisticsManager
WRReaderAIKnowledgeViewController
WRReaderAdDialog
WRReaderAnchorManager
WRReaderAuthorReviewDetailViewController
WRReaderAuthorReviewListViewCell
WRReaderAuthorReviewListViewController
WRReaderAutoPlayer
WRReaderAutoPlayerDelegate
WRReaderBackground
WRReaderBackgroundAdapter
WRReaderBackgroundChangeCell
WRReaderBackgroundChangeView
WRReaderBasePopupCellAdapter
WRReaderBitmapColorHelper
WRReaderBodyImageHelper
WRReaderBookBorrowUtils
WRReaderBookIntroControl
WRReaderBookReviewStarView
WRReaderBookmarkCell
WRReaderBookmarkViewController
WRReaderBorrowButton
WRReaderBrightnessBackgroundButton
WRReaderBrightnessChangeView
WRReaderBusinessHelper
WRReaderCatalogBaseCell
WRReaderCatalogBaseCellModel
WRReaderCatalogBaseCellView
WRReaderCatalogBatchBuyChapterView
WRReaderCatalogBookMarkCell
WRReaderCatalogBookMarkCellView
WRReaderCatalogCell
WRReaderCatalogCellUtils
WRReaderCatalogContainerViewController
WRReaderCatalogCurrentReadingInfo
WRReaderCatalogDataSource
WRReaderCatalogDataSourceDelegate
WRReaderCatalogDataSourceProtocol
WRReaderCatalogFooterCell
WRReaderCatalogHeaderView
WRReaderCatalogItemModel
WRReaderCatalogReadingCell
WRReaderCatalogReadingCellModel
WRReaderCatalogReadingCellView
WRReaderCatalogSearchHistoryView
WRReaderCatalogSearchManager
WRReaderCatalogSectionItem
WRReaderCatalogTableHeaderView
WRReaderCatalogViewController
WRReaderChangeFirstIndentTableViewCell
WRReaderChangeFirstIndentTableViewCellItemView
WRReaderChangeFirstIndentView
WRReaderChangeFirstIndentViewController
WRReaderChangeOrientationView
WRReaderChangeOrientationViewController
WRReaderChangePageTurningStyleView
WRReaderChangePageTurningStyleViewController
WRReaderCht2sManager
WRReaderColorThemeManager
WRReaderColorableProtocol
WRReaderComicsViewModel
WRReaderContentNavigationBottomControl
WRReaderContentNavigationControl
WRReaderContentNavigationManager
WRReaderContentNavigationTopControl
WRReaderContext
WRReaderControlBarView
WRReaderCurProgressTipsView
WRReaderDictionaryAIKnowledgeCollectionViewCell
WRReaderDictionaryCollectionBaseViewCell
WRReaderDictionaryCollectionResultViewCell
WRReaderDictionaryCollectionView
WRReaderDictionaryEncyclopediaCollectionViewCell
WRReaderDictionarySearchCollectionViewCell
WRReaderDictionaryTranslationCollectionViewCell
WRReaderDictionaryView
WRReaderDictionaryViewController
WRReaderEndOfTrialButton
WRReaderEndOfTrialView
WRReaderExchangePushInfo
WRReaderExperienceViewController
WRReaderFloatReviewsViewController
WRReaderFont
WRReaderFontChangeCategoryViewController
WRReaderFontChangeCategoryViewModel
WRReaderFontChangeCell
WRReaderFontChangeModalView
WRReaderFontChangeShareViewModel
WRReaderFontChangeTabsView
WRReaderFontChangeViewController
WRReaderFontConfig
WRReaderFontSizeChangeView
WRReaderFontTableViewCell
WRReaderFooterPageView
WRReaderFooternotePopupView
WRReaderHotUnderLinesTotalCountViewController
WRReaderHotUnderlinesCell
WRReaderHotUnderlinesViewController
WRReaderIncentiveButton
WRReaderInfoDebuggerViewController
WRReaderInfoDebuggerViewModel
WRReaderLastPageActionButton
WRReaderLastPageActionButtonContainerView
WRReaderLastPageBadgeView
WRReaderLastPageDelegate
WRReaderLastPageHelper
WRReaderLastPageMilestoneCardView
WRReaderLastPageReviewItem
WRReaderLastPageReviewsView
WRReaderLastPageViewController
WRReaderMaskProtocol
WRReaderMaskViewController
WRReaderMenuView
WRReaderMilestoneLabelView
WRReaderMistakeDialogContentView
WRReaderMistakeViewController
WRReaderModalView
WRReaderMoreOperationController
WRReaderNoteViewModel
WRReaderNotesSectionHeaderView
WRReaderOSSLogHelper
WRReaderOutlineViewController
WRReaderPaidVIPButton
WRReaderPanelSelectionView
WRReaderPanelSelectionViewController
WRReaderPencilNote
WRReaderPencilNoteBackgroundView
WRReaderPencilNoteBaseView
WRReaderPencilNoteButtonGroupView
WRReaderPencilNoteEraserTips
WRReaderPencilNoteEraserTipsManager
WRReaderPencilNoteHelper
WRReaderPencilNoteManager
WRReaderPencilNoteMaskView
WRReaderPencilNoteRangeReviewItem
WRReaderPencilNoteRangeReviewsPanel
WRReaderPencilNoteReviewManager
WRReaderPencilNoteTopToolView
WRReaderPencilNoteViewController
WRReaderPencilPageNoteListView
WRReaderPencilPageNoteView
WRReaderPencilRangeNoteView
WRReaderPlayerEntranceView
WRReaderPopupInfoTableViewCell
WRReaderPopupLoadMoreTableViewCell
WRReaderPopupReviewTableViewCell
WRReaderPresellPageView
WRReaderProgress
WRReaderProgressButton
WRReaderProgressChangeRuleView
WRReaderProgressChangeView
WRReaderProgressData
WRReaderProgressShareView
WRReaderProgressSlider
WRReaderProgressSliderDelegate
WRReaderProgressSmallChangeView
WRReaderReadOverToBuyView
WRReaderRefreshFooterView
WRReaderRefreshHeaderView
WRReaderReivewCountLabel
WRReaderReviewToolsView
WRReaderReviewsRNViewController
WRReaderSearchTableHeaderView
WRReaderSegmentViewController
WRReaderSettingPanelManager
WRReaderSettingPanelView
WRReaderSettingProtocol
WRReaderSinglePurchaseEndTrailBookContentView
WRReaderSizeManager
WRReaderStatisticPanelViewController
WRReaderStatisticRNViewController
WRReaderTextSelector
WRReaderTimeHelper
WRReaderTimer
WRReaderTraditionalChineseView
WRReaderTranslationManager
WRReaderTranslationUpgradeViewController
WRReaderUIFactory
WRReaderUnderlineStyleButtonManager
WRReaderVIPButton
WRReaderVIPButtonProtocol
WRReaderViewController
WRReaderViewControllerDelegate
WRReaderViewControllerHelper
WRReaderViewModel
WRReaderVisiblePageInfo
WRReaderVoteView
WRReaderWelfareCoinPanel
WRReaderWriteReviewSimpleView
WRUnderLineView
WRUnderline
WRUnderlineColorButtonsScrollView
WRUnderlineLayoutManager
WRUnderlineStore
WrBookmarkLoadErrorReport
WrBookmarkLoadErrorReportRoot
WrPageFlippingAnimationTypeRoot
WrReaderActionReport
WrReaderActionReportRoot
WrReaderActionRoot
WrReaderItemtypeRoot
WrReaderModuleRoot
WrReaderPayingtypeRoot
WrReaderSearchAuthoriedFailedTypeRoot
WrReaderSearchFailedtypeRoot
WrReaderSearchWords
WrReaderSearchWordsRoot
@@ -0,0 +1,798 @@
ivarLayout 0x1031bcf9c
layout map 0x11 0x12
name 0x1031bcf89 DTCoreTextLayouter
baseMethods 0x102acf508
entsize 12 (relative)
count 12
name 0x104ef88 (0x103b1e498)
types 0x95e950 (0x10342de64) @24@0:8@16
imp 0xfd5764d8 (0x1000459f0)
name 0x103a3ec (0x103b09908)
types 0x95e89b (0x10342ddbb) v16@0:8
imp 0xfd576560 (0x100045a84)
name 0x1058528 (0x103b27a50)
types 0x95ff95 (0x10342f4c1) @64@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16{_NSRange=QQ}48
imp 0xfd576598 (0x100045ac8)
name 0x102881c (0x103af7d50)
types 0x95e883 (0x10342ddbb) v16@0:8
imp 0xfd576734 (0x100045c70)
name 0x10436d0 (0x103b12c10)
types 0x95ffb6 (0x10342f4fa) ^{__CTFramesetter=}16@0:8
imp 0xfd576754 (0x100045c9c)
name 0x1075b84 (0x103b450d0)
types 0x95e860 (0x10342ddb0) v24@0:8@16
imp 0xfd5767c4 (0x100045d18)
name 0x102e2c8 (0x103afd820)
types 0x95e84c (0x10342dda8) @16@0:8
imp 0xfd57684c (0x100045dac)
name 0x108cf3c (0x103b5c4a0)
types 0x95e8b5 (0x10342de1d) v20@0:8B16
imp 0xfd576848 (0x100045db4)
name 0x1094710 (0x103b63c80)
types 0x95e9a8 (0x10342df1c) B16@0:8
imp 0xfd57689c (0x100045e14)
--
reserved 0x0
ivarLayout 0x0
name 0x1031bcf89 DTCoreTextLayouter
baseMethods 0x0
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383bc60 0x103ba2550
isa 0x103ba2528
superclass 0x0 _OBJC_CLASS_$_NSObject
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x10388b308
flags 0x90
instanceStart 8
instanceSize 8
reserved 0x0
ivarLayout 0x0
name 0x1031bcf9f TABAnimatedChainManagerImpl
baseMethods 0x102acf5a0
entsize 12 (relative)
count 3
name 0x1033af0 (0x103b03098)
types 0x95ff9d (0x10342f549) v48@0:8@16@24@?32@40
imp 0xfd576cac (0x10004625c)
name 0x1033aec (0x103b030a0)
types 0x95ffa6 (0x10342f55e) v56@0:8@16@24@?32#40@48
imp 0xfd576d1c (0x1000462d8)
name 0x103bf00 (0x103b0b4c0)
types 0x95e7f7 (0x10342ddbb) v16@0:8
imp 0xfd576d9c (0x100046364)
baseProtocols 0x10388b260
--
ivarLayout 0x1031bc939
layout map 0x04
name 0x1031bfb46 WREpubParser
baseMethods 0x102aec440
entsize 12 (relative)
count 11
name 0x1032c20 (0x103b1f068)
types 0x9419c3 (0x10342de0f) @32@0:8@16@24
imp 0xfd7ec460 (0x1002d88b0)
name 0x10459b4 (0x103b31e08)
types 0x941ac4 (0x10342df1c) B16@0:8
imp 0xfd7ec68c (0x1002d8ae8)
name 0x1023418 (0x103b0f878)
types 0x9419c4 (0x10342de28) v32@0:8@16@24
imp 0xfd7ec6b4 (0x1002d8b1c)
name 0x102f9b4 (0x103b1be20)
types 0x94199f (0x10342de0f) @32@0:8@16@24
imp 0xfd7ed284 (0x1002d96f8)
name 0x102f5a0 (0x103b1ba18)
types 0x941993 (0x10342de0f) @32@0:8@16@24
imp 0xfd7ed468 (0x1002d98e8)
name 0x101a8d4 (0x103b06d58)
types 0x941987 (0x10342de0f) @32@0:8@16@24
imp 0xfd7ed4ec (0x1002d9978)
name 0x100ead8 (0x103afaf68)
types 0x9454c8 (0x10343195c) v40@0:8@16@24Q32
imp 0xfd7ed82c (0x1002d9cc4)
name 0x100dc6c (0x103afa108)
types 0x94b021 (0x1034374c1) v56@0:8@16@24Q32@40@48
imp 0xfd7edbd0 (0x1002da074)
name 0x10233c8 (0x103b0f870)
types 0x94197c (0x10342de28) v32@0:8@16@24
imp 0xfd7ee028 (0x1002da4d8)
--
reserved 0x0
ivarLayout 0x0
name 0x1031bfb46 WREpubParser
baseMethods 0x102aec428
entsize 12 (relative)
count 1
name 0x1046580 (0x103b329b0)
types 0x941a30 (0x10342de64) @24@0:8@16
imp 0xfd7ee188 (0x1002da5c0)
baseProtocols 0x1038b61b8
count 1
list[0] 0x103c17ce8
isa 0x0
name 0x1031bfb53 KFEpubControllerDelegate
protocols 0x1038b6130
count 1
list[0] 0x103c15580
isa 0x0
name 0x1031bc9c5 NSObject
protocols 0x0
instanceMethods 0x103884670
entsize 24
count 19
name 0x103348607 isEqual:
types 0x10342de6f B24@0:8@16
imp 0x0
name 0x1032e9bde class
types 0x10342df44 #16@0:8
imp 0x0
name 0x1033a90f1 self
types 0x10342dda8 @16@0:8
imp 0x0
name 0x1033780e4 performSelector:
--
ivarLayout 0x1031bfd8e
layout map 0x16 0x1c 0x12 0x15 0x11
name 0x1031bfd80 WRChapterData
baseMethods 0x102aeddf0
entsize 12 (relative)
count 118
name 0x102f158 (0x103b1cf50)
types 0x93ffac (0x10342dda8) @16@0:8
imp 0xfd809f38 (0x1002f7d38)
name 0x101552c (0x103b03330)
types 0x940101 (0x10342df09) q16@0:8
imp 0xfd809f80 (0x1002f7d8c)
name 0x1019920 (0x103b07730)
types 0x94150c (0x10342f320) {_NSRange=QQ}16@0:8
imp 0xfd80a044 (0x1002f7e5c)
name 0x103fd04 (0x103b2db20)
types 0x941500 (0x10342f320) {_NSRange=QQ}16@0:8
imp 0xfd80a07c (0x1002f7ea0)
name 0x1033920 (0x103b21748)
types 0x9400dd (0x10342df09) q16@0:8
imp 0xfd80a0b0 (0x1002f7ee0)
name 0x10353cc (0x103b23200)
types 0x94427a (0x1034320b2) B24@0:8q16
imp 0xfd80a0bc (0x1002f7ef8)
name 0x10353c8 (0x103b23208)
types 0x94426e (0x1034320b2) B24@0:8q16
imp 0xfd80a158 (0x1002f7fa0)
name 0x1035b9c (0x103b239e8)
types 0x944262 (0x1034320b2) B24@0:8q16
imp 0xfd80a18c (0x1002f7fe0)
name 0x1035c60 (0x103b23ab8)
types 0x944256 (0x1034320b2) B24@0:8q16
imp 0xfd80a1c0 (0x1002f8020)
--
reserved 0x0
ivarLayout 0x0
name 0x1031bfd80 WRChapterData
baseMethods 0x102aedd90
entsize 12 (relative)
count 7
name 0x100d288 (0x103afb020)
types 0x949a69 (0x103437805) v64@0:8@16{_NSRange=QQ}24q40q48@56
imp 0xfd80a52c (0x1002f82cc)
name 0x101d21c (0x103b0afc0)
types 0x940008 (0x10342ddb0) v24@0:8@16
imp 0xfd80a694 (0x1002f8440)
name 0x10161e8 (0x103b03f98)
types 0x949a74 (0x103437828) B40@0:8{_NSRange=QQ}16Q32
imp 0xfd80c140 (0x1002f9ef8)
name 0x1078b54 (0x103b66910)
types 0x9400a4 (0x10342de64) @24@0:8@16
imp 0xfd80c154 (0x1002f9f18)
name 0x1036ed8 (0x103b24ca0)
types 0x94008a (0x10342de56) B32@0:8@16@24
imp 0xfd80cf10 (0x1002face0)
name 0x104396c (0x103b31740)
types 0x949a6a (0x103437842) {_NSRange=QQ}32@0:8q16@24
imp 0xfd80e084 (0x1002fbe60)
name 0x1024f08 (0x103b12ce8)
types 0x9422b4 (0x103430098) q32@0:8@16@24
imp 0xfd80e738 (0x1002fc520)
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383c6c8 0x103ba8d38
isa 0x103ba8d60
--
ivarLayout 0x1031c36ba
layout map 0x01 0xa2
name 0x1031c36a4 WRCoreTextLayoutFrame
baseMethods 0x102b1a760
entsize 12 (relative)
count 47
name 0x1004bd8 (0x103b1f340)
types 0x922b0e (0x10343d27a) @80@0:8^{__CTFramesetter=}16@24{_NSRange=QQ}32{CGRect={CGPoint=dd}{CGSize=dd}}48
imp 0xfdba1dec (0x1006bc55c)
name 0xff9e1c (0x103b14590)
types 0x9137ac (0x10342df24) d16@0:8
imp 0xfdba1ef4 (0x1006bc670)
name 0x10388c8 (0x103b53048)
types 0x9143b0 (0x10342eb34) v48@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16
imp 0xfdba1ef0 (0x1006bc678)
name 0x101dd44 (0x103b384d0)
types 0x922b3b (0x10343d2cb) B48@0:8{_NSRange=QQ}16{_NSRange=QQ}32
imp 0xfdba2028 (0x1006bc7bc)
name 0xfffc48 (0x103b1a3e0)
types 0x9136d3 (0x10342de6f) B24@0:8@16
imp 0xfdba2034 (0x1006bc7d4)
name 0x1007a5c (0x103b22200)
types 0x917ade (0x103432286) B32@0:8{_NSRange=QQ}16
imp 0xfdba20d0 (0x1006bc87c)
name 0xfed5f0 (0x103b07da0)
types 0x922b3d (0x10343d2f1) @64@0:8^{__CTTypesetter=}16d24{_NSRange=QQ}32d48@56
imp 0xfdba218c (0x1006bc944)
name 0x100debc (0x103b28678)
types 0x9135e8 (0x10342dda8) @16@0:8
imp 0xfdba2f20 (0x1006bd6e4)
name 0xfec0d0 (0x103b06898)
types 0x91373d (0x10342df09) q16@0:8
imp 0xfdba4788 (0x1006bef58)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c36a4 WRCoreTextLayoutFrame
baseMethods 0x102b1a650
entsize 12 (relative)
count 2
name 0xfecc98 (0x103b072f0)
types 0x922bcf (0x10343d22b) @64@0:8@16@24{_NSRange=QQ}32{_NSRange=QQ}48
imp 0xfdba9834 (0x1006c3e94)
name 0x10492bc (0x103b63920)
types 0x922bef (0x10343d257) B60@0:8@16@24{_NSRange=QQ}32q48B56
imp 0xfdba9a84 (0x1006c40f0)
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383d6e0 0x103bb2e28
isa 0x103bb2e50
superclass 0x0 _OBJC_CLASS_$_QMUINavigationButton
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x103901990
flags 0x194 RO_HAS_CXX_STRUCTORS
instanceStart 8
instanceSize 56
reserved 0x0
ivarLayout 0x1031bcc1a
layout map 0x12
name 0x1031c36bd WRReviewNavigationButton
baseMethods 0x102b1a9a0
entsize 12 (relative)
count 15
name 0x10025a8 (0x103b1cf50)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c405b DTCoreTextLayoutFrameAccessibilityElementGenerator
baseMethods 0x102b22c00
entsize 12 (relative)
count 7
name 0xfd6a98 (0x103af96a0)
types 0x90c852 (0x10342f45e) @40@0:8@16@24@?32
imp 0xfdc59bb0 (0x10077c7c0)
name 0xfd6a94 (0x103af96a8)
types 0x91ba43 (0x10343e65b) @48@0:8Q16@24@32@?40
imp 0xfdc59ccc (0x10077c8e8)
name 0xfeca30 (0x103b0f650)
types 0x91194f (0x103434573) v40@0:8@16Q24@?32
imp 0xfdc59ee0 (0x10077cb08)
name 0xfd6a64 (0x103af9690)
types 0x91ba40 (0x10343e670) @72@0:8@16{_NSRange=QQ}24@40@48@56@?64
imp 0xfdc5a1b0 (0x10077cde4)
name 0xfd6a50 (0x103af9688)
types 0x91ba5b (0x10343e697) @64@0:8@16{_NSRange=QQ}24@40@48@56
imp 0xfdc5a2d4 (0x10077cf14)
name 0x104f19c (0x103b71de0)
types 0x90c807 (0x10342f44f) @32@0:8@16@?24
imp 0xfdc5a468 (0x10077d0b4)
name 0xfeff18 (0x103b12b68)
types 0x914fd8 (0x103437c2c) {CGRect={CGPoint=dd}{CGSize=dd}}24@0:8@16
imp 0xfdc5a490 (0x10077d0e8)
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
Meta Class
isa 0x0 _OBJC_METACLASS_$_NSObject
--
reserved 0x0
ivarLayout 0x0
name 0x1031c405b DTCoreTextLayoutFrameAccessibilityElementGenerator
baseMethods 0x0
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383d9a0 0x103bb49a8
isa 0x103bb49d0
superclass 0x0 _OBJC_CLASS_$_UICollectionView
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x10390e140
flags 0x194 RO_HAS_CXX_STRUCTORS
instanceStart 8
instanceSize 48
reserved 0x0
ivarLayout 0x1031bcd23
layout map 0x11
name 0x1031c408e WRDiscoverCollectionView
baseMethods 0x102b22c60
entsize 12 (relative)
count 20
name 0x1001838 (0x103b244a0)
types 0x90b2b0 (0x10342df1c) B16@0:8
imp 0xfdc5a9b8 (0x10077d628)
name 0x101ef44 (0x103b41bb8)
types 0x91ba42 (0x10343e6ba) v36@0:8Q16B24@?28
imp 0xfdc5aa5c (0x10077d6d8)
name 0x1049f78 (0x103b6cbf8)
types 0x90b1eb (0x10342de6f) B24@0:8@16
imp 0xfdc5abc0 (0x10077d848)
--
ivarLayout 0x1031c51df
layout map 0x12 0x11 0x21
name 0x1031c51cc WRChapterPageCount
baseMethods 0x102b2ed70
entsize 12 (relative)
count 29
name 0xfefcf8 (0x103b1ea70)
types 0x910f9d (0x10343fd19) @40@0:8@16Q24q32
imp 0xfdd88e88 (0x1008b7c08)
name 0x100981c (0x103b385a0)
types 0x908ad4 (0x10343785c) {_NSRange=QQ}24@0:8q16
imp 0xfdd892a4 (0x1008b8030)
name 0xff70c8 (0x103b25e58)
types 0x8ff014 (0x10342dda8) @16@0:8
imp 0xfdd89500 (0x1008b8298)
name 0x1023294 (0x103b52030)
types 0x8ff010 (0x10342ddb0) v24@0:8@16
imp 0xfdd89504 (0x1008b82a8)
name 0xfd4710 (0x103b034b8)
types 0x8feffc (0x10342dda8) @16@0:8
imp 0xfdd89504 (0x1008b82b4)
name 0x10190fc (0x103b47eb0)
types 0x8feff8 (0x10342ddb0) v24@0:8@16
imp 0xfdd89508 (0x1008b82c4)
name 0xfd4650 (0x103b03410)
types 0x8ff145 (0x10342df09) q16@0:8
imp 0xfdd89508 (0x1008b82d0)
name 0x1019084 (0x103b47e50)
types 0x8ff141 (0x10342df11) v24@0:8q16
imp 0xfdd8950c (0x1008b82e0)
name 0xfd0fb0 (0x103affd88)
types 0x8fefcc (0x10342dda8) @16@0:8
imp 0xfdd89510 (0x1008b82f0)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c51cc WRChapterPageCount
baseMethods 0x102b2ece0
entsize 12 (relative)
count 11
name 0xff2708 (0x103b213f0)
types 0x8ff0bc (0x10342dda8) @16@0:8
imp 0xfddc1db4 (0x1008f0aa4)
name 0xfdab1c (0x103b09810)
types 0x8ff0b0 (0x10342dda8) @16@0:8
imp 0xfddc1dc4 (0x1008f0ac0)
name 0x103b978 (0x103b6a678)
types 0x8ff0a4 (0x10342dda8) @16@0:8
imp 0xfddc1dc4 (0x1008f0acc)
name 0x10063cc (0x103b350d8)
types 0x8ff098 (0x10342dda8) @16@0:8
imp 0xfddc1dc4 (0x1008f0ad8)
name 0x10046e8 (0x103b33400)
types 0x8ff08c (0x10342dda8) @16@0:8
imp 0xfddc1dc4 (0x1008f0ae4)
name 0xfdc50c (0x103b0b230)
types 0x8ff080 (0x10342dda8) @16@0:8
imp 0xfddc1df0 (0x1008f0b1c)
name 0x10027f0 (0x103b31520)
types 0x910fe5 (0x10343fd19) @40@0:8@16Q24q32
imp 0xfdd89030 (0x1008b7d68)
name 0xfd982c (0x103b08568)
types 0x9006b8 (0x10342f3f8) Q24@0:8@16
imp 0xfdd89128 (0x1008b7e6c)
name 0xfee0c0 (0x103b1ce08)
types 0x8ff05c (0x10342dda8) @16@0:8
imp 0xfdd892d4 (0x1008b8024)
--
ivarLayout 0x1031c6257
layout map 0x04 0x15 0x28
name 0x1031c624c WRPageView
baseMethods 0x102b3b018
entsize 12 (relative)
count 104
name 0xfe4310 (0x103b1f330)
types 0x9064ba (0x1034414de) @64@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16Q48q56
imp 0xfde85418 (0x1009c0440)
name 0x10294e4 (0x103b64510)
types 0x8f2eec (0x10342df1c) B16@0:8
imp 0xfde854e0 (0x1009c0514)
name 0x102a918 (0x103b65950)
types 0x8f2d7f (0x10342ddbb) v16@0:8
imp 0xfde85584 (0x1009c05c4)
name 0xfe0064 (0x103b1b0a8)
types 0x8f2d73 (0x10342ddbb) v16@0:8
imp 0xfde85704 (0x1009c0750)
name 0xfe0068 (0x103b1b0b8)
types 0x8f2d67 (0x10342ddbb) v16@0:8
imp 0xfde85770 (0x1009c07c8)
name 0xfe2eec (0x103b1df48)
types 0x8f2d5b (0x10342ddbb) v16@0:8
imp 0xfde85798 (0x1009c07fc)
name 0xfe0048 (0x103b1b0b0)
types 0x8f2d4f (0x10342ddbb) v16@0:8
imp 0xfde85af0 (0x1009c0b60)
name 0xfe2ac4 (0x103b1db38)
types 0x8f2d43 (0x10342ddbb) v16@0:8
imp 0xfde85ca8 (0x1009c0d24)
name 0xfe66d8 (0x103b21758)
types 0x8f2d24 (0x10342dda8) @16@0:8
imp 0xfde85df8 (0x1009c0e80)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c624c WRPageView
baseMethods 0x102b3b000
entsize 12 (relative)
count 1
name 0xff1cb8 (0x103b2ccc0)
types 0x8f6062 (0x10343106e) d32@0:8@16@24
imp 0xfde86674 (0x1009c1684)
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383e338 0x103bba998
isa 0x103bba9c0
superclass 0x103bc8688
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x103932f00
flags 0x194 RO_HAS_CXX_STRUCTORS
instanceStart 16
instanceSize 32
reserved 0x0
ivarLayout 0x1031bc967
layout map 0x02
name 0x1031c625b WRConfirmH5PayViewController
baseMethods 0x102b3b500
entsize 12 (relative)
count 15
name 0x10011c8 (0x103b3c6d0)
types 0x8f28af (0x10342ddbb) v16@0:8
imp 0xfde90aa0 (0x1009cbfb0)
name 0xfedbb4 (0x103b290c8)
--
ivarLayout 0x1031c646d
layout map 0x44 0x81 0x31
name 0x1031c6457 DTCoreTextLayoutFrame
baseMethods 0x102b3ca60
entsize 12 (relative)
count 54
name 0xfc84e8 (0x103b04f50)
types 0x8f4143 (0x103430baf) q32@0:8{CGPoint=dd}16
imp 0xfdd403fc (0x10087ce6c)
name 0xfcc3a4 (0x103b08e18)
types 0x900b89 (0x10343d601) {CGRect={CGPoint=dd}{CGSize=dd}}24@0:8q16
imp 0xfdd406b8 (0x10087d134)
name 0xfe2878 (0x103b1f2f8)
types 0x904d71 (0x1034417f5) @72@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16@48{_NSRange=QQ}56
imp 0xfdeb5da4 (0x1009f282c)
name 0xfe2864 (0x103b1f2f0)
types 0x8fdd25 (0x10343a7b5) @56@0:8{CGRect={CGPoint=dd}{CGSize=dd}}16@48
imp 0xfdeb5f20 (0x1009f29b4)
name 0xfcce70 (0x103b09908)
types 0x8f131f (0x10342ddbb) v16@0:8
imp 0xfdeb5f20 (0x1009f29c0)
name 0xfce904 (0x103b0b3a8)
types 0x8f1300 (0x10342dda8) @16@0:8
imp 0xfdeb5f6c (0x1009f2a18)
name 0xfbacd8 (0x103af7788)
types 0x900b34 (0x10343d5e8) {CGPoint=dd}32@0:8@16@24
imp 0xfdeb5fa4 (0x1009f2a5c)
name 0xfbacdc (0x103af7798)
types 0x8f2c9e (0x10342f75e) d24@0:8@16
imp 0xfdeb6274 (0x1009f2d38)
name 0xfbacc8 (0x103af7790)
types 0x900b1c (0x10343d5e8) {CGPoint=dd}32@0:8@16@24
imp 0xfdeb6364 (0x1009f2e34)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c6457 DTCoreTextLayoutFrame
baseMethods 0x102b3ca40
entsize 12 (relative)
count 2
name 0x101fb08 (0x103b5c550)
types 0x8f13d1 (0x10342de1d) v20@0:8B16
imp 0xfdeba310 (0x1009f6d60)
name 0x10273a4 (0x103b63df8)
types 0x8f14c4 (0x10342df1c) B16@0:8
imp 0xfdeba310 (0x1009f6d6c)
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383e3c8 0x103bbaf38
isa 0x103bbaf60
superclass 0x0 _OBJC_CLASS_$_UIView
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x103935920
flags 0x194 RO_HAS_CXX_STRUCTORS
instanceStart 8
instanceSize 280
reserved 0x0
ivarLayout 0x1031c64b4
layout map 0x02 0xb9
name 0x1031c647c WHLyricPageTextView
baseMethods 0x102b3ccf0
entsize 12 (relative)
count 60
name 0xfe0258 (0x103b1cf50)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c67a2 WREpubTypesetter
baseMethods 0x0
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
Meta Class
isa 0x0 _OBJC_METACLASS_$_NSObject
superclass 0x0 _OBJC_METACLASS_$_NSObject
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x10393a6e8
flags 0x91 RO_META
instanceStart 40
instanceSize 40
reserved 0x0
ivarLayout 0x0
name 0x1031c67a2 WREpubTypesetter
baseMethods 0x102b40010
entsize 12 (relative)
count 9
name 0xfbd748 (0x103afd760)
types 0x902393 (0x1034423af) @92@0:8@16Q24B32B36B40@44@52q60^q68^B76@84
imp 0xfdf0898c (0x100a489ac)
name 0x102d67c (0x103b6d6a0)
types 0x9023b2 (0x1034423da) v52@0:8@16@24@32B40B44B48
imp 0xfdf09b70 (0x100a49b9c)
name 0xfc41f8 (0x103b04228)
types 0x9023c0 (0x1034423f4) v40@0:8@16^B24^B32
imp 0xfdf0a380 (0x100a4a3b8)
name 0xfe4fbc (0x103b24ff8)
types 0x8ede2f (0x10342de6f) B24@0:8@16
imp 0xfdf0a5b8 (0x100a4a5fc)
name 0xfc9180 (0x103b091c8)
types 0x8ede18 (0x10342de64) @24@0:8@16
imp 0xfdf0a644 (0x100a4a694)
name 0xfc917c (0x103b091d0)
types 0x8ede0c (0x10342de64) @24@0:8@16
imp 0xfdf0a754 (0x100a4a7b0)
name 0xfc9178 (0x103b091d8)
types 0x8ede97 (0x10342defb) @28@0:8@16B24
imp 0xfdf0a864 (0x100a4a8cc)
name 0xfe3dbc (0x103b23e28)
types 0x8eddff (0x10342de6f) B24@0:8@16
imp 0xfdf0aa10 (0x100a4aa84)
name 0xfc9170 (0x103b091e8)
types 0x8edd93 (0x10342de0f) @32@0:8@16@24
imp 0xfdf0aa70 (0x100a4aaf0)
--
ivarLayout 0x1031bc9ce
layout map 0x13
name 0x1031c67c4 WRCoreTextLayouter
baseMethods 0x102b40160
entsize 12 (relative)
count 15
name 0xfbd6f8 (0x103afd860)
types 0x8edc3c (0x10342dda8) @16@0:8
imp 0xfdf0b9a8 (0x100a4bb18)
name 0xfde32c (0x103b1e4a0)
types 0x8edc97 (0x10342de0f) @32@0:8@16@24
imp 0xfdf0ba50 (0x100a4bbcc)
name 0xfd2a90 (0x103b12c10)
types 0x8ef376 (0x10342f4fa) ^{__CTFramesetter=}16@0:8
imp 0xfdf0badc (0x100a4bc64)
name 0xfc7b5c (0x103b07ce8)
types 0x902277 (0x103442407) @72@0:8{_NSRange=QQ}16{CGRect={CGPoint=dd}{CGSize=dd}}32Q64
imp 0xfdf0bb4c (0x100a4bce0)
name 0x1004f38 (0x103b450d0)
types 0x8edc14 (0x10342ddb0) v24@0:8@16
imp 0xfdf0bc0c (0x100a4bdac)
name 0xff1274 (0x103b31418)
types 0x8f7c6e (0x103437e16) @40@0:8{_NSRange=QQ}16@32
imp 0xfdf0c034 (0x100a4c1e0)
name 0xff1270 (0x103b31420)
types 0x8f7c62 (0x103437e16) @40@0:8{_NSRange=QQ}16@32
imp 0xfdf0c1ac (0x100a4c364)
name 0xfd0444 (0x103b10600)
types 0x8edbe8 (0x10342dda8) @16@0:8
imp 0xfdf0c410 (0x100a4c5d4)
name 0xfff160 (0x103b3f328)
types 0x902277 (0x103442443) @84@0:8@16{CGRect={CGPoint=dd}{CGSize=dd}}24Q56Q64B72@76
imp 0xfdf0c420 (0x100a4c5f0)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c67c4 WRCoreTextLayouter
baseMethods 0x102b40118
entsize 12 (relative)
count 5
name 0x1028e50 (0x103b68f70)
types 0x8edd4b (0x10342de6f) B24@0:8@16
imp 0xfdf0b47c (0x100a4b5a4)
name 0xfe54fc (0x103b25628)
types 0x8edd3f (0x10342de6f) B24@0:8@16
imp 0xfdf0b7cc (0x100a4b900)
name 0x10300e8 (0x103b70220)
types 0x8f10a1 (0x1034311dd) v28@0:8B16@20
imp 0xfdf0b8b8 (0x100a4b9f8)
name 0xfc6ebc (0x103b07000)
types 0x8edd1c (0x10342de64) @24@0:8@16
imp 0xfdf0bd08 (0x100a4be54)
name 0xfc6eb8 (0x103b07008)
types 0x8edd10 (0x10342de64) @24@0:8@16
imp 0xfdf0c020 (0x100a4c178)
baseProtocols 0x0
ivars 0x0
weakIvarLayout 0x0
baseProperties 0x0
000000010383e4b8 0x103bbb898
isa 0x103bbb8c0
superclass 0x103bb0ee8
cache 0x0 __objc_empty_cache
vtable 0x0
data 0x10393ab58
flags 0x194 RO_HAS_CXX_STRUCTORS
instanceStart 16
--
ivarLayout 0x1031c75f6
layout map 0x02 0x2f 0x01 0x33 0x11
name 0x1031c75c3 DTHTMLAttributedStringBuilder
baseMethods 0x102b4aa58
entsize 12 (relative)
count 25
name 0xfd4958 (0x103b1f3b8)
types 0x8eb102 (0x103435b66) @40@0:8@16@24^@32
imp 0xfdffe5dc (0x100b49044)
name 0xfacf14 (0x103af7980)
types 0x8e34ac (0x10342df1c) B16@0:8
imp 0xfdffe7dc (0x100b49250)
name 0xfc8d80 (0x103b137f8)
types 0x8e332c (0x10342dda8) @16@0:8
imp 0xfdfff100 (0x100b49b80)
name 0xfae034 (0x103af8ab8)
types 0x8e3333 (0x10342ddbb) v16@0:8
imp 0xfdfff124 (0x100b49bb0)
name 0xfae020 (0x103af8ab0)
types 0x8e3327 (0x10342ddbb) v16@0:8
imp 0xfdffff00 (0x100b4a998)
name 0xfe7d3c (0x103b327d8)
types 0x8f88ae (0x10344334e) v56@0:8@16@24@32{CGPoint=dd}40
imp 0xfe000968 (0x100b4b40c)
name 0xfe7d20 (0x103b327c8)
types 0x8e337c (0x10342de28) v32@0:8@16@24
imp 0xfe000efc (0x100b4b9ac)
name 0xfe7be4 (0x103b32698)
types 0x8e32f8 (0x10342ddb0) v24@0:8@16
imp 0xfe0010c8 (0x100b4bb84)
name 0xfe7d30 (0x103b327f0)
types 0x8e3364 (0x10342de28) v32@0:8@16@24
imp 0xfe0015b8 (0x100b4c080)
--
reserved 0x0
ivarLayout 0x0
name 0x1031c75c3 DTHTMLAttributedStringBuilder
baseMethods 0x0
baseProtocols 0x10394b7f0
count 1
list[0] 0x103c1bed0
isa 0x0
name 0x1031c75e1 DTHTMLParserDelegate
protocols 0x10394b690
count 1
list[0] 0x103c15580
isa 0x0
name 0x1031bc9c5 NSObject
protocols 0x0
instanceMethods 0x103884670
entsize 24
count 19
name 0x103348607 isEqual:
types 0x10342de6f B24@0:8@16
imp 0x0
name 0x1032e9bde class
types 0x10342df44 #16@0:8
imp 0x0
name 0x1033a90f1 self
types 0x10342dda8 @16@0:8
imp 0x0
name 0x1033780e4 performSelector:
types 0x10342df4c @24@0:8:16
imp 0x0
name 0x103378163 performSelector:withObject:
types 0x10342df57 @32@0:8:16@24
imp 0x0
--
ivarLayout 0x1031c0ccf
layout map 0x61
name 0x1031cb9a9 WRPageViewController
baseMethods 0x102b81cf8
entsize 12 (relative)
count 73
name 0xfbd2d0 (0x103b3efd0)
types 0x8ac0b7 (0x10342ddbb) v16@0:8
imp 0xfe4dedec (0x101060af4)
name 0xff0bac (0x103b728b8)
types 0x8c707f (0x103448d8f) v72@0:8@16q24{CGPoint=dd}32q48B56B60@?64
imp 0xfe4dedf0 (0x101060b04)
name 0xfdf8d0 (0x103b615e8)
types 0x8b32e5 (0x103435001) v44@0:8@16q24B32@?36
imp 0xfe4df5ec (0x10106130c)
name 0xf9d194 (0x103b1eeb8)
types 0x8bdff1 (0x10343fd19) @40@0:8@16Q24q32
imp 0xfe4df984 (0x1010616b0)
name 0xfaf870 (0x103b315a0)
types 0x8ac1d5 (0x10342df09) q16@0:8
imp 0xfe4dfba8 (0x1010618e0)
name 0xff0084 (0x103b71dc0)
types 0x8ac07b (0x10342ddbb) v16@0:8
imp 0xfe4dfbac (0x1010618f0)
name 0xff0038 (0x103b71d80)
types 0x8ac0d1 (0x10342de1d) v20@0:8B16
imp 0xfe4dfcb4 (0x101061a04)
name 0xf87bb4 (0x103b09908)
types 0x8ac063 (0x10342ddbb) v16@0:8
imp 0xfe4dfcf0 (0x101061a4c)
name 0xf78590 (0x103afa2f0)
types 0x8ac057 (0x10342ddbb) v16@0:8
imp 0xfe4dfe08 (0x101061b70)
--
reserved 0x0
ivarLayout 0x0
name 0x1031cb9a9 WRPageViewController
baseMethods 0x102b81c98
entsize 12 (relative)
count 7
name 0xf9f110 (0x103b20db0)
types 0x8ac117 (0x10342ddbb) v16@0:8
imp 0xfe4dc830 (0x10105e4d8)
name 0xfb0c9c (0x103b32948)
types 0x8ac10b (0x10342ddbb) v16@0:8
imp 0xfe4dc99c (0x10105e650)
name 0xfb0c88 (0x103b32940)
types 0x8ac0ff (0x10342ddbb) v16@0:8
imp 0xfe4dd264 (0x10105ef24)
name 0xfb0c74 (0x103b32938)
types 0x8ac0f3 (0x10342ddbb) v16@0:8
imp 0xfe4ddb28 (0x10105f7f4)
name 0xf89a18 (0x103b0b6e8)
types 0x8c708d (0x103448d61) B48@0:8@16@24B32B36B40B44
imp 0xfe4de164 (0x10105fe3c)
name 0xfa437c (0x103b26058)
types 0x8ae3b8 (0x103430098) q32@0:8@16@24
imp 0xfe4dedac (0x101060a90)
name 0xfe3180 (0x103b64e68)
types 0x8ac0c4 (0x10342ddb0) v24@0:8@16
imp 0xfe4e15b8 (0x1010632a8)
baseProtocols 0x1039a0c00
count 4
list[0] 0x103c17bc8
isa 0x0
name 0x1031bf992 UIPageViewControllerDataSource
protocols 0x1038b4590
+673
View File
@@ -0,0 +1,673 @@
/****** MP文集 ******/
/* Copy 自公众号文章 CSS START */
.rich_media_inner {
font-size: 16px;
word-wrap: break-word;
-webkit-hyphens: auto;
-ms-hyphens: auto;
hyphens: auto
}
.rich_media_area_primary {
position: relative;
padding: 10px 20px 20px;
background-color: #fff
}
.rich_media_area_primary:before {
content: " ";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 1px;
border-top: 1px solid #e5e5e5;
-webkit-transform-origin: 0 0;
transform-origin: 0 0;
-webkit-transform: scaleY(0.5);
transform: scaleY(0.5);
top: auto;
bottom: -2px
}
.rich_media_area_primary .original_img_wrp {
display: inline-block;
font-size: 0
}
.rich_media_area_primary .original_img_wrp .tips_global {
display: block;
margin-top: .5em;
font-size: 14px;
text-align: right;
width: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-wrap: normal
}
.rich_media_area_extra {
padding: 0 15px 0
}
.rich_media_title {
margin-bottom: 10px;
line-height: 1.47;
font-weight: 400;
font-size: 23px
}
.rich_media_meta_list {
margin-bottom: 18px;
line-height: 20px;
font-size: 0;
display: none;
}
.rich_media_meta_list em {
font-style: normal
}
.rich_media_meta {
display: inline-block;
vertical-align: middle;
margin-right: 8px;
margin-bottom: 10px;
font-size: 16px
}
.meta_original_tag {
display: inline-block;
vertical-align: middle;
padding: 1px .5em;
border: 1px solid #9e9e9e;
color: #8c8c8c;
border-top-left-radius: 20% 50%;
-moz-border-radius-topleft: 20% 50%;
-webkit-border-top-left-radius: 20% 50%;
border-top-right-radius: 20% 50%;
-moz-border-radius-topright: 20% 50%;
-webkit-border-top-right-radius: 20% 50%;
border-bottom-left-radius: 20% 50%;
-moz-border-radius-bottomleft: 20% 50%;
-webkit-border-bottom-left-radius: 20% 50%;
border-bottom-right-radius: 20% 50%;
-moz-border-radius-bottomright: 20% 50%;
-webkit-border-bottom-right-radius: 20% 50%;
font-size: 15px;
line-height: 1.1
}
.meta_enterprise_tag img {
width: 30px;
height: 30px!important;
display: block;
position: relative;
margin-top: -3px;
border: 0
}
.rich_media_meta_text {
color: #8c8c8c
}
span.rich_media_meta_nickname {
display: none
}
.rich_media_thumb_wrp {
margin-bottom: 6px
}
.rich_media_thumb_wrp .original_img_wrp {
display: block
}
.rich_media_thumb {
display: block;
width: 100%
}
.rich_media_content {
overflow: hidden;
color: #3e3e3e
}
.rich_media_content * {
max-width: 100%!important;
box-sizing: border-box!important;
-webkit-box-sizing: border-box!important;
word-wrap: break-word!important
}
.rich_media_content p {
clear: both;
min-height: 1em
}
.rich_media_content em {
font-style: italic
}
.rich_media_content fieldset {
min-width: 0
}
.rich_media_content .list-paddingleft-2 {
padding-left: 30px
}
.rich_media_content blockquote {
margin: 0;
padding-left: 10px;
border-left: 3px solid #dbdbdb
}
/*img {*/
/* height: auto!important*/
/*}*/
@media screen and (device-aspect-ratio: 2/3),
screen and (device-aspect-ratio: 40/71) {
.meta_original_tag {
padding-top: 0
}
}
@media(min-device-width:375px) and (max-device-width:667px) and (-webkit-min-device-pixel-ratio:2) {
.mm_appmsg .rich_media_inner, .mm_appmsg .rich_media_meta, .mm_appmsg .discuss_list, .mm_appmsg .rich_media_extra, .mm_appmsg .title_tips .tips {
font-size: 17px
}
.mm_appmsg .meta_original_tag {
font-size: 15px
}
}
@media(min-device-width:414px) and (max-device-width:736px) and (-webkit-min-device-pixel-ratio:3) {
/* .mm_appmsg .rich_media_title {*/
/* font-size: 25px*/
/* }*/
}
@media screen and (min-width: 1024px) {
.rich_media {
width: 740px;
margin-left: auto;
margin-right: auto
}
.rich_media_inner {
padding: 20px
}
body {
background-color: #fff
}
}
@media screen and (min-width: 1025px) {
body {
font-family: "Helvetica Neue", Helvetica, "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif
}
.rich_media {
position: relative
}
.rich_media_inner {
background-color: #fff;
padding-bottom: 100px
}
}
.radius_avatar {
display: inline-block;
background-color: #fff;
padding: 3px;
border-radius: 50%;
-moz-border-radius: 50%;
-webkit-border-radius: 50%;
overflow: hidden;
vertical-align: middle
}
.radius_avatar img {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
-moz-border-radius: 50%;
-webkit-border-radius: 50%;
background-color: #eee
}
.cell {
padding: .8em 0;
display: block;
position: relative
}
.cell_hd,
.cell_bd,
.cell_ft {
display: table-cell;
vertical-align: middle;
word-wrap: break-word;
word-break: break-all;
white-space: nowrap
}
.cell_primary {
width: 2000px;
white-space: normal
}
.flex_cell {
padding: 10px 0;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center
}
.flex_cell_primary {
width: 100%;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
box-flex: 1;
flex: 1
}
#copyright_info.original_tool_area {
display: none;
}
.original_tool_area {
display: block;
padding: .75em 1em 0;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
color: #3e3e3e;
border: 1px solid #eaeaea;
margin: 20px 0
}
.original_tool_area .tips_global {
position: relative;
padding-bottom: .5em;
font-size: 15px
}
.original_tool_area .tips_global:after {
content: " ";
position: absolute;
left: 0;
bottom: 0;
right: 0;
height: 1px;
border-bottom: 1px solid #dbdbdb;
-webkit-transform-origin: 0 100%;
transform-origin: 0 100%;
-webkit-transform: scaleY(0.5);
transform: scaleY(0.5)
}
.original_tool_area .radius_avatar {
width: 27px;
height: 27px;
padding: 0;
margin-right: .5em
}
.original_tool_area .radius_avatar img {
height: 100%!important
}
.original_tool_area .flex_cell_bd {
width: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-wrap: normal
}
.original_tool_area .flex_cell_ft {
font-size: 14px;
color: #8c8c8c;
padding-left: 1em;
white-space: nowrap
}
.original_tool_area .icon_access:after {
content: " ";
display: inline-block;
height: 8px;
width: 8px;
border-width: 1px 1px 0 0;
border-color: #cbcad0;
border-style: solid;
transform: matrix(0.71, 0.71, -0.71, 0.71, 0, 0);
-ms-transform: matrix(0.71, 0.71, -0.71, 0.71, 0, 0);
-webkit-transform: matrix(0.71, 0.71, -0.71, 0.71, 0, 0);
position: relative;
top: -2px;
top: -1px
}
.weui_loading {
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
-webkit-animation: weuiLoading 1s steps(12, end) infinite;
animation: weuiLoading 1s steps(12, end) infinite;
background: transparent url(data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iciIgd2lkdGg9JzEyMHB4JyBoZWlnaHQ9JzEyMHB4JyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj4KICAgIDxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiBmaWxsPSJub25lIiBjbGFzcz0iYmsiPjwvcmVjdD4KICAgIDxyZWN0IHg9JzQ2LjUnIHk9JzQwJyB3aWR0aD0nNycgaGVpZ2h0PScyMCcgcng9JzUnIHJ5PSc1JyBmaWxsPScjRTlFOUU5JwogICAgICAgICAgdHJhbnNmb3JtPSdyb3RhdGUoMCA1MCA1MCkgdHJhbnNsYXRlKDAgLTMwKSc+CiAgICA8L3JlY3Q+CiAgICA8cmVjdCB4PSc0Ni41JyB5PSc0MCcgd2lkdGg9JzcnIGhlaWdodD0nMjAnIHJ4PSc1JyByeT0nNScgZmlsbD0nIzk4OTY5NycKICAgICAgICAgIHRyYW5zZm9ybT0ncm90YXRlKDMwIDUwIDUwKSB0cmFuc2xhdGUoMCAtMzApJz4KICAgICAgICAgICAgICAgICByZXBlYXRDb3VudD0naW5kZWZpbml0ZScvPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyM5Qjk5OUEnCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSg2MCA1MCA1MCkgdHJhbnNsYXRlKDAgLTMwKSc+CiAgICAgICAgICAgICAgICAgcmVwZWF0Q291bnQ9J2luZGVmaW5pdGUnLz4KICAgIDwvcmVjdD4KICAgIDxyZWN0IHg9JzQ2LjUnIHk9JzQwJyB3aWR0aD0nNycgaGVpZ2h0PScyMCcgcng9JzUnIHJ5PSc1JyBmaWxsPScjQTNBMUEyJwogICAgICAgICAgdHJhbnNmb3JtPSdyb3RhdGUoOTAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNBQkE5QUEnCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgxMjAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNCMkIyQjInCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgxNTAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNCQUI4QjknCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgxODAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNDMkMwQzEnCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgyMTAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNDQkNCQ0InCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgyNDAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNEMkQyRDInCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgyNzAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNEQURBREEnCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgzMDAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0PgogICAgPHJlY3QgeD0nNDYuNScgeT0nNDAnIHdpZHRoPSc3JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNFMkUyRTInCiAgICAgICAgICB0cmFuc2Zvcm09J3JvdGF0ZSgzMzAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPgogICAgPC9yZWN0Pgo8L3N2Zz4=) no-repeat;
-webkit-background-size: 100%;
background-size: 100%
}
@-webkit-keyframes weuiLoading {
0% {
-webkit-transform: rotate3d(0, 0, 1, 0deg)
}
100% {
-webkit-transform: rotate3d(0, 0, 1, 360deg)
}
}
@keyframes weuiLoading {
0% {
-webkit-transform: rotate3d(0, 0, 1, 0deg)
}
100% {
-webkit-transform: rotate3d(0, 0, 1, 360deg)
}
}
.gif_img_wrp {
display: inline-block;
font-size: 0;
position: relative;
font-weight: 400;
font-style: normal;
text-indent: 0;
text-shadow: none 1px 1px rgba(0, 0, 0, 0.5)
}
.gif_img_wrp img {
vertical-align: top
}
.gif_img_tips {
background: rgba(0, 0, 0, 0.6)!important;
filter: progid: DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#99000000', endcolorstr '#99000000');
border-top-left-radius: 1.2em 50%;
-moz-border-radius-topleft: 1.2em 50%;
-webkit-border-top-left-radius: 1.2em 50%;
border-top-right-radius: 1.2em 50%;
-moz-border-radius-topright: 1.2em 50%;
-webkit-border-top-right-radius: 1.2em 50%;
border-bottom-left-radius: 1.2em 50%;
-moz-border-radius-bottomleft: 1.2em 50%;
-webkit-border-bottom-left-radius: 1.2em 50%;
border-bottom-right-radius: 1.2em 50%;
-moz-border-radius-bottomright: 1.2em 50%;
-webkit-border-bottom-right-radius: 1.2em 50%;
line-height: 2.3;
font-size: 11px;
color: #fff;
text-align: center;
position: absolute;
bottom: 10px;
left: 10px;
min-width: 65px
}
.gif_img_tips.loading {
min-width: 75px
}
.gif_img_tips i {
vertical-align: middle;
margin: -0.2em .73em 0 -2px
}
.gif_img_play_arrow {
display: inline-block;
width: 0;
height: 0;
border-width: 8px;
border-style: dashed;
border-color: transparent;
border-right-width: 0;
border-left-color: #fff;
border-left-style: solid;
border-width: 5px 0 5px 8px
}
.gif_img_loading {
width: 14px;
height: 14px
}
i.gif_img_loading {
margin-left: -4px
}
.gif_bg_tips_wrp {
position: relative;
height: 0;
line-height: 0;
margin: 0;
padding: 0
}
.gif_bg_tips_wrp .gif_img_tips_group {
position: absolute;
top: 0;
left: 0;
z-index: 9999
}
.gif_bg_tips_wrp .gif_img_tips_group .gif_img_tips {
top: 0;
left: 0;
bottom: auto
}
.rich_media_global_msg {
position: fixed;
top: 0;
left: 0;
right: 0;
padding: 1em 35px 1em 15px;
z-index: 2;
background-color: #c6e0f8;
color: #8c8c8c;
font-size: 13px
}
.rich_media_global_msg .icon_closed {
position: absolute;
right: 15px;
top: 50%;
margin-top: -5px;
line-height: 300px;
overflow: hidden;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
background: transparent url(//res.wx.qq.com/mmbizwap/zh_CN/htmledition/images/icon/appmsg/icon_appmsg_msg_closed_sprite.2x2eb52b.png) no-repeat 0 0;
width: 11px;
height: 11px;
vertical-align: middle;
display: inline-block;
-webkit-background-size: 100% auto;
background-size: 100% auto
}
.rich_media_global_msg .icon_closed:active {
background-position: 0 -17px
}
.preview_appmsg .rich_media_title {
margin-top: 26px;
}
@media screen and (min-width: 1024px) {
.rich_media_global_msg {
position: relative;
margin: 0 20px
}
.preview_appmsg .rich_media_title {
margin-top: 0
}
}
.pages_reset {
color: #3e3e3e;
line-height: 1.6;
font-size: 16px;
font-weight: 400;
font-style: normal;
text-indent: 0;
letter-spacing: normal;
text-align: left;
text-decoration: none
}
.weapp_element,
.weapp_display_element,
.mp-miniprogram {
display: block;
margin: 1em 0
}
.share_audio_context {
margin: 16px 0
}
.weapp_text_link {
font-size: 17px
}
.weapp_text_link:before {
content: '';
display: inline-block;
line-height: 1;
background-size: 12px 12px;
background-repeat: no-repeat;
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAAb1BMVEUAAAB4it11h9x2h9x2h9x2htx8j+R8i+B1h9x2h9x3h92Snv91htt2h9x1h9x4h9x1h9x1h9x2idx1h9t2h9t1htt1h9x1h9x1htx2h9x1h912h9x4h913iN17juOOjuN1iNx2h9t4h958i+B1htvejBiPAAAAJHRSTlMALPLcxKcVEOXXUgXtspU498sx69DPu5+Yc2JeRDwbCYuIRiGBtoolAAAA3ElEQVQoz62S1xKDIBBFWYiFYImm2DWF///G7DJEROOb58U79zi4O8iOo8zuCRfV8EdFgbYE49qFQs8ksJInajOA1wWfYvLcGSueU/oUGBtPpti09uNS68KTMcrQ5jce4kmN/HKn9XVPAo702JEdx9hTUrWUqVrI3KwUmM1NhIWMKdwiGvpGMWZOAj1PZuzAxHwhVSplrajoseBnbyDHAwvrtvKKhdqTtFBkL8wO5ijcsS3G1JMNvQ5mdW7fc0x0+ZcnlJlZiflAomdEyFaM7qeK2JahEjy5ZyU7jC/q/Rz/DgqEuAAAAABJRU5ErkJggg==');
vertical-align: middle;
font-size: 11px;
color: #888;
border-radius: 10px;
background-color: #f4f4f4;
margin-right: 6px;
margin-top: -4px;
background-position: center;
height: 20px;
width: 20px
}
.weui-mask {
position: fixed;
z-index: 1000;
top: 0;
right: 0;
left: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6)
}
.weui-dialog {
position: fixed;
z-index: 5000;
width: 80%;
max-width: 300px;
top: 50%;
left: 50%;
-webkit-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
background-color: #fff;
text-align: center;
border-radius: 3px;
overflow: hidden
}
.weui-dialog__hd {
padding: 1.3em 1.6em .5em
}
.weui-dialog__title {
font-weight: 400;
font-size: 18px
}
.weui-dialog__bd {
padding: 0 1.6em .8em;
min-height: 40px;
font-size: 15px;
line-height: 1.3;
word-wrap: break-word;
word-break: break-all;
color: #999
}
.weui-dialog__bd:first-child {
padding: 2.7em 20px 1.7em;
color: #353535
}
.weui-dialog__ft {
position: relative;
line-height: 48px;
font-size: 18px;
display: -webkit-box;
display: -webkit-flex;
display: flex
}
.weui-dialog__ft:after {
content: " ";
position: absolute;
left: 0;
top: 0;
right: 0;
height: 1px;
border-top: 1px solid #d5d5d6;
color: #d5d5d6;
-webkit-transform-origin: 0 0;
transform-origin: 0 0;
-webkit-transform: scaleY(0.5);
transform: scaleY(0.5)
}
.weui-dialog__btn {
display: block;
-webkit-box-flex: 1;
-webkit-flex: 1;
flex: 1;
color: #3cc51f;
text-decoration: none;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
position: relative
}
.weui-dialog__btn:active {
background-color: #eee
}
.weui-dialog__btn:after {
content: " ";
position: absolute;
left: 0;
top: 0;
width: 1px;
bottom: 0;
border-left: 1px solid #d5d5d6;
color: #d5d5d6;
-webkit-transform-origin: 0 0;
transform-origin: 0 0;
-webkit-transform: scaleX(0.5);
transform: scaleX(0.5)
}
.weui-dialog__btn:first-child:after {
display: none
}
.weui-dialog__btn_default {
color: #353535
}
.weui-dialog__btn_primary {
color: #0bb20c
}
/* Copy 自公众号文章 CSS END */
img[data-w] {
max-width: 100%;
}
iframe.video_iframe {
overflow: hidden !important; margin-left:-16px !important;
}
iframe.vote_iframe {
display: none !important;
}
/* 不支持的 DOM */
.unsupported_iframe {
border-radius: 4px;
border: 1px solid #ffcdc0;
background: #fee;
padding: 0 16px;
line-height: 50px;
color: #ff8d8d;
font-size: 14px;
}
/* 公众号音频样式 */
.re .audio_container {border-width: 1px 0; border-color: #D4D6D8; border-style: solid; padding: 16px 20px 16px 0; margin-left: -16px; margin-right: -16px; line-height: 1 !important;}
.re .audio_button {width: 40px; height: 40px; background-color: #f0f4f8; border-radius: 20px; float: left; margin-left: 16px !important; margin-top: 10px !important; background: center center no-repeat url(icon_lecture_playing_2x.png); background-size: contain;}
.re .audio_button.active { background-image: url(icon_lecture_pause_2x.png);}
.re .audio_content { overflow: hidden; padding-left: 16px;}
.re .audio_title {font-size: 18px; font-weight: bold; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: #353C46;}
.re .audio_progress {height: 3px; position: relative; margin-top: 16px !important; margin-right: 16px !important;}
.re .audio_progress_bottom {height: 100%; background-color: #E8F3FD; position: absolute; top: 0px; left: 0px; right: -16px;}
.re .audio_progress_top {height: 100%;background-color: #3CABFF; position: absolute; top: 0px; left: 0px;}
.re .audio_progress_current {width: 16px; height: 16px; border-radius: 8px; z-index: 10; box-shadow:0px 3px 6px rgba(0, 25, 104, 0.12); background-color: #FFFFFF; position: absolute; top: 50%; margin-top: -8px !important;}
.re .audio_progress_current::before {content: ''; width: 4px; height: 4px; position: absolute; background-color: #3CABFF; border-radius: 2px; top: 50%; left: 50%; margin-top: -2px; margin-left: -2px;}
.re .audio_time {margin-top: 12px !important; overflow: hidden;}
.re .audio_time span {color: #54A6F3 !important; font-size: 11px !important;}
.re .audio_time_left {float: left;}
.re .audio_time_right {float: right;}
@media (-webkit-min-device-pixel-ratio: 2), (min--moz-device-pixel-ratio: 2), (min-device-pixel-ratio: 2), (-o-min-device-pixel-ratio: 2/1), (min-resolution: 2dppx), (min-resolution: 192dpi) {
.re .audio_container { position: relative; border: none; }
.re .audio_container:after { content: ""; position: absolute; top: 0; left: 0; width: 200%; height: 200%; border-radius: 0; border-style: solid; border-color: #d4d6d8; border-width: 1px 0 1px 0; -webkit-transform: scale(0.5, 0.5); -ms-transform: scale(0.5, 0.5); transform: scale(0.5, 0.5); -webkit-transform-origin: 0 0; -ms-transform-origin: 0 0; transform-origin: 0 0; -webkit-box-sizing: border-box; box-sizing: border-box; pointer-events: none;
}
}
@media (-webkit-min-device-pixel-ratio: 3), (min--moz-device-pixel-ratio: 3), (min-device-pixel-ratio: 3), (-o-min-device-pixel-ratio: 3/1), (min-resolution: 3dppx), (min-resolution: 288dpi) {
.re .audio_button { background-image: url(icon_lecture_playing_3x.png); }
.re .audio_button.active { background-image: url(icon_lecture_pause_3x.png); }
.re .audio_container:after { width: 300%; height: 300%; border-radius: 0; -webkit-transform: scale(0.3333, 0.3333); -ms-transform: scale(0.3333, 0.3333); transform: scale(0.3333, 0.3333);
}
}
/* 被删除的公众号底部链接删除,不要直接 display none 而是让他们不显示是因为我们还需要那一块的高度 */
.weui-msg > .weui-msg__opr-area > .weui-msg__opr-area__tips > a,
.page_msg > .extra_area > .tips > a,
#activity-detail .extra_area > .tips > a {
color: #fff;
opacity: 0;
}
/* 想法详情 WebView 里的 CSS 会不正确地将 Portrait 识别为 Landscape,但因为我们的想法详情暂时不会出现横屏的情况,先临时这样修改 */
@media only screen and (-webkit-device-pixel-ratio: 3) and (device-height: 812px) and (device-width: 375px) and (orientation: landscape) {
.rich_media_area_primary {
padding: 20px 20px 15px 20px !important;
}
}
+140
View File
@@ -0,0 +1,140 @@
@charset "UTF-8";
/****** @override 微信公众号样式 ******/
body {
background-color: #fff;
}
img {
border: none !important;
}
.rich_media_area_primary {
min-height: 100%;
overflow: hidden;
outline: 0px solid transparent;
background-repeat: no-repeat;
background-position: center;
background-size: cover;
-webkit-user-select: auto !important;
-webkit-user-modify: read-only !important;
font-size: 18px;
line-height: 30px;
font-family: -apple-system;
padding: 0 20px 20px;
}
.rich_media_title {
margin: -3px 0 10px 0;
overflow: hidden;
font-weight: 700;
font-size: 24px;
line-height: 36px;
}
.rich_media_content {
overflow: visible;
}
.rich_media_content p {
/* margin: 20px 0px 0px 0px;*/
word-wrap: break-word;
}
.rich_media_content blockquote {
margin: 18px 0px 0px 0px;
padding: 14px;
/* color: #353C46;*/
/* border-left: 2px solid #D4D6D8;*/
/* background-color: #F4F5F7;*/
font-weight: 300;
}
.rich_media_content blockquote p:empty {
display: none;
}
.rich_media_content blockquote p:first-child {
margin-top: 0px;
}
/*复制的内容有可能经过js之后会包一个空的p,所以要找到下一个p去掉margin*/
.rich_media_content blockquote p:empty:first-child + p {
margin-top: 0px;
}
.rich_media_tool {
display: none;
}
.rich_media_content img {
/* display: inline-block;
margin: 0;*/
}
/*覆盖QQ音乐的样式*/
.qqmusic_area {
margin-top: 0px;
}
/*分享图片类型的页面去掉了顶部的信息,需要手动加一个 Margin 防止内容贴着顶部*/
.page_share_img .share_notice {
margin-top: 30px;
}
.share_mod_context .account_info {
display: none;
}
/* 屏蔽 iPad 上可能出现的 PC 版公众号二维码 */
#js_pc_qr_code {
display: none !important;
}
.tts {
background-color: rgba(27, 136, 238, .25) !important;
}
/* 自己的想法 */
.review {
border-bottom: 1px dashed rgba(249, 84, 87, 1) !important;
}
/* 好友的想法 */
.friend-review {
border-bottom: 1px dashed rgba(133, 140, 150, 1) !important;
}
/* 划线 */
.highlight {
background-color: rgba(255, 159, 78, .25) !important;
}
/* 从想法圈进来的划线 */
.reference {
background-color: rgba(247, 247, 0, .25) !important;
}
/* 以下几种复合的情况,只显示 review 的样式,不显示 highlight 的样式 */
.review .highlight,
.friend-review .highlight,
.highlight .review,
.highlight .friend-review,
.highlight.review,
.highlight.friend-review {
background-color: none !important;
}
body,
#js_article,
.review,
.highlight,
.friend-review,
.reference {
-webkit-tap-highlight-color: transparent;
}
/* 公众号图片点击链接 */
.h5_image_link {
pointer-events: none;
}
+413
View File
@@ -0,0 +1,413 @@
/*! normalize.css v3.0.2 | MIT License | git.io/normalize */
/**
* 1. Set default font family to sans-serif.
* 2. Prevent iOS text size adjust after orientation change, without disabling
* user zoom.
*/
html {
font-family: sans-serif; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/**
* Remove default margin.
*/
body {
margin: 0;
}
/* HTML5 display definitions
========================================================================== */
/**
* Correct `block` display not defined for any HTML5 element in IE 8/9.
* Correct `block` display not defined for `details` or `summary` in IE 10/11
* and Firefox.
* Correct `block` display not defined for `main` in IE 11.
*/
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
main,
menu,
nav,
section,
summary {
display: block;
}
/**
* 1. Correct `inline-block` display not defined in IE 8/9.
* 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.
*/
audio,
canvas,
progress,
video {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/**
* Prevent modern browsers from displaying `audio` without controls.
* Remove excess height in iOS 5 devices.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Address `[hidden]` styling not present in IE 8/9/10.
* Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22.
*/
[hidden],
template {
display: none;
}
/* Links
========================================================================== */
/**
* Remove the gray background color from active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* Improve readability when focused and also mouse hovered in all browsers.
*/
a:active,
a:hover {
outline: 0;
}
/* Text-level semantics
========================================================================== */
/**
* Address styling not present in IE 8/9/10/11, Safari, and Chrome.
*/
abbr[title] {
border-bottom: 1px dotted;
}
/**
* Address style set to `bolder` in Firefox 4+, Safari, and Chrome.
*/
b,
strong {
font-weight: bold;
}
/**
* Address styling not present in Safari and Chrome.
*/
dfn {
font-style: italic;
}
/**
* Address variable `h1` font-size and margin within `section` and `article`
* contexts in Firefox 4+, Safari, and Chrome.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/**
* Address styling not present in IE 8/9.
*/
mark {
background: #ff0;
color: #000;
}
/**
* Address inconsistent and variable font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` affecting `line-height` in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sup {
top: -0.5em;
}
sub {
bottom: -0.25em;
}
/* Embedded content
========================================================================== */
/**
* Remove border when inside `a` element in IE 8/9/10.
*/
img {
border: 0;
}
/**
* Correct overflow not hidden in IE 9/10/11.
*/
svg:not(:root) {
overflow: hidden;
}
/* Grouping content
========================================================================== */
/**
* Address margin not present in IE 8/9 and Safari.
*/
figure {
margin: 1em 40px;
}
/**
* Address differences between Firefox and other browsers.
*/
hr {
box-sizing: content-box;
height: 0;
}
/**
* Contain overflow in all browsers.
*/
pre {
overflow: auto;
}
/**
* Address odd `em`-unit font size rendering in all browsers.
*/
code,
kbd,
pre,
samp {
font-family: monospace, monospace;
font-size: 1em;
}
/* Forms
========================================================================== */
/**
* Known limitation: by default, Chrome and Safari on OS X allow very limited
* styling of `select`, unless a `border` property is set.
*/
/**
* 1. Correct color not being inherited.
* Known issue: affects color of disabled elements.
* 2. Correct font properties not being inherited.
* 3. Address margins set differently in Firefox 4+, Safari, and Chrome.
*/
button,
input,
optgroup,
select,
textarea {
color: inherit; /* 1 */
font: inherit; /* 2 */
margin: 0; /* 3 */
}
/**
* Address `overflow` set to `hidden` in IE 8/9/10/11.
*/
button {
overflow: visible;
}
/**
* Address inconsistent `text-transform` inheritance for `button` and `select`.
* All other form control elements do not inherit `text-transform` values.
* Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
* Correct `select` style inheritance in Firefox.
*/
button,
select {
text-transform: none;
}
/**
* 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
* and `video` controls.
* 2. Correct inability to style clickable `input` types in iOS.
* 3. Improve usability and consistency of cursor style between image-type
* `input` and others.
*/
button,
html input[type="button"], /* 1 */
input[type="reset"],
input[type="submit"] {
-webkit-appearance: button; /* 2 */
cursor: pointer; /* 3 */
}
/**
* Re-set default cursor for disabled elements.
*/
button[disabled],
html input[disabled] {
cursor: default;
}
/**
* Address Firefox 4+ setting `line-height` on `input` using `!important` in
* the UA stylesheet.
*/
input {
line-height: normal;
}
/**
* It's recommended that you don't attempt to style these elements.
* Firefox's implementation doesn't respect box-sizing, padding, or width.
*
* 1. Address box sizing set to `content-box` in IE 8/9/10.
* 2. Remove excess padding in IE 8/9/10.
*/
input[type="checkbox"],
input[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Fix the cursor style for Chrome's increment/decrement buttons. For certain
* `font-size` values of the `input`, it causes the cursor style of the
* decrement button to change from `default` to `text`.
*/
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Address `appearance` set to `searchfield` in Safari and Chrome.
* 2. Address `box-sizing` set to `border-box` in Safari and Chrome
*/
input[type="search"] {
-webkit-appearance: textfield; /* 1 */
-webkit-box-sizing: content-box; /* 2 */
box-sizing: content-box;
}
/**
* Remove inner padding and search cancel button in Safari and Chrome on OS X.
* Safari (but not Chrome) clips the cancel button when the search input has
* padding (and `textfield` appearance).
*/
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* Define consistent border, margin, and padding.
*/
fieldset {
border: 1px solid #c0c0c0;
margin: 0 2px;
padding: 0.35em 0.625em 0.75em;
}
/**
* 1. Correct `color` not being inherited in IE 8/9/10/11.
* 2. Remove padding so people aren't caught out if they zero out fieldsets.
*/
legend {
border: 0; /* 1 */
padding: 0; /* 2 */
}
/**
* Remove default vertical scrollbar in IE 8/9/10/11.
*/
textarea {
overflow: auto;
}
/**
* Don't inherit the `font-weight` (applied by a rule above).
* NOTE: the default cannot safely be changed in Chrome and Safari on OS X.
*/
optgroup {
font-weight: bold;
}
/* Tables
========================================================================== */
/**
* Remove most spacing between table cells.
*/
table {
border-collapse: collapse;
border-spacing: 0;
}
td,
th {
padding: 0;
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Copyright (C) 2015 Wasabeef
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* epub的书籍默认1em=16px,转化px的时候需要注意 */
@charset "UTF-8";
/****** global ******/
* {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-touch-callout: none;
}
html {
height:100%;
}
body {
height:100%;
overflow:hidden;
overflow-y:auto;
-webkit-overflow-scrolling: touch;
}
.re {
min-height:100%;
overflow:hidden;
outline:0px solid transparent;
background-repeat:no-repeat;
background-position:center;
background-size:cover;
-webkit-user-select:auto !important;
-webkit-user-modify:read-write !important;
font-size: 1em;
line-height: 1.75em;
font-family: "SF UI Text","PingFang SC","Lucida Grande", STheiti;
word-wrap: break-word;
word-break: break-all;
}
/*禁止选择*/
.re_Display {
-webkit-touch-callout: none !important;
-webkit-user-select: none !important;
-webkit-user-modify:read-only !important;
}
/****** widget ******/
.re p, .re div {
text-align:justify;
text-indent:2em;
}
.re div p, .re p div {
margin: 0px;
}
blockquote * {
font-family: inherit !important;
font-size: inherit !important;
}
a, .re_link {
text-decoration:none;
color: rgba(84, 127, 176, 1);
}
ul, ol {
padding-left: 10px;
}
ul li, ol li {
margin-bottom: 4px;
}
ul li:first-of-type, ol li:first-of-type {
margin-top: 0px;
}
/* FZFSJW--GB1-0字体在iOS平台下,在h1纯英文的场景下会导致文字重叠,所以改用HYXinRenWenSongW */
/* HYXinRenWenSongW是自动下载的,为了防止还没有下载的时候读取不到字体,就在后面用FZFSJW--GB1-0作为候补 */
h1 {
margin: 18px 0px 18px 0px;
overflow: hidden;
font-weight: normal;
font-size:1.5em;
line-height: 1.4;
font-family: 'HYXinRenWenSongW', 'FZFSJW--GB1-0';
}
h1 * {
font-family: inherit !important;
font-size: inherit !important;
}
img {
vertical-align: middle;
}
/* QQEmoticon */
/* 展示的webview字体比较大,为了视觉居中,加上一个margin */
.re_Display .emoji,
.re_Display .re_QQEmoticon {
margin-top: -1px;
}
/* replace 复制过来的 */
h1.articleTitle,
h1.articleFirstTitle {
margin-bottom:3em;
font-size:1.5em;
font-family: "SourceHanSerifCN-Medium";
font-weight: normal;
}
h2.articleSecondTitle {
font-size:1.4em;
font-family: "SourceHanSerifCN-Medium";
font-weight: normal;
margin-top: 0.83em;
}
blockquote.articleQuote {
display: block;
margin: 0px;
padding: 14px;
color: #353C46;
border-left: 2px solid #D4D6D8;
background-color: rgba(0, 30, 40, 0.04);
font-weight: 300;
text-indent: 0;
}
.articleQuote p {
margin: 0px;
text-indent: 0px;
}
.re .bodyPic {
text-align: center;
text-indent: 0px;
margin: 18px 0 18px 0;
}
.re .bodyPic .re_img {
display: inline-block;
max-width: 100%;
outline: 1px solid rgba(0,0,0,0.1);
outline-offset: -1px;
}
/*ul.articleList {*/
/*}*/
/**/
/*li.articleListItem {*/
/*}*/
+408
View File
@@ -0,0 +1,408 @@
/**
* Copyright (C) 2015 Wasabeef
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@charset "UTF-8";
/****** global ******/
* {
-webkit-tap-highlight-color: rgba(0,0,0,0);
-webkit-touch-callout: none;
}
html {
height:100%;
}
body {
height:100%;
overflow:hidden;
overflow-y:auto;
-webkit-overflow-scrolling: touch;
}
.re {
min-height:100%;
overflow:hidden;
outline:0px solid transparent;
background-repeat:no-repeat;
background-position:center;
background-size:cover;
-webkit-user-select:auto !important;
-webkit-user-modify:read-write !important;
font-size: 18px;
line-height: 30px;
font-family: -apple-system;
color: #0D141E;
padding: 0 20px;
}
.re_Display {
min-height: 0; /* 展示的时候不需要设置 min-height,否则cell里面通过 js 获取高度无法拿到正确的值 */
padding: 8px 20px 0;
line-height: 32px;
-webkit-user-modify: read-only !important;
}
.re_placeholder:before {
position: absolute;
top: 0;
content:attr(placeholder);
color: #ADB4BE;
}
/****** widget ******/
.re div, .re p {
margin: 18px 0px 0px 0px;
word-break: break-word;
word-wrap: break-word;
}
/** re下第一个element **/
.re_Write *:first-child,
.re_Display *:first-child {
margin-top: 0 !important;
}
.re_Write > :first-child
.re_Display > :first-child {
margin-top: 0 !important;
}
.re_Write > :empty + * {
margin-top: 0 !important;
}
.re div p, .re p div {
margin: 0px;
}
blockquote {
margin: 18px 0px 0px 0px;
padding: 14px;
color: #353C46;
border-left: 2px solid #D4D6D8;
background-color: #F4F5F7;
font-weight: 300;
}
blockquote p:empty {
display: none;
}
blockquote p:first-child {
margin-top: 0px;
}
/*复制的内容有可能经过js之后会包一个空的p,所以要找到下一个p去掉margin*/
blockquote p:empty:first-child + p {
margin-top: 0px;
}
a, .re_link {
text-decoration:none;
color: rgba(84, 127, 176, 1);
-webkit-tap-highlight-color: rgba(255, 255, 255, 0.5);
}
ul, ol {
list-style-type: none;
padding: 0px;
margin: 18px 0px 0px 0px;
overflow: hidden;
}
.re div ul, .re div ol {
margin: 0px;
}
.re p ul, .re p ol {
margin: 0px;
}
ul li, ol li {
margin-top: 2px;
padding: 0px 0px 0px 24px;
}
ul li:first-of-type, ol li:first-of-type {
margin-top: 0px;
}
ul li:before, ol li:before {
content: "";
width: 6px;
height: 6px;
background-color: #353C46;
border-radius: 3px;
float: left;
margin-top: 11px;
margin-left: -18px;
}
.re span{
background: none !important;
}
h1, h2 {
margin: 19px 0px 0px 0px;
overflow: hidden;
font-weight: 500;
font-size: 24px;
color: #0D141E;
line-height: 36px;
/* FZFSJW--GB1-0字体在iOS平台下,在h1纯英文的场景下会导致文字重叠,所以改用HYXinRenWenSongW */
/* HYXinRenWenSongW是自动下载的,为了防止还没有下载的时候读取不到字体,就在后面用FZFSJW--GB1-0作为候补 */
/* font-family: 'HYXinRenWenSongW', 'FZFSJW--GB1-0';*/
}
img {
vertical-align: middle;
/* 防止拖拽表情后变成 blob */
-webkit-user-drag: none;
user-drag: none;
}
/** hack掉系统产生的奇怪span **/
.re p span,
.re h1 span,
.re h2 span,
.re blockquote span,
.re ul span{
font-family: inherit !important;
font-size: inherit !important;
color: inherit !important;
}
/****** widgets ******/
/* bookItem */
.re .re_bookItem{
padding: 0px 16px 0px 0px;
margin-top: 18px;
-webkit-user-select: none !important;
border: 1px solid #dee0e2;
box-shadow: 0px 1px 4px 0px rgba(0,0,0,0.03);
overflow: hidden;
}
.re_Write .re_bookItem{
display: inline-block;
box-sizing: border-box;
width: 100%;
}
.re .re_bookItem_Touched{
background: #f8fafc;
}
@media (-webkit-min-device-pixel-ratio: 2){
.re .re_bookItem{
position: relative;
border: none;
}
.re .re_bookItem:after{
content:"";
position: absolute;
top: 0;
left: 0;
width: 200%;
height: 200%;
box-sizing: border-box;
border: 1px solid #dee0e2;
pointer-events: none;
-webkit-transform: scale(0.5);
-webkit-transform-origin: 0 0;
transform: scale(0.5);
transform-origin: 0 0;
}
}
@media (-webkit-min-device-pixel-ratio: 3){
.re .re_bookItem:after{
width: 300%;
height: 300%;
transform: scale(0.3333);
transform-origin: 0 0;
-webkit-transform: scale(0.3333);
-webkit-transform-origin: 0 0;
}
}
.re .re_bookItem_cover{
float: left;
width: 60px;
height: 86px;
margin-right: 16px;
margin-top: 0;
background: #fff;
border-right: 1px solid rgba(0,0,0,0.10);
}
@media (-webkit-min-device-pixel-ratio: 2){
.re .re_bookItem_cover{
position: relative;
border: none;
}
.re .re_bookItem_cover:after{
content:"";
position: absolute;
top: 0;
left: 0;
width: 200%;
height: 200%;
box-sizing: border-box;
border-right: 1px solid rgba(0,0,0,0.10);
pointer-events: none;
transform: scale(0.5);
transform-origin: 0 0;
-webkit-transform: scale(0.5);
-webkit-transform-origin: 0 0;
}
}
@media (-webkit-min-device-pixel-ratio: 3){
.re .re_bookItem_cover:after{
width: 300%;
height: 300%;
transform: scale(0.3333);
transform-origin: 0 0;
-webkit-transform: scale(0.3333);
-webkit-transform-origin: 0 0;
}
}
.re .re_bookItem_cover_img{
display: block;
width: 100%;
height: 100%;
}
.re .re_bookItem_title{
line-height: 20px;
margin: 0;
margin-top: 21px;
font-size: 17px;
color: #49505A;
font-family: 'HYXinRenWenSongW', 'FZFSJW--GB1-0';
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.re .re_bookItem_author{
margin-top: 8px;
font-size: 15px;
line-height: 18px;
color: #717882;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: 'HYXinRenWenSongW', 'FZFSJW--GB1-0';
}
.re_img {
display: block;
max-width: 100%;
margin: 18px auto 0px;
background-color: #f4f5f7;
}
.re_Write .re_img{
display: inline-block;
width: 100%;
vertical-align: bottom; /** 为了修复图片后面连着文字时文字的光标长度 **/
}
.re_Write .re_img_MarginTop{
margin-top: 18px !important;
}
.re_Write .re_img_MarginBottom{
margin-bottom: 18px !important;
}
.re_Write .re_img_MarginBottom + .re_img_MarginTop{
margin-top: 0 !important;
}
/** display 特供样式 :为了让图片宽度撑满全屏。**/
.re_Display .bodyPic{
margin-left: -16px !important;
margin-right: -16px !important;
}
/* QQEmoticon */
.re_QQEmoticon{
padding-right: 2px;
}
.re_Display .emoji,
.re_Display .re_QQEmoticon{
/* 展示的webview字体比较大,为了视觉居中,加上一个margin */
margin-top: -1px;
}
/****** night theme ******/
.re_Night {
color: rgba(196, 200, 204, 1);
}
.re_Night blockquote {
color : rgb(218, 220, 224);
background-color : rgb(10, 14, 18);
border-left: 1px solid rgba(116, 120, 124, 1);
}
.re_Night a, .re_Night .re_Night {
color: rgba(84, 127, 176, 1);
}
.re_Night ul li:before, .re_Night ol li:before {
background-color: rgba(196, 200, 204, 1);
}
.re_Night h1,
.re_Night h2,
.re_Night h3,
.re_Night h4,
.re_Night h5,
.re_Night h6,
.re_Night p,
.re_Night textarea,
.re_Night form,
.re_Night select,
.re_Night input,
.re_Night span,
.re_Night button,
.re_Night em,
.re_Night menu,
.re_Night aside,
.re_Night table,
.re_Night tr,
.re_Night td,
.re_Night nav,
.re_Night dl,
.re_Night dt,
.re_Night dd,
.re_Night amp-iframe,
.re_Night main,
.re_Night section {
color: rgba(180, 180, 182, 1) !important;
border-color: #555555 !important;
text-shadow: 0 0 0 #000;
}
p img {
max-width: 100% !important;
height: auto !important;
}
.re_Night.re_placeholder:before {
color: #646466;
}
+190
View File
@@ -0,0 +1,190 @@
@charset "UTF-8";
/****** @override 腾讯视频样式 ******/
/* 控制器主体 */
.tvp_controls {
height: 48px!important;
background-color: rgba(0, 0, 0, .6)!important;
}
.tvp_controls_hide .tvp_controls {
bottom: -48px!important;
}
/* 上次播放到xx,为你继续播放提示 */
.tvp_overlay_tips {
bottom: 48px!important;
background-color: transparent!important;
}
.tvp_controls_hide .tvp_overlay_tips {
bottom: 0!important;
}
/* 按钮 */
.tvp_btn_value {
background: none!important;
border: 0!important;
width: 20px!important;
height: 20px!important;
background-size: 134px 40px!important;
margin: 0px!important;
background-image: url(https://rescdn.qqmail.com/weread/cover/icon/WREpubMPVideo.png)!important;
}
.tvp_pause .tvp_btn_value {
background-position: 40px 0!important;
}
.tvp_play .tvp_btn_value {
background-position: 20px 0!important;
}
/* 预加载的滑块轨道 */
.tvp_time_loaded {
background-color: rgb(128, 128, 128)!important;
}
/* 已经播放过的滑块轨道 */
.tvp_time_current {
background-color: #fff!important;
}
.tvp_time_loaded, .tvp_time_current {
top: 0!important;
}
/* 时间显示器 */
.tvp_time_panel {
width: 100%!important;
}
/* 进度条 */
.tvp_time_total {
width: 58%!important;
left: 21%!important;
top: 23px!important;
height: 2px!important;
}
/* 滑块handle */
.tvp_time_handle {
top: -16px!important;
background-color: transparent!important;
background-size: 134px 40px!important;
background-image: url(https://rescdn.qqmail.com/weread/cover/icon/WREpubMPVideo.png)!important;
background-position: 90px 0!important;
}
.tvp_time_handle:after {
background: none!important;
}
/* 正在播放的时间和总时间: 00:05/03:06 */
.tvp_time_panel .tvp_time_panel_current,
.tvp_time_panel .tvp_time_panel_total {
font-size: 12px!important;
color: #fff!important;
position: relative!important;
top: -15px!important;
font-family: Helvetica!important;
}
/* 播放总时间 */
.tvp_time_panel .tvp_time_panel_total {
float: right!important;
}
/* 正在播放的时间和总时间的分割线 */
.tvp_time_panel .tvp_time_panel_split {
display: none!important;
}
/* 全屏按钮 */
.tvp_fullscreen_button button:before,
.tvp_fullscreen_button button:after,
.tvp_fullscreen_button .tvp_btn_value:before,
.tvp_fullscreen_button .tvp_btn_value:after {
display: none!important;
}
.tvp_fullscreen_button {
position: relative;
}
.tvp_fullscreen_button button {
background-image: url(https://rescdn.qqmail.com/weread/cover/icon/WREpubMPVideo.png)!important;
width: 20px!important;
height: 20px!important;
background-size: 134px 40px!important;
background-position: 60px 0!important;
display: block!important;
top: 50%!important;
left: 50%!important;
margin: -10px 0 0 -10px!important;
position:absolute!important;
}
/* 广告 */
.tvp_ads_go,
.tvp_app_download_onpause {
display: none!important;
}
/* 全屏的播放控制器 */
.tvp_overlay_play {
width: 100%!important;
height: 100%!important;
bottom: 0!important;
background-color: rgba(0, 0, 0, 0.6)!important;
}
.tvp_overlay_play .tvp_button_play {
background-color: transparent;
width: 40px!important;
height: 40px!important;
top: 50%;
left: 50%;
margin: -20px 0 0 -20px;
background-size: 134px 40px!important;
background-image: url(https://rescdn.qqmail.com/weread/cover/icon/WREpubMPVideo.png)!important;
background-position: 0px 0;
border: none!important;
}
/* 播放器按钮 */
.tvp_playpause_button,
.tvp_fullscreen_button {
width: 35px;
}
@media only screen
and (min-device-width : 375px)
and (max-device-width : 667px) {
/* iPhone6 尺寸 */
.tvp_time_total {
width: 60%!important;
left: 20%!important;
}
.tvp_playpause_button,
.tvp_fullscreen_button {
width: 40px!important;
}
}
@media only screen
and (min-device-width : 414px)
and (max-device-width : 736px) {
/* iPhone6P 尺寸 */
.tvp_time_total {
width: 64%!important;
left: 18%!important;
}
.tvp_playpause_button,
.tvp_fullscreen_button {
width: 50px!important;
}
}
+56
View File
@@ -0,0 +1,56 @@
/* 适配搜狗百科 bar 里的 icon 被染色 */
.header-home, .header-home:before, .header-logo, .loginBox, .header-search, .header-search:before, .header-more:before {
background-color: transparent !important
}
/* 适配搜狗百科 某些透明的 div 被染色导致挡住图片 */
.abstract-img-none {
background: none !important
}
/* 适配百度搜索图片丢失 */
.c-touchable-feedback-no-default * {
background-color: transparent !important
}
img, video {
z-index: 1 !important
}
/*背景纯黑*/
*, *:before, *:after {
background-color: rgba(0, 0, 0, 1) !important;
}
.wr-business-container,
.wr-business-container *,
.wr-business-container *:before,
.wr-business-container *:after {
background-color: rgb(28, 28, 29) !important;
}
/*背景颜色和一般字体颜色*/
div, h1, h2, h3, h4, h5, h6, p, body, em, html, link, textarea, form, select, input, span, button, em, menu, aside, table, tr, td, nav, dl, dt, dd, amp-iframe, main, section {
color: rgba(180, 180, 182, 1) !important;
border-color: #555555 !important;
text-shadow: 0 0 0 #000;
}
/*超链接*/
a {
color: rgba(84, 127, 176, 1) !important;
}
blockquote {
color : rgb(218, 220, 224);
background-color : rgb(10, 14, 18) !important;
border-left: 1px solid rgba(116, 120, 124, 1);
}
img {
border: none !important;
}
ul li:before, ol li:before {
background-color: rgba(196, 200, 204, 1) !important;
}
+221
View File
@@ -0,0 +1,221 @@
/* this file is processed with xxd via a build rule and embedded in library */
/* these styles come from Safari */
/* note that comments are only permitted before selectors and before the styles */
/* DO NOT fiddle with this file if you want to have your own styles,
pass your own stylesheet via the option parameter to override these defaults */
head {
display:none;
}
title {
display:none;
}
style {
display:none;
}
link {
display: none;
}
meta {
display: none;
}
script {
display: none;
}
html {
display:block;
margin:0;
padding:0;
}
body {
display:block;
font-size:16px;
margin:0;
padding:0;
}
article,aside,footer,header,hgroup,nav,section {
display:block;
}
p {
display:block;
margin:0;
}
img {
margin:0;
}
ul,ol,menu,dir {
display:block;
margin:1em 0 1em 0;
padding-left:24px;
}
ul {
list-style-type:disc;
}
ol {
list-style-type:decimal;
}
li {
display:list-item;
}
ul ul, ol ul {
list-style-type: circle;
}
ol ol ul, ol ul ul, ul ol ul, ul ul ul {
list-style-type: square;
}
code {
font-family:Courier;
}
pre, xmp, plaintext, listing {
display: block;
font-family: monospace;
white-space: pre;
margin: 1em 0;
}
a {
color:#2262A3;
text-decoration:underline;
}
a:active {
color:#2262A3;
}
center {
text-align:center;
display:block;
}
strong,b {
font-weight:bold;
}
i,em {
font-style:italic;
}
u {
text-decoration:underline;
}
big {
font-size:bigger;
}
small {
font-size:smaller;
}
sub {
font-size:smaller;
vertical-align:sub;
}
sup {
font-size:smaller;
vertical-align:super;
}
s,strike,del {
text-decoration:line-through;
}
tt,code,kbd,samp {
font-family:monospace;
}
pre,xmp,plaintext,listing {
display:block;
font-family:monospace;
white-space:pre;
margin-top:1em;
margin-right:0;
margin-bottom:1em;
}
pre {
background-color:rgba(0,0,0,.05);
padding-top:1em;
padding-bottom:1em;
border-radius:0.3em;
}
h1 {
display:block;
font-size:1.5em;
font-weight: normal;
}
h2 {
display:block;
font-size:1.4em;
margin-top: 0.83em;
}
h3 {
display:block;
font-size:1.3em;
margin-top: 1em;
}
h4 {
display:block;
font-size:1.2em;
margin-top: 1.33em;
}
h5 {
display:block;
font-size:1.1em;
margin-top: 1.67em;
}
h6 {
display:block;
font-size:1em;
margin-top: 2.33em;
}
div {
display: block;
}
hr {
display: block;
margin:0.5em auto 0.5em auto;
border-style: inset;
border-width: 1px;
}
table {
display: table;
border-collapse: separate;
border-spacing: 2px;
border-color: gray;
}
blockquote {
display: block;
}
+171
View File
@@ -0,0 +1,171 @@
/* <pre>代码块,注意必须写font-weight使字体生效 */
pre {
font-family: "Menlo";
font-weight: normal;
line-height: 1.5em;
}
/*版权信息*/
.copyRightTitle {
color: black;
font-size: 1.5em;
font-family: "Source Han Serif CN";
font-weight: normal;
}
/*图片说明文字*/
.eepub-single-image-title {
font-size: 0.75em;
text-align: center;
line-height: 1.4em;
color: rgba(0, 0, 0, 0.9);
margin: 0.2em 0.4em 1em 0.4em;
font-family: "FZFSJW--GB1-0";
font-weight: normal;
}
/*标题*/
.firstTitle, h1.firstTitle {
font-size: 1.5em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.25em;
}
.secondTitle, h2.secondTitle {
font-size: 1.4em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.35em;
}
.thirdTitle, h3.thirdTitle {
font-size: 1.3em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.5em;
}
.fourthTitle, h4.fourthTitle {
font-size: 1.2em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.65em;
}
.fifthTitle, h5.fifthTitle {
font-size: 1.1em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 1.85em;
}
.sixthTitle, h6.sixthTitle {
font-size: 1em;
font-family: "Source Han Serif CN";
font-weight: bold;
line-height: 2em;
}
/*首字加大*/
/*浮动元素有默认的margin,所以这里会修复一下*/
.ftext {
float: left;
margin: 0em;
font-size: 2.38em;
font-weight: normal;
}
/*引用内容*/
.conQuot {
font-family: "FZFSJW--GB1-0";
font-weight: normal;
margin: 0em 0em 0.2em 0em;
}
/*标题下来可能会有一行subHead*/
.subHead{
text-indent: 2em;
}
pre, pre span, pre code {
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size:.7em!important;
}
.bodyPic {
wr-vertical-center-style: 2;
}
.qrbodyPic {
page-break-inside: avoid;
wr-vertical-center-style: 2;
}
/* 注标图 */
.qqreader-footnote {
width: 1em;
}
/* 翻译 */
.wr-translation {
line-height: 1.7em !important;
}
/*-------------------- 小文章相关 ----------------------*/
/*epub-custom-rule-6 如果 class="weread-page-relate" 在开头,需要把上一页末尾一行移到这一页*/
.weread-page-relate {
weread-page-relate:true;
}
/*epub-custom-rule-29 小文章打赏的样式*/
.chapter-reward {
display: block;
height: 120px;
width: 100%;
margin-top: 24px;
}
/*epub-custom-rule-7 小文章工具栏标签的样式*/
.chapter-tool {
display: block;
height: 140px;
width: 100%;
margin-top: 24px;
}
/* 章节结尾工具 */
.book-chapter-tool {
display: block;
height: 78px;
width: 100%;
margin-top: 24px;
}
/* 调试好样式后和安卓同步,交给后台随小文章 css 下发,并把下面的样式从replace.css 删除 */
/* 安卓的re_bookItem是通过原生view留出上下空间的,但是iOS是通过css来控制 */
.re_bookItem{
display: block;
margin: 18px 0 18px 0;
}
p {
text-align:justify;
}
img[data-image-size="large"] {
width: 100%;
}
/* 文集公众号文章不支持控件的样式 */
.unsupported_iframe {
border-radius: 4px;
border: 1px solid #ffcdc0;
background: #fee;
padding: 12px 0px;
color: #ff8d8d;
text-align: center;
font-size: 14px;
}
/* 私有垂直居中类 */
.wr-vertical-center {
wr-vertical-center-style: 1 !important;
}
@@ -0,0 +1,113 @@
/* <pre>代码块,注意必须写font-weight使字体生效 */
pre {
font-family: "Menlo";
font-weight: normal;
line-height: 1.5em;
}
/*版权信息*/
.copyRightTitle {
color: black;
font-size: 1.5em;
font-family: "Source Han Serif CN";
font-weight: normal;
}
/*图片说明文字*/
.eepub-single-image-title {
font-size: 0.75em;
text-align: center;
line-height: 1.4em;
color: rgba(0, 0, 0, 0.9);
margin: 0.2em 0.4em 1em 0.4em;
font-weight: normal;
}
/*标题*/
.firstTitle, h1.firstTitle {
font-size: 1.5em;
font-weight: bold;
line-height: 1.25em;
}
.secondTitle, h2.secondTitle {
font-size: 1.4em;
font-weight: bold;
line-height: 1.35em;
}
.thirdTitle, h3.thirdTitle {
font-size: 1.3em;
font-weight: bold;
line-height: 1.5em;
}
.fourthTitle, h4.fourthTitle {
font-size: 1.2em;
font-weight: bold;
line-height: 1.65em;
}
.fifthTitle, h5.fifthTitle {
font-size: 1.1em;
font-weight: bold;
line-height: 1.85em;
}
.sixthTitle, h6.sixthTitle {
font-size: 1em;
font-weight: bold;
line-height: 2em;
}
/*首字加大*/
/*浮动元素有默认的margin,所以这里会修复一下*/
.ftext {
float: left;
margin: 0em;
font-size: 2.38em;
font-weight: normal;
}
/*引用内容*/
.conQuot {
font-weight: normal;
margin: 0em 0em 0.2em 0em;
}
/*标题下来可能会有一行subHead*/
.subHead{
text-indent: 2em;
}
pre, pre span, pre code {
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size:.7em!important;
}
.bodyPic {
wr-vertical-center-style: 2;
}
.qrbodyPic {
page-break-inside: avoid;
wr-vertical-center-style: 2;
}
/* 注标图 */
.qqreader-footnote {
width: 1em;
}
/* 私有垂直居中类 */
.wr-vertical-center {
wr-vertical-center-style: 1 !important;
}
/* 翻译 */
.wr-translation {
line-height: 1.7em !important;
}
/* 章节结尾工具 */
.book-chapter-tool {
display: block;
height: 78px;
width: 100%;
margin-top: 24px;
}
@@ -0,0 +1,137 @@
/* <pre>代码块,注意必须写font-weight使字体生效 */
pre {
font-family: "Menlo";
font-weight: normal;
line-height: 1.5em;
}
/*版权信息*/
.copyRightTitle {
color: black;
font-size: 1.5em;
font-family: "SourceHanSerifCN-Medium";
font-weight: normal;
}
/*图片说明文字*/
.eepub-single-image-title {
font-size: 0.75em;
text-align: center;
line-height: 1.4em;
color: rgba(0, 0, 0, 0.9);
margin: 0.2em 0.4em 1em 0.4em;
font-family: "FZFSJW--GB1-0";
font-weight: normal;
}
/*标题*/
.preface {
font-size:1.5em;
font-family: "SourceHanSerifCN-Medium";
font-weight: normal;
color: rgba(0, 0, 0, .9);
}
.firstTitle, h1.firstTitle {
font-size:1.5em;
font-family: "SourceHanSerifCN-Medium";
font-weight: normal;
}
.secondTitle, h2.secondTitle {
font-size:1.4em;
font-family: "SourceHanSerifCN-Medium";
font-weight: normal;
margin-top: 0.83em;
}
.thirdTitle, h3.thirdTitle {
font-size:1.3em;
font-family: "SourceHanSerifCN-Medium";
font-weight: normal;
margin-top: 1em;
}
.fourthTitle, h4.fourthTitle {
font-size:1.2em;
font-family: "SourceHanSerifCN-Medium";
font-weight: bold;
margin-top: 1.33em;
}
.fifthTitle, h5.fifthTitle {
font-size:1.1em;
font-family: "SourceHanSerifCN-Medium";
font-weight: bold;
margin-top: 1.67em;
}
.sixthTitle, h6.sixthTitle {
font-size:1em;
font-family: "SourceHanSerifCN-Medium";
font-weight: bold;
margin-top: 2.33em;
}
/*首字加大*/
/*浮动元素有默认的margin,所以这里会修复一下*/
.ftext {
float: left;
margin: 0em;
font-size: 2.38em;
font-weight: normal;
}
/*引用内容*/
.conQuot {
font-family: "FZFSJW--GB1-0";
font-weight: normal;
margin: 0em 0em 0.2em 0em;
}
/*标题下来可能会有一行subHead*/
.subHead{
text-indent: 2em;
}
pre, pre span, pre code {
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size:.7em!important;
}
.qrbodyPic {
page-break-inside: avoid;
}
/* 注标图 */
.qqreader-footnote {
width: 1em;
}
/*-------------------- 小文章相关 ----------------------*/
/*epub-custom-rule-6 如果 class="weread-page-relate" 在开头,需要把上一页末尾一行移到这一页*/
.weread-page-relate {
weread-page-relate:true;
}
/*epub-custom-rule-7 小文章工具栏标签的样式*/
.chapter-tool {
display: block;
height: 160px;
width: 100%;
margin-top: 24px;
}
/* 调试好样式后和安卓同步,交给后台随小文章 css 下发,并把下面的样式从replace.css 删除 */
/* 安卓的re_bookItem是通过原生view留出上下空间的,但是iOS是通过css来控制 */
.re_bookItem{
display: block;
margin: 18px 0 18px 0;
}
/* epub-custom-rule-21 忽略公众号文章 <p> 的 background-color,因为公众号太多这样的样式,影响排版效果 */
p, strong, span {
background-color:transparent !important;
}
/* epub-custom-rule-26 公众号文章背景色强制设为透明 */
section, .rich_media_area_primary {
background-color:transparent !important;
}
+1
View File
@@ -0,0 +1 @@
.hljs{display:block;overflow-x:auto;padding:0.5em;background:#fff;color:black}.xml .hljs-meta{color:#c0c0c0}.hljs-comment,.hljs-quote{color:#007400}.hljs-tag,.hljs-attribute,.hljs-keyword,.hljs-selector-tag,.hljs-literal,.hljs-name{color:#aa0d91}.hljs-variable,.hljs-template-variable{color:#3F6E74}.hljs-code,.hljs-string,.hljs-meta-string{color:#c41a16}.hljs-regexp,.hljs-link{color:#0E0EFF}.hljs-title,.hljs-symbol,.hljs-bullet,.hljs-number{color:#1c00cf}.hljs-section,.hljs-meta{color:#643820}.hljs-class .hljs-title,.hljs-type,.hljs-built_in,.hljs-builtin-name,.hljs-params{color:#5c2699}.hljs-attr{color:#836C28}.hljs-subst{color:#000}.hljs-formula{background-color:#eee;font-style:italic}.hljs-addition{background-color:#baeeba}.hljs-deletion{background-color:#ffc8bd}.hljs-selector-id,.hljs-selector-class{color:#9b703f}.hljs-doctag,.hljs-strong{font-weight:bold}.hljs-emphasis{font-style:italic}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+26
View File
@@ -0,0 +1,26 @@
var generator = {};
generator.generateHtml = function (content, js, css) {
var result = content;
result = result.replace("<!--headTrap<body></body><head></head><html></html>-->", "");
result = result.replace("<!--tailTrap<body></body><head></head><html></html>-->", "");
var match = result.match(/window\.__nonce_str = "(\d+)"/);
var nonce = "";
if (match && match.length > 1) {
nonce = "nonce='" + match[1] + "'";
}
var insertJs = "<script " + (nonce.length > 1 ? nonce : "") + " type='text/javascript'>" + js + "</script></body>";
var insertStyle = "<style type='text/css'>" + css + "</style></head>";
result = result.replace("</head>", insertStyle);
result = result.replace("</body>", insertJs);
return result;
}
generator.generateHtmlForMPReview = function (content,js, css) {
var result = generator.generateHtml(content,js,css)
var modifiedCopyRight = " <span id = 'copyright_logo' stype = 'display:none' > ";
var reg = /<span id="copyright_logo".*>?/;
result = result.replace(reg,modifiedCopyRight);
return result;
}
+26
View File
@@ -0,0 +1,26 @@
(function(){
var head = document.getElementsByTagName("head")[0];
var meta = document.createElement('meta');
meta.name = "viewport";
meta.content = "width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no";
head.appendChild(meta);
var children = document.body.children;
var titleFounded = false;
for(var i = 0; i < children.length; i++){
var child = children[i]
var tagName = child.tagName.toLowerCase()
// 隐藏标题前的空段落
if(!child.textContent){
child.style.display = "none";
}else if(titleFounded){
// 如果找到标题了,那么遇到第一个不为空的短路,就不再处理了
break
}
// 隐藏标题
if(tagName === 'h1' || tagName === 'h2' || tagName === 'h3'){
child.style.display = "none"
titleFounded = true
}
}
})()
+125
View File
@@ -0,0 +1,125 @@
var mediaPlatform = {};
mediaPlatform.getMPHeight = function() {
var height = document.getElementById('js_article').scrollHeight;
return height;
};
mediaPlatform.getMPInfo = function() {
var title;
if (msg_title) {
title = msg_title;
}
else {
title = document.getElementsByTagName('h1')[0].innerHTML.toString();
}
var thumbUrl;
if (msg_cdn_url) {
thumbUrl = msg_cdn_url;
}
else {
thumbUrl = document.getElementsByTagName('img')[0].src.toString();
}
var account;
if (nickname) {
account = nickname;
}
if (!title) {title = "";}
if (!thumbUrl) {thumbUrl= "";}
if (!account) {account = "";}
var info = {
"title": title,
"thumbUrl": thumbUrl,
"account": account,
}
return info;
};
mediaPlatform.getMPInfoStr = function() {
return JSON.stringify(mediaPlatform.getMPInfo());
};
// 这里的做法是为了非独立图片(下方有内容)可以保持跟内容12px的距离
mediaPlatform.handleImageBlank = function() {
var imgs = document.getElementsByTagName('img');
for (var i = 0; i < imgs.length; i++) {
var nextNode = imgs[i].nextSibling;
var preNode = imgs[i].previousSibling;
// 只处理上方有空行的情况,下方有空行经常会跟图片的说明文字连用,尝试修改会导致样式错乱
if (preNode && preNode.nodeName.toLowerCase() == 'br') {
// 处理图片上面有个br,但是br不能自定义高度,所以直接去掉重新加margin
if (preNode.style) {
preNode.style.display = "none";
}
imgs[i].style.marginTop = '12px';
} else if (preNode && preNode.nodeType == 3 && !this.emptyTextNode(preNode)) {
// 处理图片跟上面文字在同一个p里面,但是没有br隔开
imgs[i].style.marginTop = '12px';
}
}
};
mediaPlatform.emptyTextNode = function(el) {
return !el.data || /^(\s|\t)+$/g.test(el.data)
}
// 干掉p>br的情况的margin-top
mediaPlatform.updateBlankHeight = function() {
var blanks = document.getElementsByTagName('br');
for (var i = blanks.length - 1; i >= 0; i--) {
var parentNode = blanks[i].parentNode;
if (parentNode.nodeName.toLowerCase() == "p" && parentNode.childNodes.length == 1) {
parentNode.style.height = '20px';
parentNode.style.minHeight = '0em';
}
}
};
// 观察到部分公众号文章首评的懒加载图片无法正常显示,这是由于 scroll 事件没有触发导致的(怀疑是在 rangy-classapplier 引入后产生了混合反应,但在 Android 上确是好的)。这里的解决办法是手动触发一次 scroll 事件,但由于我们的 webview 其实是完整高度的,本身并不能滚动,所以这样做也不会产生抖动或者其他副作用。
mediaPlatform.protectLazyLoad = function() {
window.scrollTo(0, 1);
};
mediaPlatform.init = function() {
mediaPlatform.updateBlankHeight();
mediaPlatform.handleImageBlank();
mediaPlatform.protectLazyLoad();
};
mediaPlatform.getAuthor = function() {
var find = false;
var author = document.getElementById('js_name');
if (author != null) {
console.log('mediaPlatform.getAuthor ' + author.innerText)
author.addEventListener('click',function(){
wereadBridge.handleWithRichEditor("onClickAuthor",{"param" : author.innerText, "cmd": "onClickAuthor"}, "", "");
}, false)
}
}
mediaPlatform.init();
// 用于在退出公众号文章的时候暂停视频播放
// 由于腾讯视频是通过 iFrame 内嵌的,没有办法直接操作,我们用刷新 iFrame 的方式实现停止播放的效果
function pauseMedia() {
pauseCurrentAudio();
pauseCurrentVideo();
}
function pauseCurrentAudio() {
var audios = document.querySelectorAll('audio');
for (var i = 0; i < audios.length; i++) {
var audio = audios[i];
audio.pause();
}
}
function pauseCurrentVideo() {
var iframes = document.querySelectorAll('iframe');
for (var i = 0; i < iframes.length; i++) {
var oldFrame = iframes[i];
oldFrame.src = oldFrame.src;
}
}
File diff suppressed because it is too large Load Diff
+237
View File
@@ -0,0 +1,237 @@
(function() /** {$b5167ccadfb66ea3ba95d92d7c9946c9} */{
WeReadBridge = function() /** {$b9c7abd4d524a31563f1f27b768c47a9} */ {
this._sendMessageQueue = [];
this._callback_count = 1000;
this._callback_map = {};
this.available_func = {};
this._iframe = document.createElement("iframe");
this._iframe.setAttribute("id", "iframe");
this._iframe.setAttribute("style","position:absolute;top:0;left:0;width:1px;height:1px;visibility:hidden;");
this._QUEUE_HAS_MESSAGE_URL = 'wereadapijs://dispatch_message/';
document.body.appendChild(this._iframe);
this._resultIframe = document.createElement("iframe");
this._resultIframe.setAttribute("id", "_resultIframe");
this._resultIframe.setAttribute("style","position:absolute;top:0;left:0;width:1px;height:1px;visibility:hidden;");
this._resultIframe._SET_RESULT_URL = 'wereadapijs://private/setresult/';
document.body.appendChild(this._resultIframe);
}
window.callback = {}
function wrapCallback(name, callback) {
window.callback[name] = callback;
return name;
}
WeReadBridge.prototype.handleWithRichEditor = function(apiName, params,successCallback, failCallback) {
//alert("hi");
this._call(apiName,params,function(successOrNot, result) {
if (typeof successCallback === "function" || typeof failCallback === "function") {
self._handleCallback(result, successOrNot, successCallback, failCallback);
};
});
}
WeReadBridge.prototype.execMPReaderMethod = function(apiName, params, successCallback, failCallback) {
this._call(apiName,params,function(successOrNot, result) {
if (typeof successCallback === "function" || typeof failCallback === "function") {
self._handleCallback(result, successOrNot, successCallback, failCallback);
};
});
}
WeReadBridge.prototype.fetchQueue = function() /** {$4a202e1afc4b36610e87f574e5478e15} */ {
var messageQueueString = JSON.stringify(this._sendMessageQueue);
this._sendMessageQueue = [];
this._setResultValue('fetchqueue', messageQueueString);
return messageQueueString;
}
WeReadBridge.prototype._setResultValue = function(scene, result) /** {$23e4b67694084da787d5fa5c739b9a66} */ {
// Android 通过另一个iframe上传数据
if (result === undefined) {
result = '';
}
this._resultIframe.src = this._resultIframe._SET_RESULT_URL + scene + '&' + this._base64Encode(this._utf8Encode(result));
}
// public method for url encoding
WeReadBridge.prototype._utf8Encode = function(str) /** {$9da9355c0a506c2d0c6dd40c35ea7fb6} */ {
str = str.replace(/\r\n/g, "\n");
var utftext = "";
for (var n = 0; n < str.length; n++) {
var c = str.charCodeAt(n);
if (c < 128) {
utftext += String.fromCharCode(c);
} else if ((c > 127) && (c < 2048)) {
utftext += String.fromCharCode((c >> 6) | 192);
utftext += String.fromCharCode((c & 63) | 128);
} else {
utftext += String.fromCharCode((c >> 12) | 224);
utftext += String.fromCharCode(((c >> 6) & 63) | 128);
utftext += String.fromCharCode((c & 63) | 128);
}
}
return utftext;
}
WeReadBridge.prototype._base64Encode = function(str) /** {$caa95e8456b5cda1716514776a0bf77a} */ {
//base64编码
var base64encodechars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
if (str === undefined) {
return str;
}
var out, i, len;
var c1, c2, c3;
len = str.length;
i = 0;
out = "";
while (i < len) {
c1 = str.charCodeAt(i++) & 0xff;
if (i == len) {
out += base64encodechars.charAt(c1 >> 2);
out += base64encodechars.charAt((c1 & 0x3) << 4);
out += "==";
break;
}
c2 = str.charCodeAt(i++);
if (i == len) {
out += base64encodechars.charAt(c1 >> 2);
out += base64encodechars.charAt(((c1 & 0x3) << 4) | ((c2 & 0xf0) >> 4));
out += base64encodechars.charAt((c2 & 0xf) << 2);
out += "=";
break;
}
c3 = str.charCodeAt(i++);
out += base64encodechars.charAt(c1 >> 2);
out += base64encodechars.charAt(((c1 & 0x3) << 4) | ((c2 & 0xf0) >> 4));
out += base64encodechars.charAt(((c2 & 0xf) << 2) | ((c3 & 0xc0) >> 6));
out += base64encodechars.charAt(c3 & 0x3f);
}
return out;
}
WeReadBridge.prototype._sendMessage = function(message) /** {$0685a6a5b33dbf2f615a5ae1faedb0c8} */ {
this._sendMessageQueue.push(message);
this._iframe.src = this._QUEUE_HAS_MESSAGE_URL;
}
WeReadBridge.prototype.handleMessage = function(message) /** {$623f3ca7480b38b659e4f32c858046f4} */ {
var callbackId = message["callbackId"];
if (!callbackId || typeof callbackId !== 'string') {
return;
}
var successOrNot = message["successOrNot"];
var params = message["params"];
if (typeof this._callback_map[callbackId] === "function"){
this._callback_map[callbackId](successOrNot,params);
delete this._callback_map[callbackId];
}
}
WeReadBridge.prototype._call = function(func, params, callback) /** {$0a78d8c17b87d24c599c91b498b8662b} */ {
if (!func || typeof func !== "string") {
return;
}
if (typeof params !== "object") {
params = {};
}
var msgObj = {"func":func,"params":params};
var callbackID = (this._callback_count++).toString();
params["callbackId"] = callbackID;
if (typeof callback === "function") {
this._callback_map[callbackID] = callback;
msgObj["callbackId"] = callbackID;
}
this._sendMessage(JSON.stringify(msgObj));
}
WeReadBridge.prototype._handleCallback = function(result, successOrNot, successCallback, failCallback) {
// Android 返回 JSON String,在这里转JSON
resultJSON = (typeof result == 'string') ? JSON.parse(result) : result;
if (successOrNot) {
successCallback(resultJSON);
} else {
failCallback(resultJSON);
}
}
WeReadBridge.prototype.onReady = function(func) /** {$9ce323af53931aa37c8788958d68f687} */ {
!_isReady && _bindReadyFuns.unshift([this, func]);
}
WeReadBridge.prototype.bindReady = function(func) /** {$d3b152348187da57f207a3a3eb27fa54} */ {
!_isReady ? _bindReadyFuns.unshift([this, func]) : func.call(this);
}
WeReadBridge.prototype.isReady = function() /** {$9c23e94ea3f2aafaae1c349574569f78} */ {
return _isReady;
}
WeReadBridge.prototype.isAvailable = function(apiName) /** {$67e34482192a8170c150ec414ed4ba7a} */ {
return !!(_WeReadBridgeInfo && _WeReadBridgeInfo["apis"][apiName]);
}
var _isReady, _WeReadBridgeInfo, _bindReadyFuns = [],
_onReady = function() /** {$d1b53430a7b4de2d74d12a81eec715ec} */ {
_WeReadBridgeInfo = window["__QMB_INFO__"];
if (_isReady) return;
_isReady = true;
var _funcParams;
while (_funcParams = _bindReadyFuns.pop()) {
_funcParams[1].call(_funcParams[0]);
};
};
window["wereadBridge"] = new WeReadBridge();
if (window["__WRB_INFO__"]) {
_onReady();
} else {
window["__WRB_INFO_CALL__"] = function() /** {$4f54cf9d496e3e999912adfb7b5d37b7} */ {
_onReady();
};
}
})();
// /** 函数模板
// * func_name description
// * @param successCallback(result) result["param_name"]
// * @param failCallback(result) result["param_name"]
// */
// WeReadBridge.prototype.func_name = function(params, successCallback, failCallback) {
// var self = this;
// this._call("func_name", params, function(successOrNot, result) {
// var params = {"func_name":result};
// self.localLog(params);
// if (successOrNot) {
// successCallback(result);
// } else {
// failCallback(result);
// }
// });
// }
// window.wereadBridge = new WeReadBridge();
// window.wereadBridge.goToUrl("http://wecall.qq.com/");//weread://bookDetail?opentype=0&bookId=414048");
/**
load页面之前需要先执行以下的js
eval('window["__QMB_INFO__"]={apis:{"a":1,"b":1},ver:"4.0.5",os:"android"};window["__QMB_INFO_CALL__"]&&window["__QMB_INFO_CALL__"]();');
*/
// window.wereadBridge.moreOperation(new Array({'shareToWechatTimeLine': {'title':'title','imageUrl':'imageUrl','abstract':'abstract','url':'url'}}));
// window.wereadBridge.shareToWechatFriend({'title':'中文','imageUrl':'imageUrl','abstract':'中文测试','url':'url'}, function(){}, function(){});
// window.wereadBridge.shareToWechatTimeline({'title':'中文','imageUrl':'imageUrl','abstract':'中文测试','url':'url'}, function(){}, function(){});
// window.wereadBridge.getAppInfo(function(result){alert('getAppInfo: onSucc:'+JSON.stringify(result));}, function(result){alert('getAppInfo: onFailed:'+JSON.stringify(result));});
// window.wereadBridge.closeBrowser();
// window.wereadBridge.window.wereadBridge.mobileSync(function(error,result) {alert('error:' + error+"\n"+'result:' + result);});;
// window.wereadBridge.window.wereadBridge.refreshToken(function(error,result) {alert('error:' + error+"\n"+'result:' + result);});;
// window.wereadBridge.showBrowserMoreButton({'shareToWechatFriend':0, 'shareToWechatTimeline':1, 'copyLink':0, 'openLinkWithBrowser':0});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,261 @@
/**
* Copyright (C) 2015 Wasabeef
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
var RDisplay = {};
RDisplay.editor = document.getElementById('editor');
RDisplay.dataSeparator = "r_e_ds";
// Initializations
RDisplay.onBookToucheStart = function(ele){
ele.classList.add('re_bookItem_Touched');
}
RDisplay.onBookToucheEnd = function(ele){
ele.classList.remove('re_bookItem_Touched');
}
RDisplay.clickedBookId = null
RDisplay.onBookClick = function(ele){
var id = ele.getAttribute('data-id');
wereadBridge.handleWithRichEditor("onGotoBookDetail", {"data-id" : id}, "", "");
}
RDisplay.getClickedBookPosition = function() {
if(RDisplay.clickedBookId) {
var ele = document.getElementById(RDisplay.clickedBookId);
var position = RDisplay.getCoords(ele)
var position = {"top":position.top,"height":position.height,"width":position.width,"left":position.left}
var result = JSON.stringify(position)
return result
}
}
RDisplay.onImageClick = function(ele){
var src = ele.getAttribute('src');
var index = ele.getAttribute('index')
RDisplay.clickedBookId = ele.getAttribute('id');
var position = RDisplay.getCoords(ele)
wereadBridge.handleWithRichEditor("onGotoImageDetail", {"src" : src,"index":index,"top":position.top,"height":position.height,"width":position.width,"left":position.left}, "", "");
}
var reBookItems = document.querySelectorAll('.re_bookItem');
var reImgs = document.querySelectorAll('.re_img');
if(reBookItems.length > 0){
for(var i = 0; i<reBookItems.length; i++){
var bookItem = reBookItems[i];
bookItem.setAttribute('ontouchstart', 'RDisplay.onBookToucheStart(this)');
bookItem.setAttribute('ontouchend', 'RDisplay.onBookToucheEnd(this)');
bookItem.setAttribute('ontouchcancel', 'RDisplay.onBookToucheEnd(this)');
bookItem.setAttribute('onclick', 'RDisplay.onBookClick(this)');
}
}
if(reImgs.length > 0){
for(var i = 0; i<reImgs.length; i++){
var img = reImgs[i];
img.setAttribute('onclick', 'RDisplay.onImageClick(this)');
img.setAttribute('index', i.toString());
var idStr = "re_img_" + i
img.setAttribute('id',idStr);
}
}
RDisplay.getAllImgSrcs = function() {
srcArray = [];
var imgs = document.querySelectorAll('.re_img');
for (var i = 0; i < imgs.length; i++) {
srcArray.push(imgs[i].getAttribute('src'))
}
var srcs = "";
if (srcArray.length > 0) {
srcs = srcArray.join(RDisplay.dataSeparator);
}
return srcs;
}
// 获取当前光标的node
RDisplay.getSelectedNode = function() {
var node = RDisplay.getSelectedBaseNode();
if (node) { return (node.nodeName == "#text" ? node.parentNode : node); }
};
// 获取当前光标的node
RDisplay.getSelectedBaseNode = function() {
var node, selection;
if (window.getSelection) {
selection = getSelection();
node = selection.anchorNode; // 返回该选区起点所在的节点(Node)
}
if (!node && document.selection) {
selection = document.selection
var range = selection.getRangeAt ? selection.getRangeAt(0) : selection.createRange();
node = range.commonAncestorContainer ? range.commonAncestorContainer :
range.parentElement ? range.parentElement() : range.item(0);
}
return node;
};
RDisplay.setHtml = function(str){
document.getElementById('editor').innerHTML = str;
}
RDisplay.selected = function() {
RDisplay.editor.style.backgroundColor = 'rgba(238, 239, 241, 1)';
}
RDisplay.unselected = function() {
RDisplay.editor.style.backgroundColor = 'rgba(255, 255, 255, 1)';
}
RDisplay.logDom = null;
RDisplay.log = function(str){
if(RDisplay.logDom == null){
RDisplay.logDom = document.createElement('div');
RDisplay.logDom.setAttribute('style','position:absolute;top:0;right:0;font-size:12px;border:1px solid #000;color:#000;padding:3px');
document.getElementsByTagName('body')[0].appendChild(RDisplay.logDom);
}
RDisplay.logDom.innerHTML = RDisplay.logDom.innerHTML + "<br/>" + str;
}
document.addEventListener("selectionchange", function(e) {
RDisplay.contentChanged(e);
});
RDisplay.contentChanged = function(e) {
var param = {};
if (typeof(e) != "undefined") {
var node = RDisplay.getSelectedNode();
if (node) {
var nodeName = node.nodeName.toLowerCase();
// Link
if (nodeName == 'a') {
var title = node.getAttribute('title');
var href = node.getAttribute('href');
if (href != undefined) { param['link:'] = href; }
if (title !== undefined) { param['link-title'] = title; }
param['link-text'] = node.innerText;
}
}
}
wereadBridge.handleWithRichEditor("onSelectionChange",param, "", "");
}
RDisplay.getCoords = function(elem) {
var box = elem.getBoundingClientRect();
var body = document.body;
var docEl = document.documentElement;
var scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop;
var scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft;
var clientTop = docEl.clientTop || body.clientTop || 0;
var clientLeft = docEl.clientLeft || body.clientLeft || 0;
var top = box.top + scrollTop - clientTop;
var left = box.left + scrollLeft - clientLeft;
return {
top: Math.round(top),
left: Math.round(left),
height: box.height,
width: box.width
};
}
RDisplay.getLastImgSrc = function(){
var imgs = document.getElementsByTagName('img');
for(var i = imgs.length - 1;i >= 0;i--){
var img = imgs[i];
var src = img.getAttribute('src');
if(src && (src.indexOf('http:') == 0 || src.indexOf('https:') == 0 || src.indexOf('wrcachedwebviewurlkey-http:') == 0 || src.indexOf('wrcachedwebviewurlkey-https:') == 0)){
return src;
}
var data_src = img.getAttribute('data-src');
if(data_src && (data_src.indexOf('http:') == 0 || data_src.indexOf('https:') == 0 || data_src.indexOf('wrcachedwebviewurlkey-http:') == 0 || data_src.indexOf('wrcachedwebviewurlkey-https:') == 0)){
return data_src;
}
}
return '';
}
RDisplay.removeImgWithSrc = function(url) {
var imgs = document.getElementsByTagName('img');
for (var i = imgs.length - 1; i >= 0; i--) {
var img = imgs[i]
var data_src = img.getAttribute('data-src');
var src = img.getAttribute('src');
if(data_src === url || src === url) {
img.parentNode.removeChild(img);
}
}
}
RDisplay.getText = function() {
var tempDom = document.createElement('div');
// 过滤掉插入的书dom里的文字,这里需要过滤掉零宽连字符,不然牛逼的ios里面会出现乱码
tempDom.innerHTML = RDisplay.editor.innerHTML.replace(new RegExp('\u200D', 'g'), '')
.replace('<br>', '')
.replace('<br />', '');
// getText时将插入的书籍和图片转化成 [书籍] [图片]
var books = tempDom.querySelectorAll('.re_bookItem'),
imgs = tempDom.querySelectorAll('.re_img');
if(books.length > 0){
for(var i=0; i<books.length; i++){
var bookDom = books[i],
bookTextNode = document.createTextNode('[书籍]');
bookDom.parentNode.insertBefore(bookTextNode, bookDom);
bookDom.parentNode.removeChild(bookDom);
}
}
if(imgs.length > 0){
for(var i=0; i<imgs.length; i++){
var imgDom = imgs[i],
imgTextNode = document.createTextNode('[图片]');
imgDom.parentNode.insertBefore(imgTextNode, imgDom);
imgDom.parentNode.removeChild(imgDom);
}
}
return tempDom.innerText;
}
RDisplay.getHtmlForEpub = function() {
return RDisplay.editor.innerHTML;
}
RDisplay.getAllBookIds = function() {
var books = RE.editor.querySelectorAll('.re_bookItem'),
idArray = [];
for (var i = 0; i < books.length; i++) {
idArray.push(books[i].getAttribute('data-id'))
}
var bookIds = "";
if (idArray.length > 0) {
bookIds = idArray.join(RE.dataSeparator);
}
return bookIds;
}
document.addEventListener("DOMContentLoaded", function(event) {
wereadBridge.handleWithRichEditor("onDOMContentLoaded", "", "", "");
});
File diff suppressed because it is too large Load Diff
+766
View File
@@ -0,0 +1,766 @@
/*
The MIT License (MIT)
Copyright (c) 2007-2013 Einar Lielmanis and contributors.
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.
JS Beautifier
---------------
*/
function trim(s) {
return s.replace(/^\s+|\s+$/g, '');
}
function ltrim(s) {
return s.replace(/^\s+/g, '');
}
function style_html(html_source, options) {
//Wrapper function to invoke all the necessary constructors and deal with the output.
var multi_parser,
indent_inner_html,
indent_size,
indent_character,
wrap_line_length,
brace_style,
unformatted,
preserve_newlines,
max_preserve_newlines;
options = options || {};
// backwards compatibility to 1.3.4
if ((options.wrap_line_length === undefined || parseInt(options.wrap_line_length, 10) === 0) &&
(options.max_char === undefined || parseInt(options.max_char, 10) === 0)) {
options.wrap_line_length = options.max_char;
}
indent_inner_html = options.indent_inner_html || false;
indent_size = parseInt(options.indent_size || 4, 10);
indent_character = options.indent_char || ' ';
brace_style = options.brace_style || 'collapse';
wrap_line_length = parseInt(options.wrap_line_length, 10) === 0 ? 32786 : parseInt(options.wrap_line_length || 250, 10);
unformatted = options.unformatted || ['a', 'span', 'bdo', 'em', 'strong', 'dfn', 'code', 'samp', 'kbd', 'var', 'cite', 'abbr', 'acronym', 'q', 'sub', 'sup', 'tt', 'i', 'b', 'big', 'small', 'u', 's', 'strike', 'font', 'ins', 'del', 'pre', 'address', 'dt', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
preserve_newlines = options.preserve_newlines || true;
max_preserve_newlines = preserve_newlines ? parseInt(options.max_preserve_newlines || 32786, 10) : 0;
indent_handlebars = options.indent_handlebars || false;
function Parser() {
this.pos = 0; //Parser position
this.token = '';
this.current_mode = 'CONTENT'; //reflects the current Parser mode: TAG/CONTENT
this.tags = { //An object to hold tags, their position, and their parent-tags, initiated with default values
parent: 'parent1',
parentcount: 1,
parent1: ''
};
this.tag_type = '';
this.token_text = this.last_token = this.last_text = this.token_type = '';
this.newlines = 0;
this.indent_content = indent_inner_html;
this.Utils = { //Uilities made available to the various functions
whitespace: "\n\r\t ".split(''),
single_token: 'br,input,link,meta,!doctype,basefont,base,area,hr,wbr,param,img,isindex,?xml,embed,?php,?,?='.split(','), //all the single tags for HTML
extra_liners: 'head,body,/html'.split(','), //for tags that need a line of whitespace before them
in_array: function(what, arr) {
for (var i = 0; i < arr.length; i++) {
if (what === arr[i]) {
return true;
}
}
return false;
}
};
this.traverse_whitespace = function() {
var input_char = '';
input_char = this.input.charAt(this.pos);
if (this.Utils.in_array(input_char, this.Utils.whitespace)) {
this.newlines = 0;
while (this.Utils.in_array(input_char, this.Utils.whitespace)) {
if (preserve_newlines && input_char === '\n' && this.newlines <= max_preserve_newlines) {
this.newlines += 1;
}
this.pos++;
input_char = this.input.charAt(this.pos);
}
return true;
}
return false;
};
this.get_content = function() { //function to capture regular content between tags
var input_char = '',
content = [],
space = false; //if a space is needed
while (this.input.charAt(this.pos) !== '<') {
if (this.pos >= this.input.length) {
return content.length ? content.join('') : ['', 'TK_EOF'];
}
if (this.traverse_whitespace()) {
if (content.length) {
space = true;
}
continue; //don't want to insert unnecessary space
}
if (indent_handlebars) {
// Handlebars parsing is complicated.
// {{#foo}} and {{/foo}} are formatted tags.
// {{something}} should get treated as content, except:
// {{else}} specifically behaves like {{#if}} and {{/if}}
var peek3 = this.input.substr(this.pos, 3);
if (peek3 === '{{#' || peek3 === '{{/') {
// These are tags and not content.
break;
} else if (this.input.substr(this.pos, 2) === '{{') {
if (this.get_tag(true) === '{{else}}') {
break;
}
}
}
input_char = this.input.charAt(this.pos);
this.pos++;
if (space) {
if (this.line_char_count >= this.wrap_line_length) { //insert a line when the wrap_line_length is reached
this.print_newline(false, content);
this.print_indentation(content);
} else {
this.line_char_count++;
content.push(' ');
}
space = false;
}
this.line_char_count++;
content.push(input_char); //letter at-a-time (or string) inserted to an array
}
return content.length ? content.join('') : '';
};
this.get_contents_to = function(name) { //get the full content of a script or style to pass to js_beautify
if (this.pos === this.input.length) {
return ['', 'TK_EOF'];
}
var input_char = '';
var content = '';
var reg_match = new RegExp('</' + name + '\\s*>', 'igm');
reg_match.lastIndex = this.pos;
var reg_array = reg_match.exec(this.input);
var end_script = reg_array ? reg_array.index : this.input.length; //absolute end of script
if (this.pos < end_script) { //get everything in between the script tags
content = this.input.substring(this.pos, end_script);
this.pos = end_script;
}
return content;
};
this.record_tag = function(tag) { //function to record a tag and its parent in this.tags Object
if (this.tags[tag + 'count']) { //check for the existence of this tag type
this.tags[tag + 'count']++;
this.tags[tag + this.tags[tag + 'count']] = this.indent_level; //and record the present indent level
} else { //otherwise initialize this tag type
this.tags[tag + 'count'] = 1;
this.tags[tag + this.tags[tag + 'count']] = this.indent_level; //and record the present indent level
}
this.tags[tag + this.tags[tag + 'count'] + 'parent'] = this.tags.parent; //set the parent (i.e. in the case of a div this.tags.div1parent)
this.tags.parent = tag + this.tags[tag + 'count']; //and make this the current parent (i.e. in the case of a div 'div1')
};
this.retrieve_tag = function(tag) { //function to retrieve the opening tag to the corresponding closer
if (this.tags[tag + 'count']) { //if the openener is not in the Object we ignore it
var temp_parent = this.tags.parent; //check to see if it's a closable tag.
while (temp_parent) { //till we reach '' (the initial value);
if (tag + this.tags[tag + 'count'] === temp_parent) { //if this is it use it
break;
}
temp_parent = this.tags[temp_parent + 'parent']; //otherwise keep on climbing up the DOM Tree
}
if (temp_parent) { //if we caught something
this.indent_level = this.tags[tag + this.tags[tag + 'count']]; //set the indent_level accordingly
this.tags.parent = this.tags[temp_parent + 'parent']; //and set the current parent
}
delete this.tags[tag + this.tags[tag + 'count'] + 'parent']; //delete the closed tags parent reference...
delete this.tags[tag + this.tags[tag + 'count']]; //...and the tag itself
if (this.tags[tag + 'count'] === 1) {
delete this.tags[tag + 'count'];
} else {
this.tags[tag + 'count']--;
}
}
};
this.indent_to_tag = function(tag) {
// Match the indentation level to the last use of this tag, but don't remove it.
if (!this.tags[tag + 'count']) {
return;
}
var temp_parent = this.tags.parent;
while (temp_parent) {
if (tag + this.tags[tag + 'count'] === temp_parent) {
break;
}
temp_parent = this.tags[temp_parent + 'parent'];
}
if (temp_parent) {
this.indent_level = this.tags[tag + this.tags[tag + 'count']];
}
};
this.get_tag = function(peek) { //function to get a full tag and parse its type
var input_char = '',
content = [],
comment = '',
space = false,
tag_start, tag_end,
tag_start_char,
orig_pos = this.pos,
orig_line_char_count = this.line_char_count;
peek = peek !== undefined ? peek : false;
do {
if (this.pos >= this.input.length) {
if (peek) {
this.pos = orig_pos;
this.line_char_count = orig_line_char_count;
}
return content.length ? content.join('') : ['', 'TK_EOF'];
}
input_char = this.input.charAt(this.pos);
this.pos++;
if (this.Utils.in_array(input_char, this.Utils.whitespace)) { //don't want to insert unnecessary space
space = true;
continue;
}
if (input_char === "'" || input_char === '"') {
input_char += this.get_unformatted(input_char);
space = true;
}
if (input_char === '=') { //no space before =
space = false;
}
if (content.length && content[content.length - 1] !== '=' && input_char !== '>' && space) {
//no space after = or before >
if (this.line_char_count >= this.wrap_line_length) {
this.print_newline(false, content);
this.print_indentation(content);
} else {
content.push(' ');
this.line_char_count++;
}
space = false;
}
if (indent_handlebars && tag_start_char === '<') {
// When inside an angle-bracket tag, put spaces around
// handlebars not inside of strings.
if ((input_char + this.input.charAt(this.pos)) === '{{') {
input_char += this.get_unformatted('}}');
if (content.length && content[content.length - 1] !== ' ' && content[content.length - 1] !== '<') {
input_char = ' ' + input_char;
}
space = true;
}
}
if (input_char === '<' && !tag_start_char) {
tag_start = this.pos - 1;
tag_start_char = '<';
}
if (indent_handlebars && !tag_start_char) {
if (content.length >= 2 && content[content.length - 1] === '{' && content[content.length - 2] == '{') {
if (input_char === '#' || input_char === '/') {
tag_start = this.pos - 3;
} else {
tag_start = this.pos - 2;
}
tag_start_char = '{';
}
}
this.line_char_count++;
content.push(input_char); //inserts character at-a-time (or string)
if (content[1] && content[1] === '!') { //if we're in a comment, do something special
// We treat all comments as literals, even more than preformatted tags
// we just look for the appropriate close tag
content = [this.get_comment(tag_start)];
break;
}
if (indent_handlebars && tag_start_char === '{' && content.length > 2 && content[content.length - 2] === '}' && content[content.length - 1] === '}') {
break;
}
} while (input_char !== '>');
var tag_complete = content.join('');
var tag_index;
var tag_offset;
if (tag_complete.indexOf(' ') !== -1) { //if there's whitespace, thats where the tag name ends
tag_index = tag_complete.indexOf(' ');
} else if (tag_complete[0] === '{') {
tag_index = tag_complete.indexOf('}');
} else { //otherwise go with the tag ending
tag_index = tag_complete.indexOf('>');
}
if (tag_complete[0] === '<' || !indent_handlebars) {
tag_offset = 1;
} else {
tag_offset = tag_complete[2] === '#' ? 3 : 2;
}
var tag_check = tag_complete.substring(tag_offset, tag_index).toLowerCase();
if (tag_complete.charAt(tag_complete.length - 2) === '/' ||
this.Utils.in_array(tag_check, this.Utils.single_token)) { //if this tag name is a single tag type (either in the list or has a closing /)
if (!peek) {
this.tag_type = 'SINGLE';
}
} else if (indent_handlebars && tag_complete[0] === '{' && tag_check === 'else') {
if (!peek) {
this.indent_to_tag('if');
this.tag_type = 'HANDLEBARS_ELSE';
this.indent_content = true;
this.traverse_whitespace();
}
} else if (tag_check === 'script') { //for later script handling
if (!peek) {
this.record_tag(tag_check);
this.tag_type = 'SCRIPT';
}
} else if (tag_check === 'style') { //for future style handling (for now it justs uses get_content)
if (!peek) {
this.record_tag(tag_check);
this.tag_type = 'STYLE';
}
} else if (this.is_unformatted(tag_check, unformatted)) { // do not reformat the "unformatted" tags
comment = this.get_unformatted('</' + tag_check + '>', tag_complete); //...delegate to get_unformatted function
content.push(comment);
// Preserve collapsed whitespace either before or after this tag.
if (tag_start > 0 && this.Utils.in_array(this.input.charAt(tag_start - 1), this.Utils.whitespace)) {
content.splice(0, 0, this.input.charAt(tag_start - 1));
}
tag_end = this.pos - 1;
if (this.Utils.in_array(this.input.charAt(tag_end + 1), this.Utils.whitespace)) {
content.push(this.input.charAt(tag_end + 1));
}
this.tag_type = 'SINGLE';
} else if (tag_check.charAt(0) === '!') { //peek for <! comment
// for comments content is already correct.
if (!peek) {
this.tag_type = 'SINGLE';
this.traverse_whitespace();
}
} else if (!peek) {
if (tag_check.charAt(0) === '/') { //this tag is a double tag so check for tag-ending
this.retrieve_tag(tag_check.substring(1)); //remove it and all ancestors
this.tag_type = 'END';
this.traverse_whitespace();
} else { //otherwise it's a start-tag
this.record_tag(tag_check); //push it on the tag stack
if (tag_check.toLowerCase() !== 'html') {
this.indent_content = true;
}
this.tag_type = 'START';
// Allow preserving of newlines after a start tag
this.traverse_whitespace();
}
if (this.Utils.in_array(tag_check, this.Utils.extra_liners)) { //check if this double needs an extra line
this.print_newline(false, this.output);
if (this.output.length && this.output[this.output.length - 2] !== '\n') {
this.print_newline(true, this.output);
}
}
}
if (peek) {
this.pos = orig_pos;
this.line_char_count = orig_line_char_count;
}
return content.join(''); //returns fully formatted tag
};
this.get_comment = function(start_pos) { //function to return comment content in its entirety
// this is will have very poor perf, but will work for now.
var comment = '',
delimiter = '>',
matched = false;
this.pos = start_pos;
input_char = this.input.charAt(this.pos);
this.pos++;
while (this.pos <= this.input.length) {
comment += input_char;
// only need to check for the delimiter if the last chars match
if (comment[comment.length - 1] === delimiter[delimiter.length - 1] &&
comment.indexOf(delimiter) !== -1) {
break;
}
// only need to search for custom delimiter for the first few characters
if (!matched && comment.length < 10) {
if (comment.indexOf('<![if') === 0) { //peek for <![if conditional comment
delimiter = '<![endif]>';
matched = true;
} else if (comment.indexOf('<![cdata[') === 0) { //if it's a <[cdata[ comment...
delimiter = ']]>';
matched = true;
} else if (comment.indexOf('<![') === 0) { // some other ![ comment? ...
delimiter = ']>';
matched = true;
} else if (comment.indexOf('<!--') === 0) { // <!-- comment ...
delimiter = '-->';
matched = true;
}
}
input_char = this.input.charAt(this.pos);
this.pos++;
}
return comment;
};
this.get_unformatted = function(delimiter, orig_tag) { //function to return unformatted content in its entirety
if (orig_tag && orig_tag.toLowerCase().indexOf(delimiter) !== -1) {
return '';
}
var input_char = '';
var content = '';
var min_index = 0;
var space = true;
do {
if (this.pos >= this.input.length) {
return content;
}
input_char = this.input.charAt(this.pos);
this.pos++;
if (this.Utils.in_array(input_char, this.Utils.whitespace)) {
if (!space) {
this.line_char_count--;
continue;
}
if (input_char === '\n' || input_char === '\r') {
content += '\n';
/* Don't change tab indention for unformatted blocks. If using code for html editing, this will greatly affect <pre> tags if they are specified in the 'unformatted array'
for (var i=0; i<this.indent_level; i++) {
content += this.indent_string;
}
space = false; //...and make sure other indentation is erased
*/
this.line_char_count = 0;
continue;
}
}
content += input_char;
this.line_char_count++;
space = true;
if (indent_handlebars && input_char === '{' && content.length && content[content.length - 2] === '{') {
// Handlebars expressions in strings should also be unformatted.
content += this.get_unformatted('}}');
// These expressions are opaque. Ignore delimiters found in them.
min_index = content.length;
}
} while (content.toLowerCase().indexOf(delimiter, min_index) === -1);
return content;
};
this.get_token = function() { //initial handler for token-retrieval
var token;
if (this.last_token === 'TK_TAG_SCRIPT' || this.last_token === 'TK_TAG_STYLE') { //check if we need to format javascript
var type = this.last_token.substr(7);
token = this.get_contents_to(type);
if (typeof token !== 'string') {
return token;
}
return [token, 'TK_' + type];
}
if (this.current_mode === 'CONTENT') {
token = this.get_content();
if (typeof token !== 'string') {
return token;
} else {
return [token, 'TK_CONTENT'];
}
}
if (this.current_mode === 'TAG') {
token = this.get_tag();
if (typeof token !== 'string') {
return token;
} else {
var tag_name_type = 'TK_TAG_' + this.tag_type;
return [token, tag_name_type];
}
}
};
this.get_full_indent = function(level) {
level = this.indent_level + level || 0;
if (level < 1) {
return '';
}
return Array(level + 1).join(this.indent_string);
};
this.is_unformatted = function(tag_check, unformatted) {
//is this an HTML5 block-level link?
if (!this.Utils.in_array(tag_check, unformatted)) {
return false;
}
if (tag_check.toLowerCase() !== 'a' || !this.Utils.in_array('a', unformatted)) {
return true;
}
//at this point we have an tag; is its first child something we want to remain
//unformatted?
var next_tag = this.get_tag(true /* peek. */ );
// test next_tag to see if it is just html tag (no external content)
var tag = (next_tag || "").match(/^\s*<\s*\/?([a-z]*)\s*[^>]*>\s*$/);
// if next_tag comes back but is not an isolated tag, then
// let's treat the 'a' tag as having content
// and respect the unformatted option
if (!tag || this.Utils.in_array(tag, unformatted)) {
return true;
} else {
return false;
}
};
this.printer = function(js_source, indent_character, indent_size, wrap_line_length, brace_style) { //handles input/output and some other printing functions
this.input = js_source || ''; //gets the input for the Parser
this.output = [];
this.indent_character = indent_character;
this.indent_string = '';
this.indent_size = indent_size;
this.brace_style = brace_style;
this.indent_level = 0;
this.wrap_line_length = wrap_line_length;
this.line_char_count = 0; //count to see if wrap_line_length was exceeded
for (var i = 0; i < this.indent_size; i++) {
this.indent_string += this.indent_character;
}
this.print_newline = function(force, arr) {
this.line_char_count = 0;
if (!arr || !arr.length) {
return;
}
if (force || (arr[arr.length - 1] !== '\n')) { //we might want the extra line
arr.push('\n');
}
};
this.print_indentation = function(arr) {
for (var i = 0; i < this.indent_level; i++) {
arr.push(this.indent_string);
this.line_char_count += this.indent_string.length;
}
};
this.print_token = function(text) {
if (text || text !== '') {
if (this.output.length && this.output[this.output.length - 1] === '\n') {
this.print_indentation(this.output);
text = ltrim(text);
}
}
this.print_token_raw(text);
};
this.print_token_raw = function(text) {
if (text && text !== '') {
if (text.length > 1 && text[text.length - 1] === '\n') {
// unformatted tags can grab newlines as their last character
this.output.push(text.slice(0, -1));
this.print_newline(false, this.output);
} else {
this.output.push(text);
}
}
for (var n = 0; n < this.newlines; n++) {
this.print_newline(n > 0, this.output);
}
this.newlines = 0;
};
this.indent = function() {
this.indent_level++;
};
this.unindent = function() {
if (this.indent_level > 0) {
this.indent_level--;
}
};
};
return this;
}
/*_____________________--------------------_____________________*/
multi_parser = new Parser(); //wrapping functions Parser
multi_parser.printer(html_source, indent_character, indent_size, wrap_line_length, brace_style); //initialize starting values
while (true) {
var t = multi_parser.get_token();
multi_parser.token_text = t[0];
multi_parser.token_type = t[1];
if (multi_parser.token_type === 'TK_EOF') {
break;
}
switch (multi_parser.token_type) {
case 'TK_TAG_START':
multi_parser.print_newline(false, multi_parser.output);
multi_parser.print_token(multi_parser.token_text);
if (multi_parser.indent_content) {
multi_parser.indent();
multi_parser.indent_content = false;
}
multi_parser.current_mode = 'CONTENT';
break;
case 'TK_TAG_STYLE':
case 'TK_TAG_SCRIPT':
multi_parser.print_newline(false, multi_parser.output);
multi_parser.print_token(multi_parser.token_text);
multi_parser.current_mode = 'CONTENT';
break;
case 'TK_TAG_END':
//Print new line only if the tag has no content and has child
if (multi_parser.last_token === 'TK_CONTENT' && multi_parser.last_text === '') {
var tag_name = multi_parser.token_text.match(/\w+/)[0];
var tag_extracted_from_last_output = null;
if (multi_parser.output.length) {
tag_extracted_from_last_output = multi_parser.output[multi_parser.output.length - 1].match(/(?:<|{{#)\s*(\w+)/);
}
if (tag_extracted_from_last_output === null ||
tag_extracted_from_last_output[1] !== tag_name) {
multi_parser.print_newline(false, multi_parser.output);
}
}
multi_parser.print_token(multi_parser.token_text);
multi_parser.current_mode = 'CONTENT';
break;
case 'TK_TAG_SINGLE':
// Don't add a newline before elements that should remain unformatted.
var tag_check = multi_parser.token_text.match(/^\s*<([a-z]+)/i);
if (!tag_check || !multi_parser.Utils.in_array(tag_check[1], unformatted)) {
multi_parser.print_newline(false, multi_parser.output);
}
multi_parser.print_token(multi_parser.token_text);
multi_parser.current_mode = 'CONTENT';
break;
case 'TK_TAG_HANDLEBARS_ELSE':
multi_parser.print_token(multi_parser.token_text);
if (multi_parser.indent_content) {
multi_parser.indent();
multi_parser.indent_content = false;
}
multi_parser.current_mode = 'CONTENT';
break;
case 'TK_CONTENT':
multi_parser.print_token(multi_parser.token_text);
multi_parser.current_mode = 'TAG';
break;
case 'TK_STYLE':
case 'TK_SCRIPT':
if (multi_parser.token_text !== '') {
multi_parser.print_newline(false, multi_parser.output);
var text = multi_parser.token_text,
_beautifier,
script_indent_level = 1;
if (multi_parser.token_type === 'TK_SCRIPT') {
_beautifier = typeof js_beautify === 'function' && js_beautify;
} else if (multi_parser.token_type === 'TK_STYLE') {
_beautifier = typeof css_beautify === 'function' && css_beautify;
}
if (options.indent_scripts === "keep") {
script_indent_level = 0;
} else if (options.indent_scripts === "separate") {
script_indent_level = -multi_parser.indent_level;
}
var indentation = multi_parser.get_full_indent(script_indent_level);
if (_beautifier) {
// call the Beautifier if avaliable
text = _beautifier(text.replace(/^\s*/, indentation), options);
} else {
// simply indent the string otherwise
var white = text.match(/^\s*/)[0];
var _level = white.match(/[^\n\r]*$/)[0].split(multi_parser.indent_string).length - 1;
var reindent = multi_parser.get_full_indent(script_indent_level - _level);
text = text.replace(/^\s*/, indentation)
.replace(/\r\n|\r|\n/g, '\n' + reindent)
.replace(/\s+$/, '');
}
if (text) {
multi_parser.print_token_raw(indentation + trim(text));
multi_parser.print_newline(false, multi_parser.output);
}
}
multi_parser.current_mode = 'TAG';
break;
}
multi_parser.last_token = multi_parser.token_type;
multi_parser.last_text = multi_parser.token_text;
}
return multi_parser.output.join('');
}
+25
View File
@@ -0,0 +1,25 @@
window.cssInjector = {
inject: function(json) {
Object.keys(json).forEach(function(identifier) {
var style = document.getElementById(identifier)
if (!style) {
var style = document.createElement('style');
style.type = 'text/css';
style.setAttribute('id', identifier);
}
style.innerHTML = json[identifier]
document.getElementsByTagName('head')[0].appendChild(style);
});
},
remove: function(identifier) {
var style = document.getElementById(identifier)
if (style) {
style.parentNode.removeChild(style)
}
},
}
File diff suppressed because one or more lines are too long
+349
View File
@@ -0,0 +1,349 @@
var WRAUDIO_TEMPLATE = '<div id="wraudioid_xxx" class="audio_container">\
<div id="wraudio_button" class="audio_button"></div>\
<div class="audio_content">\
<p id="wraudio_title" class="audio_title"></p>\
<div class="audio_progress">\
<div class="audio_progress_bottom"></div>\
<div id="wraudio_progress_bar" class="audio_progress_top" style="width: 0;"></div>\
<div id="wraudio_progress_button" class="audio_progress_current" style="left: 0;"></div>\
</div>\
<div class="audio_time">\
<span id="wraudio_time_elapsed" class="audio_time_left"></span>\
<span id="wraudio_time_remains" class="audio_time_right"></span>\
</div>\
</div>\
</div>';
var _Utils = function () {
this.findChildById = function (element, childID) {
var retElement = null;
var lstChildren = Utils.getAllDescendant(element);
for (var i = 0; i < lstChildren.length; i++) {
if (lstChildren[i].id == childID) {
retElement = lstChildren[i];
break;
}
}
return retElement;
}
this.getAllDescendant = function (element, lstChildrenNodes) {
lstChildrenNodes = lstChildrenNodes ? lstChildrenNodes : [];
var lstChildren = element.childNodes;
for (var i = 0; i < lstChildren.length; i++) {
if (lstChildren[i].nodeType == 1) // 1 is 'ELEMENT_NODE'
{
lstChildrenNodes.push(lstChildren[i]);
lstChildrenNodes = Utils.getAllDescendant(lstChildren[i], lstChildrenNodes);
}
}
return lstChildrenNodes;
}
this._formatTime = function (time, quotationStyle) {
if (time < 0) {
return '- : -'
}
var hour = parseInt(time / 3600);
var min = parseInt((time - hour * 3600) / 60);
var sec = parseInt((time - hour * 3600 - min * 60));
var timeText = '';
if (hour > 0) {
timeText += hour + ':';
}
if (quotationStyle) {
if (min > 0) {
timeText += min + "'";
}
} else {
if (min < 10) {
timeText += '0' + min + ':';
} else {
timeText += min + ':';
}
}
if (quotationStyle) {
timeText += sec + "''";
} else {
if (sec < 10) {
timeText += '0' + sec;
} else {
timeText += sec;
}
}
return timeText;
}
this.formatTime = function (time, remains) {
var intTime = parseInt(time);
var desc = this._formatTime(intTime);
if (remains && intTime > 0) {
return '-' + desc;
}
return desc;
}
}
var Utils = new _Utils;
var player = {audio: null, current: null};
var videoIframes = [];
function getContentRoot() {
return document.getElementById('editor');
}
function createWRAudio(title, src, duration) {
var tmp = document.createElement('div');
tmp.innerHTML = WRAUDIO_TEMPLATE;
var wrAudio = tmp.firstChild;
wrAudio.id = 'wraudioid_' + Date.now() + '_' + Math.floor((Math.random() * 1000) + 1);
wrAudio.src = src;
wrAudio.duration = duration;
var titleEl = Utils.findChildById(wrAudio, 'wraudio_title', true);
titleEl.innerHTML = title || '';
var button = Utils.findChildById(wrAudio, 'wraudio_button', true);
button.addEventListener('click', function() {
clickPlayButton(wrAudio);
}, false);
var elapsedEl = Utils.findChildById(wrAudio, 'wraudio_time_elapsed', true);
var remainsEl = Utils.findChildById(wrAudio, 'wraudio_time_remains', true);
elapsedEl.innerHTML = Utils.formatTime(0);
remainsEl.innerHTML = Utils.formatTime(duration, true);
return wrAudio;
}
function resetWRAudio(wrAudio) {
if (!wrAudio) {
return;
}
var button = Utils.findChildById(wrAudio, 'wraudio_button', true);
button.classList.remove('active');
var elapsedEl = Utils.findChildById(wrAudio, 'wraudio_time_elapsed', true);
var remainsEl = Utils.findChildById(wrAudio, 'wraudio_time_remains', true);
elapsedEl.innerHTML = Utils.formatTime(0);
remainsEl.innerHTML = Utils.formatTime(wrAudio.duration, true);
var progressBar = Utils.findChildById(wrAudio, 'wraudio_progress_bar', true);
var progressButton = Utils.findChildById(wrAudio, 'wraudio_progress_button', true);
progressBar.style.width = '0';
progressButton.style.left = '0';
}
function clickPlayButton(wrAudio) {
if (!wrAudio) {
return;
}
var src = wrAudio.src;
if (!player.current) {
player.current = wrAudio;
player.audio.src = src;
player.audio.play();
} else if (player.current.id != wrAudio.id) {
var last = player.current;
player.current = wrAudio;
resetWRAudio(last);
player.audio.src = src;
player.audio.play();
} else {
if (!player.audio.paused) {
player.audio.pause();
} else {
player.audio.play();
}
}
}
function pauseCurrentAudio() {
if (player.audio) {
player.audio.pause();
}
}
function pauseCurrentVideo() {
let newVideoIframes = [];
for (var i = 0; i < videoIframes.length; i++) {
var oldFrame = videoIframes[i];
oldFrame.src = oldFrame.src;
}
}
function pauseMedia() {
pauseCurrentAudio();
pauseCurrentVideo();
}
function onPlay() {
var button = Utils.findChildById(player.current, 'wraudio_button', true);
button.classList.add('active');
}
function onPause() {
var button = Utils.findChildById(player.current, 'wraudio_button', true);
button.classList.remove('active');
}
function onEnded() {
var button = Utils.findChildById(player.current, 'wraudio_button', true);
button.classList.remove('active');
}
function onDurationchange() {
player.current.duration = player.audio.duration;
}
function onVideoPlaying() {
pauseCurrentAudio();
}
function onTimeupdate() {
var wrAudio = player.current;
var elapsedEl = Utils.findChildById(wrAudio, 'wraudio_time_elapsed', true);
var remainsEl = Utils.findChildById(wrAudio, 'wraudio_time_remains', true);
elapsedEl.innerHTML = Utils.formatTime(player.audio.currentTime);
remainsEl.innerHTML = Utils.formatTime(player.audio.duration - player.audio.currentTime, true);
var progressBar = Utils.findChildById(wrAudio, 'wraudio_progress_bar', true);
var progressButton = Utils.findChildById(wrAudio, 'wraudio_progress_button', true);
var progress = 0;
if (player.audio.duration > 0) {
progress = parseInt(player.audio.currentTime / player.audio.duration * 100);
}
progressBar.style.width = progress + '%';
progressButton.style.left = progress + '%';
}
function adjustMPContent() {
var imgs = document.querySelectorAll('img[data-src]');
for (var i = 0; i < imgs.length ; i++) {
var img = imgs[i];
img.src = img.getAttribute('data-src');
img.style = 'width: auto !important; height: auto !important; visibility: visible !important;max-width:100% !important';
}
var links = document.querySelectorAll('a[href^="https://open.weixin.qq.com/connect/oauth2/authorize"],a[href^="http://open.weixin.qq.com/connect/oauth2/authorize"]');
for (var i = 0; i < links.length ; i++) {
var link = links[i]
params = link.href.split('&');
for (var j = 0;j<params.length;j++) {
param = params[j];
if (param.startsWith('redirect_uri=')) {
link.href = decodeURIComponent(param.split('redirect_uri=')[1]);
}
}
}
var iframes = document.querySelectorAll('iframe[data-src]');
for (var i = 0; i < iframes.length ; i++) {
(function (index) {
var iframe = iframes[index];
var iframeWidth = document.body.offsetWidth;
var iframeHeight = iframeWidth * 0.75;
var src = iframe.getAttribute('data-src');
src = src.replace(/preview.html/, 'player.html');
src = src.replace(/&(width=.*height=.*)&/, '&width=' + iframeWidth + '&height=' + iframeHeight + '&');
iframe.src = src;
// 是否视频
/*
if ((' ' + iframe.className + ' ').indexOf(' video_iframe ') > -1) {
iframe.onload = function () {
var styleNode = document.createElement('style');
styleNode.type = 'text/css';
styleNode.appendChild(document.createTextNode(''));
iframe.contentDocument.body.appendChild(styleNode);
}
}
*/
// if (iframe.classList.contains("video_iframe")) {
// iframe.onload = function() {
// if (iframe.contentWindow && iframe.contentWindow.document) {
// var videoEl = iframe.contentWindow.document.getElementById("tenvideo_video_player_0");
// if (videoEl) {
// videoEl.onplaying = function() {
// onVideoPlaying();
// };
// }
// }
// };
// }
videoIframes.push(iframe);
})(i);
}
var votes = document.querySelectorAll(".vote_area");
for (var i = 0; i < votes.length; i++) {
var vote = votes[i];
vote.parentElement.removeChild(vote);
}
var miniPrograms = document.querySelectorAll("mp-miniprogram");
for (var i = 0; i < miniPrograms.length; i++) {
var miniProgram = miniPrograms[i];
var parentElement = miniProgram.parentElement;
if (parentElement && parentElement.tagName && parentElement.tagName.toLowerCase() === 'p') {
if (parentElement.parentElement) {
parentElement.parentElement.removeChild(parentElement);
}
}
}
var miniAppLinks = document.querySelectorAll(".weapp_text_link, .weapp_image_link");
for (var i = 0; i < miniAppLinks.length; i++) {
var miniAppLink = miniAppLinks[i];
miniAppLink.parentElement.removeChild(miniAppLink);
}
var needPlayer = false;
var mpvoices = document.querySelectorAll('mpvoice[voice_encode_fileid]');
for (var i = 0; i < mpvoices.length ; i++) {
var mpvoice = mpvoices[i];
var title = mpvoice.getAttribute('name');
var src = 'https://res.wx.qq.com/voice/getvoice?mediaid=' + mpvoice.getAttribute('voice_encode_fileid');
var duration = mpvoice.hasAttribute('play_length') ? mpvoice.getAttribute('play_length') / 1000 : -1;
var wrAudio = createWRAudio(title, src, duration);
mpvoice.parentElement.appendChild(wrAudio);
needPlayer = true;
}
var qqmusics = document.querySelectorAll('qqmusic[audiourl]');
for (var i = 0; i < qqmusics.length ; i++) {
var qqmusic = qqmusics[i];
var title = qqmusic.getAttribute('music_name');
var src = qqmusic.getAttribute('audiourl');
var duration = qqmusic.hasAttribute('play_length') ? qqmusic.getAttribute('play_length') / 1000 : -1;
var wrAudio = createWRAudio(title, src, duration);
qqmusic.parentElement.appendChild(wrAudio);
needPlayer = true;
}
if (needPlayer) {
var audio = document.createElement('audio');
audio.autoplay = false;
audio.controls = true;
audio.style.display = "none";
audio.addEventListener("play", onPlay, false);
audio.addEventListener("pause", onPause, false);
audio.addEventListener("ended", onEnded, false);
audio.addEventListener("durationchange", onDurationchange, false);
audio.addEventListener("timeupdate", onTimeupdate, false);
getContentRoot().appendChild(audio);
player.audio = audio;
}
}
document.addEventListener('DOMContentLoaded', function() {
adjustMPContent();
}, false);
+74
View File
@@ -0,0 +1,74 @@
(function() {
if (window.wrClickHandler) {
window.removeEventListener('click', window.wrClickHandler, true);
}
window.wrClickHandler = function(e) {
console.log('Hook: Click event intercepted', {
target: e.target,
tagName: e.target.tagName,
id: e.target.id,
className: e.target.className
});
var eventInfo = null;
var currentElement = e.target;
while (currentElement && currentElement !== document) {
var tagName = currentElement.tagName || '';
var currentId = currentElement.id || '';
if (currentId === 'js_name') {
// 公众号顶部的作者
eventInfo = {
contentType: 'profile',
bizId: ''
};
console.log('Hook: MP author click intercepted');
break;
} else if (currentId === 'copyright_info') {
// 公众号顶部的文章来源
var bizId = typeof source_encode_biz !== 'undefined' ? source_encode_biz : '';
if (bizId) {
eventInfo = {
contentType: 'profile',
bizId: bizId
};
console.log('Hook: Copyright profile click intercepted, bizId:', bizId);
}
break;
} else if (tagName === 'MP-COMMON-PROFILE') {
// 公众号
var bizId = currentElement.dataset && currentElement.dataset['id'] ? currentElement.dataset['id'] : '';
if (bizId) {
eventInfo = {
contentType: 'profile',
bizId: bizId
};
console.log('Hook: Profile click intercepted, bizId:', bizId);
}
break;
} else if (currentElement.href && currentElement.href.indexOf('mp.weixin.qq.com') !== -1) {
// 公众号文章链接
eventInfo = {
contentType: 'link',
url: currentElement.href
};
console.log('Hook: Link click intercepted, URL:', currentElement.href);
break;
}
currentElement = currentElement.parentElement;
}
// 如果有 eventInfo,则阻断事件冒泡,拦截公众号原始的跳转弹窗
if (eventInfo) {
e.preventDefault();
e.stopPropagation();
}
if (eventInfo && wereadBridge) {
wereadBridge.execMPReaderMethod('MPReader', {
cmd: 'clickMpInterceptor',
data: eventInfo
});
}
};
window.addEventListener('click', window.wrClickHandler, true);
console.log('Hook: Click interceptor setup completed');
})();
+7
View File
@@ -0,0 +1,7 @@
class WRAboutViewController {
viewDidLoad() {
self.origin__viewDidLoad();
// 把原来的 rights 和 reserved 开头改成大写
self.copyrightLabel().setText('腾讯公司 版权所有\nCopyright \u00A9 1998 - 2022 Tencent. All Rights Reserved.');
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,711 @@
/**
* Highlighter module for Rangy, a cross-browser JavaScript range and selection library
* https://github.com/timdown/rangy
*
* Depends on Rangy core, ClassApplier and optionally TextRange modules.
*
* Copyright 2015, Tim Down
* Licensed under the MIT license.
* Version: 1.3.0
* Build date: 10 May 2015
*/
(function (factory, root) {
if (typeof define == "function" && define.amd) {
// AMD. Register as an anonymous module with a dependency on Rangy.
define(["./rangy-core"], factory);
} else if (typeof module != "undefined" && typeof exports == "object") {
// Node/CommonJS style
module.exports = factory(require("rangy"));
} else {
// No AMD or CommonJS support so we use the rangy property of root (probably the global variable)
factory(root.rangy);
}
})(function (rangy) {
rangy.createModule("Highlighter", ["ClassApplier"], function (api, module) {
var dom = api.dom;
var contains = dom.arrayContains;
var getBody = dom.getBody;
var createOptions = api.util.createOptions;
var forEach = api.util.forEach;
var nextHighlightId = 1;
// Puts highlights in order, last in document first.
function compareHighlights(h1, h2) {
return h1.characterRange.start - h2.characterRange.start;
}
function getContainerElement(doc, id) {
return id ? doc.getElementById(id) : getBody(doc);
}
/*----------------------------------------------------------------------------------------------------------------*/
var highlighterTypes = {};
function HighlighterType(type, converterCreator) {
this.type = type;
this.converterCreator = converterCreator;
}
HighlighterType.prototype.create = function () {
var converter = this.converterCreator();
converter.type = this.type;
return converter;
};
function registerHighlighterType(type, converterCreator) {
highlighterTypes[type] = new HighlighterType(type, converterCreator);
}
function getConverter(type) {
var highlighterType = highlighterTypes[type];
if (highlighterType instanceof HighlighterType) {
return highlighterType.create();
} else {
throw new Error("Highlighter type '" + type + "' is not valid");
}
}
api.registerHighlighterType = registerHighlighterType;
/*----------------------------------------------------------------------------------------------------------------*/
function CharacterRange(start, end) {
this.start = start;
this.end = end;
}
CharacterRange.prototype = {
intersects: function (charRange) {
return this.start < charRange.end && this.end > charRange.start;
},
isContiguousWith: function (charRange) {
return this.start == charRange.end || this.end == charRange.start;
},
isEqual: function (charRange) {
return this.start === charRange.start && this.end === charRange.end
},
isContain: function (charRange, exclusive) {
return exclusive ? this.start < charRange.start && this.end > charRange.end
: this.start <= charRange.start && this.end >= charRange.end
},
union: function (charRange) {
return new CharacterRange(Math.min(this.start, charRange.start), Math.max(this.end, charRange.end));
},
intersection: function (charRange) {
return new CharacterRange(Math.max(this.start, charRange.start), Math.min(this.end, charRange.end));
},
getComplements: function (charRange) {
var ranges = [];
if (this.start >= charRange.start) {
if (this.end <= charRange.end) {
return [];
}
ranges.push(new CharacterRange(charRange.end, this.end));
} else {
ranges.push(new CharacterRange(this.start, Math.min(this.end, charRange.start)));
if (this.end > charRange.end) {
ranges.push(new CharacterRange(charRange.end, this.end));
}
}
return ranges;
},
toString: function () {
return "[CharacterRange(" + this.start + ", " + this.end + ")]";
}
};
CharacterRange.fromCharacterRange = function (charRange) {
return new CharacterRange(charRange.start, charRange.end);
};
/*----------------------------------------------------------------------------------------------------------------*/
var textContentConverter = {
rangeToCharacterRange: function (range, containerNode) {
var bookmark = range.getBookmark(containerNode);
return new CharacterRange(bookmark.start, bookmark.end);
},
characterRangeToRange: function (doc, characterRange, containerNode) {
var range = api.createRange(doc);
range.moveToBookmark({
start: characterRange.start,
end: characterRange.end,
containerNode: containerNode
});
return range;
},
serializeSelection: function (selection, containerNode) {
var ranges = selection.getAllRanges(), rangeCount = ranges.length;
var rangeInfos = [];
var backward = rangeCount == 1 && selection.isBackward();
for (var i = 0, len = ranges.length; i < len; ++i) {
rangeInfos[i] = {
characterRange: this.rangeToCharacterRange(ranges[i], containerNode),
backward: backward
};
}
return rangeInfos;
},
restoreSelection: function (selection, savedSelection, containerNode) {
selection.removeAllRanges();
var doc = selection.win.document;
for (var i = 0, len = savedSelection.length, range, rangeInfo, characterRange; i < len; ++i) {
rangeInfo = savedSelection[i];
characterRange = rangeInfo.characterRange;
range = this.characterRangeToRange(doc, rangeInfo.characterRange, containerNode);
selection.addRange(range, rangeInfo.backward);
}
}
};
registerHighlighterType("textContent", function () {
return textContentConverter;
});
/*----------------------------------------------------------------------------------------------------------------*/
// Lazily load the TextRange-based converter so that the dependency is only checked when required.
registerHighlighterType("TextRange", (function () {
var converter;
return function () {
if (!converter) {
// Test that textRangeModule exists and is supported
var textRangeModule = api.modules.TextRange;
if (!textRangeModule) {
throw new Error("TextRange module is missing.");
} else if (!textRangeModule.supported) {
throw new Error("TextRange module is present but not supported.");
}
converter = {
rangeToCharacterRange: function (range, containerNode) {
return CharacterRange.fromCharacterRange(range.toCharacterRange(containerNode));
},
characterRangeToRange: function (doc, characterRange, containerNode) {
var range = api.createRange(doc);
range.selectCharacters(containerNode, characterRange.start, characterRange.end);
return range;
},
serializeSelection: function (selection, containerNode) {
return selection.saveCharacterRanges(containerNode);
},
restoreSelection: function (selection, savedSelection, containerNode) {
selection.restoreCharacterRanges(containerNode, savedSelection);
}
};
}
return converter;
};
})());
/*----------------------------------------------------------------------------------------------------------------*/
function Highlight(doc, characterRange, classApplier, converter, id, containerElementId) {
if (id) {
this.id = id;
nextHighlightId = Math.max(nextHighlightId, id + 1);
} else {
this.id = nextHighlightId++;
}
this.characterRange = characterRange;
this.doc = doc;
this.classApplier = classApplier;
this.converter = converter;
this.containerElementId = containerElementId || null;
this.applied = false;
this.innerText = this.getText().replace(/\s+/g, '');
}
Highlight.prototype = {
getContainerElement: function () {
return getContainerElement(this.doc, this.containerElementId);
},
getRange: function () {
return this.converter.characterRangeToRange(this.doc, this.characterRange, this.getContainerElement());
},
fromRange: function (range) {
this.characterRange = this.converter.rangeToCharacterRange(range, this.getContainerElement());
},
getText: function () {
return this.getRange().toString();
},
containsElement: function (el) {
return this.getRange().containsNodeContents(el.firstChild);
},
unapply: function () {
this.classApplier.undoToRange(this.getRange());
this.applied = false;
},
apply: function () {
this.classApplier.applyToRange(this.getRange());
this.applied = true;
},
getHighlightElements: function () {
return this.classApplier.getElementsWithClassIntersectingRange(this.getRange());
},
toString: function () {
return "[Highlight(ID: " + this.id + ", class: " + this.classApplier.className + ", character range: " +
this.characterRange.start + " - " + this.characterRange.end + ")]";
}
};
/*----------------------------------------------------------------------------------------------------------------*/
function Highlighter(doc, type) {
type = type || "textContent";
this.doc = doc || document;
this.classAppliers = {};
this.highlights = [];
this.converter = getConverter(type);
}
Highlighter.prototype = {
addClassApplier: function (classApplier) {
this.classAppliers[classApplier.className] = classApplier;
},
getMergedCharacterRange: function (characterRange, classNames, isSure) {
var highlights = this.highlights, i, len = highlights.length, hRange
var useHighlights =[]
for (i = 0; i < len; i++) {
if (classNames.indexOf(highlights[i].classApplier.className) >= 0) {
useHighlights.push(highlights[i])
}
}
len = useHighlights.length
if(!len){
return null
}
if(!isSure){
for (i = 0; i < len; i++) {
hRange = useHighlights[i].characterRange
if(hRange.isContain(characterRange)){
characterRange = hRange
isSure = true
break
}
}
if(!isSure){
return null
}
}
for (i = 0; i < len; i++) {
hRange = useHighlights[i].characterRange
if(hRange.intersects(characterRange) || hRange.isContiguousWith(characterRange)){
characterRange = hRange.union(characterRange)
}
}
return characterRange;
},
createCharacterRange: function (start, end) {
return new CharacterRange(start, end)
},
getHighlightForElement: function (el) {
var highlights = this.highlights;
var possibleMatches = [];
var possibleMatchesContainReview = false;
for (var i = 0, len = highlights.length; i < len; ++i) {
if (highlights[i].containsElement(el)) {
possibleMatches.push(highlights[i]);
if (highlights[i].classApplier.className === 'review') {
possibleMatchesContainReview = true;
}
}
}
if (!possibleMatches.length) {
return null;
}
if (!possibleMatchesContainReview) {
return possibleMatches[0];
}
for (var i = 0, len = possibleMatches.length; i < len; ++i) {
if (possibleMatches[i].classApplier.className === 'review') {
return possibleMatches[i];
}
}
return null;
},
removeHighlights: function (highlights) {
for (var i = 0, len = this.highlights.length, highlight; i < len; ++i) {
highlight = this.highlights[i];
if (contains(highlights, highlight)) {
highlight.unapply();
this.highlights.splice(i--, 1);
}
}
},
removeAllHighlights: function () {
this.removeHighlights(this.highlights);
},
getIntersectingHighlights: function (ranges) {
// Test each range against each of the highlighted ranges to see whether they overlap
var intersectingHighlights = [], highlights = this.highlights;
forEach(ranges, function (range) {
//var selCharRange = converter.rangeToCharacterRange(range);
forEach(highlights, function (highlight) {
if (range.intersectsRange(highlight.getRange()) && !contains(intersectingHighlights, highlight)) {
intersectingHighlights.push(highlight);
}
});
});
return intersectingHighlights;
},
highghtWhichContainSelection: function (classNames, selection) {
selection = selection || api.getSelection(this.doc)
var highlights = this.highlights, ranges = selection.getAllRanges()
var highlight, range
loop: for(var i =0; i < highlights.length; i++){
highlight = highlights[i]
if(classNames.indexOf(highlight.classApplier.className) < 0){
continue
}
for(var j=0; j< ranges.length; j++){
range = ranges[j]
if(!highlight.getRange().containsRange(range)){
continue loop
}
}
return highlight
}
return null
},
highlightCharacterRanges: function (className, charRanges, options) {
var i, len, j;
var highlights = this.highlights;
var converter = this.converter;
var doc = this.doc;
var highlightsToRemove = [];
var classApplier = className ? this.classAppliers[className] : null;
options = createOptions(options, {
containerElementId: null,
exclusive: true
});
var containerElementId = options.containerElementId;
var exclusive = options.exclusive;
var containerElement, containerElementRange, containerElementCharRange;
if (containerElementId) {
containerElement = this.doc.getElementById(containerElementId);
if (containerElement) {
containerElementRange = api.createRange(this.doc);
containerElementRange.selectNodeContents(containerElement);
containerElementCharRange = new CharacterRange(0, containerElementRange.toString().length);
}
}
var charRange, highlightCharRange, removeHighlight, isSameClassApplier, highlightsToKeep,
splitHighlight;
for (i = 0, len = charRanges.length; i < len; ++i) {
charRange = charRanges[i];
highlightsToKeep = [];
// Restrict character range to container element, if it exists
if (containerElementCharRange) {
charRange = charRange.intersection(containerElementCharRange);
}
// Ignore empty ranges
if (charRange.start === charRange.end) {
continue;
}
// Check for intersection with existing highlights. For each intersection, create a new highlight
// which is the union of the highlight range and the selected range
for (j = 0; j < highlights.length; ++j) {
removeHighlight = false;
if (containerElementId === highlights[j].containerElementId) {
highlightCharRange = highlights[j].characterRange;
isSameClassApplier = (classApplier === highlights[j].classApplier);
splitHighlight = !isSameClassApplier && exclusive;
// Replace the existing highlight if it needs to be:
// 1. merged (isSameClassApplier)
// 2. partially or entirely erased (className === null)
// 3. partially or entirely replaced (isSameClassApplier == false && exclusive == true)
if ((highlightCharRange.intersects(charRange) || highlightCharRange.isContiguousWith(charRange)) &&
(isSameClassApplier || splitHighlight)) {
// Remove existing highlights, keeping the unselected parts
if (splitHighlight) {
forEach(highlightCharRange.getComplements(charRange), function (rangeToAdd) {
highlightsToKeep.push(new Highlight(doc, rangeToAdd, highlights[j].classApplier, converter, null, containerElementId));
});
}
removeHighlight = true;
if (isSameClassApplier) {
charRange = highlightCharRange.union(charRange);
}
}
}
if (removeHighlight) {
highlightsToRemove.push(highlights[j]);
highlights[j] = new Highlight(doc, highlightCharRange.union(charRange), classApplier, converter, null, containerElementId);
}else{
highlightsToKeep.push(highlights[j]);
}
}
// Add new range
if (classApplier) {
highlightsToKeep.push(new Highlight(doc, charRange, classApplier, converter, null, containerElementId));
}
this.highlights = highlights = highlightsToKeep;
}
// Remove the old highlights
forEach(highlightsToRemove, function (highlightToRemove) {
highlightToRemove.unapply();
});
// Apply new highlights
var newHighlights = [];
forEach(highlights, function (highlight) {
if (!highlight.applied) {
highlight.apply();
// 只返回 class 相同的 highlight, 过滤掉因 split 而产生的新的 highlight
if(highlight.classApplier === classApplier){
newHighlights.push(highlight);
}
}
});
return newHighlights;
},
highlightRanges: function (className, ranges, options) {
var selCharRanges = [];
var converter = this.converter;
options = createOptions(options, {
containerElement: null,
exclusive: true
});
var containerElement = options.containerElement;
var containerElementId = containerElement ? containerElement.id : null;
var containerElementRange;
if (containerElement) {
containerElementRange = api.createRange(containerElement);
containerElementRange.selectNodeContents(containerElement);
}
forEach(ranges, function (range) {
var scopedRange = containerElement ? containerElementRange.intersection(range) : range;
selCharRanges.push(converter.rangeToCharacterRange(scopedRange, containerElement || getBody(range.getDocument())));
});
return this.highlightCharacterRanges(className, selCharRanges, {
containerElementId: containerElementId,
exclusive: options.exclusive
});
},
highlightSelection: function (className, options) {
var converter = this.converter;
var classApplier = className ? this.classAppliers[className] : false;
options = createOptions(options, {
containerElementId: null,
selection: api.getSelection(this.doc),
exclusive: true
});
var containerElementId = options.containerElementId;
var exclusive = options.exclusive;
var selection = options.selection;
var doc = selection.win.document;
var containerElement = getContainerElement(doc, containerElementId);
if (!classApplier && className !== false) {
throw new Error("No class applier found for class '" + className + "'");
}
// Store the existing selection as character ranges
var serializedSelection = converter.serializeSelection(selection, containerElement);
// Create an array of selected character ranges
var selCharRanges = [];
forEach(serializedSelection, function (rangeInfo) {
selCharRanges.push(CharacterRange.fromCharacterRange(rangeInfo.characterRange));
});
var newHighlights = this.highlightCharacterRanges(className, selCharRanges, {
containerElementId: containerElementId,
exclusive: exclusive
});
// Restore selection
converter.restoreSelection(selection, serializedSelection, containerElement);
return newHighlights;
},
unhighlightSelection: function (selection) {
selection = selection || api.getSelection(this.doc);
var intersectingHighlights = this.getIntersectingHighlights(selection.getAllRanges());
this.removeHighlights(intersectingHighlights);
selection.removeAllRanges();
return intersectingHighlights;
},
getHighlightsInSelection: function (selection) {
selection = selection || api.getSelection(this.doc);
return this.getIntersectingHighlights(selection.getAllRanges());
},
selectionOverlapsHighlight: function (selection) {
return this.getHighlightsInSelection(selection).length > 0;
},
serialize: function (options) {
var highlighter = this;
var highlights = highlighter.highlights;
var serializedType, serializedHighlights, convertType, serializationConverter;
highlights.sort(compareHighlights);
options = createOptions(options, {
serializeHighlightText: false,
type: highlighter.converter.type
});
serializedType = options.type;
convertType = (serializedType != highlighter.converter.type);
if (convertType) {
serializationConverter = getConverter(serializedType);
}
serializedHighlights = ["type:" + serializedType];
forEach(highlights, function (highlight) {
var characterRange = highlight.characterRange;
var containerElement;
// Convert to the current Highlighter's type, if different from the serialization type
if (convertType) {
containerElement = highlight.getContainerElement();
characterRange = serializationConverter.rangeToCharacterRange(
highlighter.converter.characterRangeToRange(highlighter.doc, characterRange, containerElement),
containerElement
);
}
var parts = [
characterRange.start,
characterRange.end,
highlight.id,
highlight.classApplier.className,
highlight.containerElementId
];
if (options.serializeHighlightText) {
parts.push(highlight.getText());
}
serializedHighlights.push(parts.join("$"));
});
return serializedHighlights.join("|");
},
deserialize: function (serialized) {
var serializedHighlights = serialized.split("|");
var highlights = [];
var firstHighlight = serializedHighlights[0];
var regexResult;
var serializationType, serializationConverter, convertType = false;
if (firstHighlight && (regexResult = /^type:(\w+)$/.exec(firstHighlight))) {
serializationType = regexResult[1];
if (serializationType != this.converter.type) {
serializationConverter = getConverter(serializationType);
convertType = true;
}
serializedHighlights.shift();
} else {
throw new Error("Serialized highlights are invalid.");
}
var classApplier, highlight, characterRange, containerElementId, containerElement;
for (var i = serializedHighlights.length, parts; i-- > 0;) {
parts = serializedHighlights[i].split("$");
characterRange = new CharacterRange(+parts[0], +parts[1]);
containerElementId = parts[4] || null;
// Convert to the current Highlighter's type, if different from the serialization type
if (convertType) {
containerElement = getContainerElement(this.doc, containerElementId);
characterRange = this.converter.rangeToCharacterRange(
serializationConverter.characterRangeToRange(this.doc, characterRange, containerElement),
containerElement
);
}
classApplier = this.classAppliers[parts[3]];
if (!classApplier) {
throw new Error("No class applier found for class '" + parts[3] + "'");
}
highlight = new Highlight(this.doc, characterRange, classApplier, this.converter, parseInt(parts[2]), containerElementId);
highlight.apply()
highlights.push(highlight);
}
this.highlights = highlights;
}
};
api.Highlighter = Highlighter;
api.createHighlighter = function (doc, rangeCharacterOffsetConverterType) {
return new Highlighter(doc, rangeCharacterOffsetConverterType);
};
});
return rangy;
}, this);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
window.newsInjector = {
removeUnnecessary: function() {
document.getElementsByTagName('body')[0].style = 'background-color:red'
var divs = document.getElementsByTagName('div')
for (var i = divs.length - 1; i >= 0; i--) {
var node = divs[i]
if (node.textContent == '查看全文' &&
node.nextSibling &&
node.nextSibling.nodeName == 'IMG') {
node.style = 'display: none!important;'
node.nextSibling.style = 'display: none!important';
}
}
}
}
File diff suppressed because it is too large Load Diff

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