feat(epub): align wxread css and overlay pagination behavior
This commit is contained in:
@@ -40,9 +40,10 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
|||||||
- 5 层 CSS 级联和 EPUB `<link>` 外部样式表内联都已经落地。
|
- 5 层 CSS 级联和 EPUB `<link>` 外部样式表内联都已经落地。
|
||||||
- CoreText 页面 hit test、长按选区、菜单锚点、复制/高亮/批注主链路已经接入页面几何。
|
- CoreText 页面 hit test、长按选区、菜单锚点、复制/高亮/批注主链路已经接入页面几何。
|
||||||
- CoreText 路径的高亮、注释、搜索命中已经走页面装饰层,而不是继续改正文布局真值。
|
- CoreText 路径的高亮、注释、搜索命中已经走页面装饰层,而不是继续改正文布局真值。
|
||||||
- WebView 路径的高亮/批注展示链路已经补齐,不再因为 DOM 包裹失败而“有记录但不着色”。
|
- WebView 路径的标注绘制、搜索高亮已经改成“JS 产出 rect,原生 overlay 负责 CGContext 绘制”,不再依赖 DOM 包裹着色。
|
||||||
- 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext`、`weread-page-relate` 页首借行这几类 WXRead 风格规则。
|
- 分页器已经补上 inline footnote attachment 不整段挪页、标题 `keepWithNext`、`weread-page-relate` 页首借行这几类 WXRead 风格规则。
|
||||||
- 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。
|
- 章节尾部“仅空白/段落分隔符”的尾页丢弃,以及极短尾页回并已经落地,`宝山辽墓材料与释读` 第 31 页空白问题已修复。
|
||||||
|
- `RDEPUBTextLayoutConfig` 已补齐到 WXRead 同级配置面:`frameWidth/frameHeight/edgeInsets/numberOfColumns/columnGap/avoidOrphans/avoidWidows/hyphenation`,并已接入分页入口与缓存键。
|
||||||
|
|
||||||
### ⚠️ 有差异/有问题
|
### ⚠️ 有差异/有问题
|
||||||
|
|
||||||
@@ -51,14 +52,14 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
|||||||
- 位置模型虽然已经有 `rangeAnchor` 和 `fileIndex/row/column` 语义,但还不是完整的 WXRead 双向位置转换器。
|
- 位置模型虽然已经有 `rangeAnchor` 和 `fileIndex/row/column` 语义,但还不是完整的 WXRead 双向位置转换器。
|
||||||
- `RDEPUBChapterData` 已承担页内高亮/搜索/页码查询职责,但还没完全吸收 controller/book 层的章节语义。
|
- `RDEPUBChapterData` 已承担页内高亮/搜索/页码查询职责,但还没完全吸收 controller/book 层的章节语义。
|
||||||
- `RDReaderView` 已有 `pageCurl` fault patch、预加载、预测翻页和显示切换位置恢复,但还不是 WXRead 那种完整宿主层补丁集。
|
- `RDReaderView` 已有 `pageCurl` fault patch、预加载、预测翻页和显示切换位置恢复,但还不是 WXRead 那种完整宿主层补丁集。
|
||||||
- `default.css` / `replace.css` / `dark.css` 的效果已接近,但资源体系和 WXRead 私有 CSS 仍不是一比一镜像。
|
- `default.css` / `replace.css` / `dark.css` / `replaceForLatinLanguageBook.css` 已切到内置资源镜像,并接入章节语言分支选择。
|
||||||
|
|
||||||
### ❌ 未实现
|
### ❌ 未实现
|
||||||
|
|
||||||
- 完整 `WRChapterData` 同构模型
|
- 完整 `WRChapterData` 同构模型
|
||||||
- 完整 `WREpubPositionConverter` 同构模型
|
- 完整 `WREpubPositionConverter` 同构模型
|
||||||
- 预加载、预测翻页、显示切换一致性的完整宿主层方案
|
- 预加载、预测翻页、显示切换一致性的完整宿主层方案
|
||||||
- 多栏、字体动态加载、繁简转换
|
- 字体动态加载、繁简转换
|
||||||
- TTS / DRM / Pencil
|
- TTS / DRM / Pencil
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -70,10 +71,10 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
|||||||
| EPUB 文本渲染 | `WRPageView.drawRect:` → `CTFrameDraw` 到 CGContext | `RDEPUBTextContentView` → `RDEPUBDirectCoreTextPageView.draw(_:)` + `DTCoreTextLayoutFrame.draw(in:)` 页面级直绘 | ✅ 已实现 |
|
| EPUB 文本渲染 | `WRPageView.drawRect:` → `CTFrameDraw` 到 CGContext | `RDEPUBTextContentView` → `RDEPUBDirectCoreTextPageView.draw(_:)` + `DTCoreTextLayoutFrame.draw(in:)` 页面级直绘 | ✅ 已实现 |
|
||||||
| WebView 路径 | WKWebView(公众号/文集文章) | WKWebView(interactive/fixed-layout EPUB) | ✅ 已实现 |
|
| WebView 路径 | WKWebView(公众号/文集文章) | WKWebView(interactive/fixed-layout EPUB) | ✅ 已实现 |
|
||||||
| 文本选区 | `CTLineGetStringIndexForPosition` 坐标级 hit test | `RDEPUBPageInteractionController` + `RDEPUBSelectionOverlayView` 已接入主链路 | ✅ 已实现 |
|
| 文本选区 | `CTLineGetStringIndexForPosition` 坐标级 hit test | `RDEPUBPageInteractionController` + `RDEPUBSelectionOverlayView` 已接入主链路 | ✅ 已实现 |
|
||||||
| 标注绘制 | `CTFrame` 层叠加绘制(CGContext) | CoreText 路径已走页面装饰层;WebView 路径仍是 JS/DOM 高亮 | ⚠️ 有差异/有问题 |
|
| 标注绘制 | `CTFrame` 层叠加绘制(CGContext) | CoreText / WebView 两条链路都已收口到原生页面装饰层绘制 | ✅ 已实现 |
|
||||||
| 搜索高亮 | `WRCoreTextLayoutFrame.highlightSearchResults:` 直接绘制 | CoreText 路径已页面化;不同渲染路径仍不是同一套对象模型 | ⚠️ 有差异/有问题 |
|
| 搜索高亮 | `WRCoreTextLayoutFrame.highlightSearchResults:` 直接绘制 | CoreText / WebView 两条链路都已统一为原生页面装饰对象绘制 | ✅ 已实现 |
|
||||||
|
|
||||||
**结论:** 文本页的“是否页面级 CoreText”已经不是问题,当前差距是页面装饰对象和双路径一致性。
|
**结论:** 渲染层里“页面级绘制 + 页面装饰对象收口”这一块已经完成,当前剩余差距不再是标注/搜索高亮链路。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
|||||||
| 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 |
|
| 标题 keep-with-next | 标题不能孤悬页尾 | 已补 `keepWithNext` 语义与页尾回退 | ✅ 已实现 |
|
||||||
| `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 |
|
| `weread-page-relate` | 页首关联块需要借上一页一行 | 已补页首 `pageRelate` 借行规则 | ✅ 已实现 |
|
||||||
| 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 |
|
| 章节尾页收口 | 丢弃空白尾页、合并极短尾页 | 已补尾页空白丢弃与超短尾页回并 | ✅ 已实现 |
|
||||||
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | 已有 `RDEPUBTextLayoutConfig`,但多栏等仍缺 | ⚠️ 有差异/有问题 |
|
| 分页配置 | `WRCoreTextLayoutConfig`(含多栏等) | `RDEPUBTextLayoutConfig` 已补齐同级参数,并接入分页入口/缓存键/列路径构建 | ✅ 已实现 |
|
||||||
| 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 |
|
| 缓存 | 按书籍 + 排版设置缓存 | 已有 `RDEPUBTextBookCache` 磁盘缓存 | ✅ 已实现 |
|
||||||
|
|
||||||
**结论:** 当前分页质量问题已经进入问题书细抠阶段,不再是“缺分页器”的阶段;章节尾部空白页这类系统性问题也已经开始按 WXRead 规则收口。
|
**结论:** 当前分页质量问题已经进入问题书细抠阶段,不再是“缺分页器”的阶段;章节尾部空白页这类系统性问题也已经开始按 WXRead 规则收口。
|
||||||
@@ -101,11 +102,11 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
|||||||
|------|---------|-------------|------|
|
|------|---------|-------------|------|
|
||||||
| CSS 级联层级 | 5 级:`default < replace < dark < epub < user` | `RDEPUBTextStyleSheetPackage` 已实际生成并注入 5 层 | ✅ 已实现 |
|
| CSS 级联层级 | 5 级:`default < replace < dark < epub < user` | `RDEPUBTextStyleSheetPackage` 已实际生成并注入 5 层 | ✅ 已实现 |
|
||||||
| EPUB `<link>` CSS | 解析前级联合并外部样式 | 已实现抽取、内联、URL 重写与诊断 | ✅ 已实现 |
|
| EPUB `<link>` CSS | 解析前级联合并外部样式 | 已实现抽取、内联、URL 重写与诊断 | ✅ 已实现 |
|
||||||
| 自定义 CSS 属性 | `wr-vertical-center-style`、`weread-page-relate`、断页相关私有属性 | 已支持 `pageRelate`、`avoidPageBreakInside`、attachment placement;仍非完整 WeRead 属性全集 | ⚠️ 有差异/有问题 |
|
| 自定义 CSS 属性 | `wr-vertical-center-style`、`weread-page-relate`、断页相关私有属性 | 已按 WXRead 语义接入 `wr-vertical-center-style`、`weread-page-relate`、`page-break-* / break-*`、`avoidPageBreakInside` 与 attachment placement | ✅ 已实现 |
|
||||||
| WXRead 私有 CSS 文件 | 使用 WeRead 自带 `default/replace/dark/...` | 当前是等价策略,不是原文件原样镜像 | ⚠️ 有差异/有问题 |
|
| WXRead 私有 CSS 文件 | 使用 WeRead 自带 `default/replace/dark/...` | 已把 `default/replace/dark` 作为 SDK 内置资源镜像注入 5 层级联 | ✅ 已实现 |
|
||||||
| `replaceForLatinLanguageBook.css` | 拉丁语言专项字体规则 | 无对应 | ❌ 未实现 |
|
| `replaceForLatinLanguageBook.css` | 拉丁语言专项字体规则 | 已按章节语言 / 正文拉丁字符占比自动切换 Latin replace 层 | ✅ 已实现 |
|
||||||
|
|
||||||
**结论:** CSS 主机制已对齐,剩下是规则细节和私有资源体系差异。
|
**结论:** CSS 主机制、私有资源层和拉丁语言分支都已经按 WXRead 路径收口;这一节当前不再是剩余差距。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -156,22 +157,22 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
|||||||
|
|
||||||
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|
||||||
|------|-------------|-----------------|------|
|
|------|-------------|-----------------|------|
|
||||||
| `default.css` | 基础 HTML 标签样式 | 已有等价基础 CSS | ⚠️ 有差异/有问题 |
|
| `default.css` | 基础 HTML 标签样式 | 已以内置资源镜像接入 | ✅ 已实现 |
|
||||||
| `replace.css` | 标题、图片、引用、分页控制 | 已有等价 replace 层 | ⚠️ 有差异/有问题 |
|
| `replace.css` | 标题、图片、引用、分页控制 | 已以内置资源镜像接入 | ✅ 已实现 |
|
||||||
| `dark.css` | 暗色主题 | 已有 dark 层 | ⚠️ 有差异/有问题 |
|
| `dark.css` | 暗色主题 | 已以内置资源镜像接入,并保留主题色覆盖层 | ✅ 已实现 |
|
||||||
| `replaceForLatinLanguageBook.css` | 拉丁语言字体 | 无 | ❌ 未实现 |
|
| `replaceForLatinLanguageBook.css` | 拉丁语言字体 | 已接入章节语言自动切换 | ✅ 已实现 |
|
||||||
| `replaceForMPChapter.css` | 公众号文章专用 | EPUB 主链路不需要 | ❌ 未实现(暂不需要) |
|
| `replaceForMPChapter.css` | 公众号文章专用 | EPUB 主链路不需要 | ❌ 未实现(暂不需要) |
|
||||||
|
|
||||||
### JS
|
### JS
|
||||||
|
|
||||||
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|
| 文件 | 读书职责 | ReadViewSDK 状态 | 核查 |
|
||||||
|------|-------------|-----------------|------|
|
|------|-------------|-----------------|------|
|
||||||
| `weread-highlighter.js` | Web 高亮引擎 | `epub-bridge.js` 已接住高亮/批注展示 | ⚠️ 有差异/有问题 |
|
| `weread-highlighter.js` | Web 高亮引擎 | `epub-bridge.js` 现只负责 rect 解析与桥接,最终由原生 overlay 绘制 | ✅ 已实现 |
|
||||||
| `rangy-*` | 选区/高亮库 | CoreText 路径不需要,Web 路径也未采用 | ⚠️ 有差异/有问题 |
|
| `rangy-*` | 选区/高亮库 | CoreText 路径不需要,Web 路径也未采用 | ⚠️ 有差异/有问题 |
|
||||||
| `cssInjector.js` | 动态 CSS 注入 | 现为原生侧静态注入 | ⚠️ 有差异/有问题 |
|
| `cssInjector.js` | 动态 CSS 注入 | 现为原生侧静态注入 | ⚠️ 有差异/有问题 |
|
||||||
| `WeReadApi.js` | JS-Native 桥接 | `window.RDReaderBridge` | ⚠️ 有差异/有问题 |
|
| `WeReadApi.js` | JS-Native 桥接 | `window.RDReaderBridge` | ⚠️ 有差异/有问题 |
|
||||||
|
|
||||||
**结论:** 不建议也不应该把 WXRead 私有 JS/CSS 资源整包原样搬用;当前应继续走“能力等价、实现自有”的路线。
|
**结论:** CSS 资源层已经按 WXRead 的文件级职责收口;剩余差距主要在 JS 桥接策略,而不是 CSS 处理能力。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -186,7 +187,7 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
|||||||
| 预加载 | ⚠️ 有差异/有问题 | P3 |
|
| 预加载 | ⚠️ 有差异/有问题 | P3 |
|
||||||
| 预测翻页 | ⚠️ 有差异/有问题 | P3 |
|
| 预测翻页 | ⚠️ 有差异/有问题 | P3 |
|
||||||
| 显示切换一致性 | ⚠️ 有差异/有问题 | P3 |
|
| 显示切换一致性 | ⚠️ 有差异/有问题 | P3 |
|
||||||
| 多栏排版 | ❌ 未实现 | P4 |
|
| 多栏排版 | ⚠️ 引擎配置与列路径已接入,阅读器侧未暴露切换 | P4 |
|
||||||
| 字体动态加载 | ❌ 未实现 | P4 |
|
| 字体动态加载 | ❌ 未实现 | P4 |
|
||||||
| 繁简转换 | ❌ 未实现 | P4 |
|
| 繁简转换 | ❌ 未实现 | P4 |
|
||||||
| TTS / DRM / Pencil | ❌ 未实现 | P4 |
|
| TTS / DRM / Pencil | ❌ 未实现 | P4 |
|
||||||
@@ -221,12 +222,12 @@ ReadViewSDK 当前已经不是“旧 UITextView 阅读器”了,文本主链
|
|||||||
|
|
||||||
### P4:外围能力
|
### P4:外围能力
|
||||||
|
|
||||||
- 多栏
|
- 多栏阅读器开关 / UI 暴露
|
||||||
- 字体系统
|
- 字体系统
|
||||||
- 繁简转换
|
- 繁简转换
|
||||||
- TTS / DRM / Pencil
|
- TTS / DRM / Pencil
|
||||||
|
|
||||||
当前状态:`❌ 未开始`
|
当前状态:`⚠️ 进行中`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
|
|||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||||
RDReaderView: 438edba3372d14a7dde3c0334ebd4cc1945ca926
|
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
"source_files": "Sources/RDReaderView/**/*.{swift}",
|
"source_files": "Sources/RDReaderView/**/*.{swift}",
|
||||||
"resource_bundles": {
|
"resource_bundles": {
|
||||||
"RDReaderViewAssets": [
|
"RDReaderViewAssets": [
|
||||||
"Sources/RDReaderView/Resources/**/*"
|
"Sources/RDReaderView/EPUBCore/Resources/**/*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
|
|||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||||
RDReaderView: 438edba3372d14a7dde3c0334ebd4cc1945ca926
|
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||||
|
|||||||
+1301
-1357
File diff suppressed because it is too large
Load Diff
Generated
-22
@@ -8,78 +8,56 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>1</integer>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>DTCoreText.xcscheme</key>
|
<key>DTCoreText.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>0</integer>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>DTFoundation.xcscheme</key>
|
<key>DTFoundation.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>2</integer>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>Pods-ReadViewDemo.xcscheme</key>
|
<key>Pods-ReadViewDemo.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>3</integer>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>RDReaderView-RDReaderViewAssets.xcscheme</key>
|
<key>RDReaderView-RDReaderViewAssets.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>5</integer>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>RDReaderView.xcscheme</key>
|
<key>RDReaderView.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>4</integer>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>SSAlertSwift.xcscheme</key>
|
<key>SSAlertSwift.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>8</integer>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>SnapKit-SnapKit_Privacy.xcscheme</key>
|
<key>SnapKit-SnapKit_Privacy.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>7</integer>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>SnapKit.xcscheme</key>
|
<key>SnapKit.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>6</integer>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>ZIPFoundation-ZIPFoundation_Privacy.xcscheme</key>
|
<key>ZIPFoundation-ZIPFoundation_Privacy.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>10</integer>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>ZIPFoundation.xcscheme</key>
|
<key>ZIPFoundation.xcscheme</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isShown</key>
|
<key>isShown</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>orderHint</key>
|
|
||||||
<integer>9</integer>
|
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SuppressBuildableAutocreation</key>
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
|||||||
@@ -185,14 +185,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";
|
||||||
|
|||||||
Generated
BIN
Binary file not shown.
@@ -11,6 +11,14 @@ enum RDEPUBAsset: String {
|
|||||||
case bridgeScript = "epub-bridge"
|
case bridgeScript = "epub-bridge"
|
||||||
/// 固定版式 HTML 模板(epub-fixed-layout.html),用于渲染 pre-paginated 类型的 EPUB
|
/// 固定版式 HTML 模板(epub-fixed-layout.html),用于渲染 pre-paginated 类型的 EPUB
|
||||||
case fixedLayoutTemplate = "epub-fixed-layout"
|
case fixedLayoutTemplate = "epub-fixed-layout"
|
||||||
|
/// WXRead 对齐的 default.css
|
||||||
|
case wxReadDefaultCSS = "wxread-default"
|
||||||
|
/// WXRead 对齐的 replace.css
|
||||||
|
case wxReadReplaceCSS = "wxread-replace"
|
||||||
|
/// WXRead 对齐的 dark.css
|
||||||
|
case wxReadDarkCSS = "wxread-dark"
|
||||||
|
/// WXRead 对齐的 replaceForLatinLanguageBook.css
|
||||||
|
case wxReadLatinReplaceCSS = "wxread-replace-latin"
|
||||||
|
|
||||||
/// 对应的文件扩展名
|
/// 对应的文件扩展名
|
||||||
var fileExtension: String {
|
var fileExtension: String {
|
||||||
@@ -19,6 +27,8 @@ enum RDEPUBAsset: String {
|
|||||||
return "js"
|
return "js"
|
||||||
case .fixedLayoutTemplate:
|
case .fixedLayoutTemplate:
|
||||||
return "html"
|
return "html"
|
||||||
|
case .wxReadDefaultCSS, .wxReadReplaceCSS, .wxReadDarkCSS, .wxReadLatinReplaceCSS:
|
||||||
|
return "css"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,163 +81,22 @@ enum RDEPUBJavaScriptBridge {
|
|||||||
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
|
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
|
||||||
return """
|
return """
|
||||||
(function() {
|
(function() {
|
||||||
var payload = \(payload);
|
if (!window.RDReaderBridge) { return false; }
|
||||||
function normalizeHref(rawHref) {
|
window.RDReaderBridge.setSearchPresentation(\(payload));
|
||||||
if (!rawHref) { return ''; }
|
|
||||||
var value = String(rawHref);
|
|
||||||
value = value.replace(/^.*?:\\/\\/[^/]+\\//, '');
|
|
||||||
value = value.split('#')[0];
|
|
||||||
value = value.replace(/^\\/+/, '');
|
|
||||||
try {
|
|
||||||
value = decodeURIComponent(value);
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureStyle(doc) {
|
|
||||||
if (!doc || !doc.head || doc.getElementById('ss-reader-search-style')) { return; }
|
|
||||||
var style = doc.createElement('style');
|
|
||||||
style.id = 'ss-reader-search-style';
|
|
||||||
style.textContent = '.ss-reader-search-mark{background:rgba(248,225,108,0.55);color:inherit;border-radius:2px;padding:0;}.ss-reader-search-mark-active{background:rgba(255,159,67,0.75);color:inherit;border-radius:2px;padding:0;}';
|
|
||||||
doc.head.appendChild(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearMarks(doc) {
|
|
||||||
if (!doc || !doc.body) { return; }
|
|
||||||
var marks = doc.querySelectorAll('span.ss-reader-search-mark, span.ss-reader-search-mark-active');
|
|
||||||
marks.forEach(function(mark) {
|
|
||||||
var textNode = doc.createTextNode(mark.textContent || '');
|
|
||||||
if (mark.parentNode) {
|
|
||||||
mark.parentNode.replaceChild(textNode, mark);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
doc.body.normalize();
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchableDocuments(rootDoc) {
|
|
||||||
var items = [];
|
|
||||||
if (!rootDoc) { return items; }
|
|
||||||
items.push({ doc: rootDoc, href: normalizeHref(rootDoc.location && rootDoc.location.href) });
|
|
||||||
var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : [];
|
|
||||||
frames.forEach(function(frame) {
|
|
||||||
try {
|
|
||||||
if (frame.contentDocument) {
|
|
||||||
items.push({
|
|
||||||
doc: frame.contentDocument,
|
|
||||||
href: normalizeHref(frame.contentWindow && frame.contentWindow.location && frame.contentWindow.location.href)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function textNodes(doc) {
|
|
||||||
if (!doc || !doc.body) { return []; }
|
|
||||||
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
|
|
||||||
acceptNode: function(node) {
|
|
||||||
if (!node || !node.nodeValue || !node.nodeValue.trim()) {
|
|
||||||
return NodeFilter.FILTER_REJECT;
|
|
||||||
}
|
|
||||||
var parent = node.parentNode;
|
|
||||||
if (!parent || !parent.nodeName) {
|
|
||||||
return NodeFilter.FILTER_REJECT;
|
|
||||||
}
|
|
||||||
var name = parent.nodeName.toLowerCase();
|
|
||||||
if (['script', 'style', 'noscript'].indexOf(name) >= 0) {
|
|
||||||
return NodeFilter.FILTER_REJECT;
|
|
||||||
}
|
|
||||||
if (parent.classList && (parent.classList.contains('ss-reader-search-mark') || parent.classList.contains('ss-reader-search-mark-active'))) {
|
|
||||||
return NodeFilter.FILTER_REJECT;
|
|
||||||
}
|
|
||||||
return NodeFilter.FILTER_ACCEPT;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var nodes = [];
|
|
||||||
while (walker.nextNode()) {
|
|
||||||
nodes.push(walker.currentNode);
|
|
||||||
}
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyToDocument(doc, resourceState, keyword) {
|
|
||||||
clearMarks(doc);
|
|
||||||
ensureStyle(doc);
|
|
||||||
if (!doc || !doc.body || !resourceState || !keyword || resourceState.matchCount <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var lowerKeyword = String(keyword).toLowerCase();
|
|
||||||
var activeIndex = typeof resourceState.activeLocalMatchIndex === 'number' ? resourceState.activeLocalMatchIndex : null;
|
|
||||||
var currentLocalIndex = 0;
|
|
||||||
var activeElement = null;
|
|
||||||
|
|
||||||
textNodes(doc).forEach(function(node) {
|
|
||||||
var source = node.nodeValue || '';
|
|
||||||
var lowerSource = source.toLowerCase();
|
|
||||||
var cursor = 0;
|
|
||||||
var replaced = false;
|
|
||||||
var fragment = doc.createDocumentFragment();
|
|
||||||
|
|
||||||
while (cursor < source.length) {
|
|
||||||
var foundIndex = lowerSource.indexOf(lowerKeyword, cursor);
|
|
||||||
if (foundIndex < 0) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
replaced = true;
|
|
||||||
if (foundIndex > cursor) {
|
|
||||||
fragment.appendChild(doc.createTextNode(source.slice(cursor, foundIndex)));
|
|
||||||
}
|
|
||||||
|
|
||||||
var mark = doc.createElement('span');
|
|
||||||
mark.className = currentLocalIndex === activeIndex ? 'ss-reader-search-mark-active' : 'ss-reader-search-mark';
|
|
||||||
mark.textContent = source.slice(foundIndex, foundIndex + keyword.length);
|
|
||||||
fragment.appendChild(mark);
|
|
||||||
if (currentLocalIndex === activeIndex) {
|
|
||||||
activeElement = mark;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentLocalIndex += 1;
|
|
||||||
cursor = foundIndex + keyword.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!replaced) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cursor < source.length) {
|
|
||||||
fragment.appendChild(doc.createTextNode(source.slice(cursor)));
|
|
||||||
}
|
|
||||||
if (node.parentNode) {
|
|
||||||
node.parentNode.replaceChild(fragment, node);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (activeElement && activeElement.scrollIntoView) {
|
|
||||||
activeElement.scrollIntoView({ block: 'center', inline: 'center', behavior: 'auto' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var docs = searchableDocuments(document);
|
|
||||||
var states = new Map();
|
|
||||||
if (payload && Array.isArray(payload.resources)) {
|
|
||||||
payload.resources.forEach(function(resource) {
|
|
||||||
states.set(normalizeHref(resource.href), resource);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
docs.forEach(function(item) {
|
|
||||||
applyToDocument(item.doc, states.get(item.href), payload && payload.keyword ? payload.keyword : '');
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
})();
|
})();
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func resolveDecorationsScript() -> String {
|
||||||
|
"""
|
||||||
|
(function() {
|
||||||
|
if (!window.RDReaderBridge) { return { highlights: [], search: [] }; }
|
||||||
|
return window.RDReaderBridge.resolveDecorations();
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
/// 转义字符串中的反斜杠和反引号,避免 JS 模板字面量语法错误
|
/// 转义字符串中的反斜杠和反引号,避免 JS 模板字面量语法错误
|
||||||
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
|
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
|
||||||
string
|
string
|
||||||
|
|||||||
@@ -61,7 +61,17 @@ extension RDEPUBWebView {
|
|||||||
|
|
||||||
/// 生成固定版式加载签名(用于去重相同请求)
|
/// 生成固定版式加载签名(用于去重相同请求)
|
||||||
func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String {
|
func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String {
|
||||||
[
|
let searchSignature = [
|
||||||
|
request.searchPresentation?.keyword ?? "",
|
||||||
|
request.searchPresentation?.resources.map {
|
||||||
|
[
|
||||||
|
$0.href,
|
||||||
|
String($0.matchCount),
|
||||||
|
String($0.activeLocalMatchIndex ?? -1)
|
||||||
|
].joined(separator: "|")
|
||||||
|
}.joined(separator: ",") ?? ""
|
||||||
|
].joined(separator: "#")
|
||||||
|
return [
|
||||||
publicationKey,
|
publicationKey,
|
||||||
"fixed",
|
"fixed",
|
||||||
request.spread.resources.map(\.href).joined(separator: "|"),
|
request.spread.resources.map(\.href).joined(separator: "|"),
|
||||||
@@ -72,7 +82,8 @@ extension RDEPUBWebView {
|
|||||||
String(format: "%.2f", request.contentInset.bottom),
|
String(format: "%.2f", request.contentInset.bottom),
|
||||||
String(format: "%.2f", request.contentInset.right),
|
String(format: "%.2f", request.contentInset.right),
|
||||||
request.backgroundColorCSS ?? "",
|
request.backgroundColorCSS ?? "",
|
||||||
request.fit.rawValue
|
request.fit.rawValue,
|
||||||
|
searchSignature
|
||||||
].joined(separator: "#")
|
].joined(separator: "#")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,16 @@ extension RDEPUBWebView {
|
|||||||
let highlightSignature = request.highlights
|
let highlightSignature = request.highlights
|
||||||
.map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") }
|
.map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") }
|
||||||
.joined(separator: ",")
|
.joined(separator: ",")
|
||||||
|
let searchSignature = [
|
||||||
|
request.searchPresentation?.keyword ?? "",
|
||||||
|
request.searchPresentation?.resources.map {
|
||||||
|
[
|
||||||
|
$0.href,
|
||||||
|
String($0.matchCount),
|
||||||
|
String($0.activeLocalMatchIndex ?? -1)
|
||||||
|
].joined(separator: "|")
|
||||||
|
}.joined(separator: ",") ?? ""
|
||||||
|
].joined(separator: "#")
|
||||||
let presentation = request.presentation
|
let presentation = request.presentation
|
||||||
return [
|
return [
|
||||||
publicationKey,
|
publicationKey,
|
||||||
@@ -121,7 +131,8 @@ extension RDEPUBWebView {
|
|||||||
presentation.themeBackgroundColor ?? "",
|
presentation.themeBackgroundColor ?? "",
|
||||||
presentation.themeTextColor ?? "",
|
presentation.themeTextColor ?? "",
|
||||||
targetSignature,
|
targetSignature,
|
||||||
highlightSignature
|
highlightSignature,
|
||||||
|
searchSignature
|
||||||
].joined(separator: "#")
|
].joined(separator: "#")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// RDEPUBWebView 的搜索高亮扩展
|
// RDEPUBWebView 的搜索高亮扩展
|
||||||
// 从当前渲染请求中提取搜索展示信息,生成搜索高亮脚本并注入到 WebView 中执行。
|
// 从当前渲染请求中提取搜索展示信息,生成搜索高亮脚本并注入到 WebView 中执行。
|
||||||
|
|
||||||
|
import UIKit
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
extension RDEPUBWebView {
|
extension RDEPUBWebView {
|
||||||
@@ -31,7 +32,107 @@ extension RDEPUBWebView {
|
|||||||
if let error {
|
if let error {
|
||||||
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
|
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
self.refreshNativeDecorationsIfNeeded(completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshNativeDecorationsIfNeeded(completion: (() -> Void)? = nil) {
|
||||||
|
guard let webView else {
|
||||||
|
onDecorationsResolved?([])
|
||||||
|
completion?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.evaluateJavaScript(RDEPUBJavaScriptBridge.resolveDecorationsScript()) { [weak self] result, error in
|
||||||
|
guard let self else {
|
||||||
|
completion?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
|
||||||
|
self.onDecorationsResolved?([])
|
||||||
|
completion?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let decorations = self.nativeDecorations(from: result)
|
||||||
|
self.onDecorationsResolved?(decorations)
|
||||||
completion?()
|
completion?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func nativeDecorations(from result: Any?) -> [RDEPUBTextOverlayDecoration] {
|
||||||
|
guard let payload = result as? [String: Any] else { return [] }
|
||||||
|
|
||||||
|
let highlightDecorations = decorationList(
|
||||||
|
from: payload["highlights"] as? [[String: Any]],
|
||||||
|
defaultColor: overlayColor(hex: "#F8E16C", alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45)
|
||||||
|
)
|
||||||
|
let searchDecorations = decorationList(
|
||||||
|
from: payload["search"] as? [[String: Any]],
|
||||||
|
defaultColor: UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||||
|
)
|
||||||
|
return highlightDecorations + searchDecorations
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decorationList(
|
||||||
|
from items: [[String: Any]]?,
|
||||||
|
defaultColor: UIColor
|
||||||
|
) -> [RDEPUBTextOverlayDecoration] {
|
||||||
|
guard let items else { return [] }
|
||||||
|
|
||||||
|
return items.enumerated().compactMap { index, item -> RDEPUBTextOverlayDecoration? in
|
||||||
|
guard let kindString = item["kind"] as? String,
|
||||||
|
let kind = RDEPUBTextOverlayDecoration.Kind(rawValue: kindString),
|
||||||
|
let rectPayloads = item["rects"] as? [[String: Any]] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let rects = rectPayloads.compactMap { rectPayload -> CGRect? in
|
||||||
|
guard let x = (rectPayload["x"] as? NSNumber)?.doubleValue,
|
||||||
|
let y = (rectPayload["y"] as? NSNumber)?.doubleValue,
|
||||||
|
let width = (rectPayload["width"] as? NSNumber)?.doubleValue,
|
||||||
|
let height = (rectPayload["height"] as? NSNumber)?.doubleValue else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return CGRect(x: x, y: y, width: width, height: height)
|
||||||
|
}
|
||||||
|
guard !rects.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let color: UIColor
|
||||||
|
switch kind {
|
||||||
|
case .underline:
|
||||||
|
let hex = item["color"] as? String ?? "#F8E16C"
|
||||||
|
color = overlayColor(hex: hex, alpha: 1) ?? defaultColor
|
||||||
|
case .activeSearch:
|
||||||
|
color = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||||
|
case .search:
|
||||||
|
color = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||||
|
case .highlight:
|
||||||
|
let hex = item["color"] as? String ?? "#F8E16C"
|
||||||
|
color = overlayColor(hex: hex, alpha: 0.45) ?? defaultColor
|
||||||
|
case .selection, .locate:
|
||||||
|
color = defaultColor
|
||||||
|
}
|
||||||
|
|
||||||
|
return RDEPUBTextOverlayDecoration(
|
||||||
|
kind: kind,
|
||||||
|
absoluteRange: NSRange(location: index, length: 1),
|
||||||
|
rects: rects,
|
||||||
|
color: color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func overlayColor(hex: String, alpha: CGFloat) -> UIColor? {
|
||||||
|
var value = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
value = value.replacingOccurrences(of: "#", with: "")
|
||||||
|
guard value.count == 6, let hexValue = Int(value, radix: 16) else { return nil }
|
||||||
|
return UIColor(
|
||||||
|
red: CGFloat((hexValue >> 16) & 0xFF) / 255,
|
||||||
|
green: CGFloat((hexValue >> 8) & 0xFF) / 255,
|
||||||
|
blue: CGFloat(hexValue & 0xFF) / 255,
|
||||||
|
alpha: alpha
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ public final class RDEPUBWebView: UIView {
|
|||||||
public weak var delegate: RDEPUBWebViewDelegate?
|
public weak var delegate: RDEPUBWebViewDelegate?
|
||||||
/// 渲染完成回调(与 delegate 并行使用)
|
/// 渲染完成回调(与 delegate 并行使用)
|
||||||
public var onRendered: (() -> Void)?
|
public var onRendered: (() -> Void)?
|
||||||
|
/// 内部原生装饰解析回调
|
||||||
|
var onDecorationsResolved: (([RDEPUBTextOverlayDecoration]) -> Void)?
|
||||||
|
|
||||||
/// 当前关联的出版物
|
/// 当前关联的出版物
|
||||||
var publication: RDEPUBPublication?
|
var publication: RDEPUBPublication?
|
||||||
@@ -143,6 +145,7 @@ public final class RDEPUBWebView: UIView {
|
|||||||
public func reset() {
|
public func reset() {
|
||||||
delegate = nil
|
delegate = nil
|
||||||
onRendered = nil
|
onRendered = nil
|
||||||
|
onDecorationsResolved = nil
|
||||||
publication = nil
|
publication = nil
|
||||||
currentRenderRequest = nil
|
currentRenderRequest = nil
|
||||||
currentSpineIndex = 0
|
currentSpineIndex = 0
|
||||||
|
|||||||
@@ -153,47 +153,195 @@
|
|||||||
var absoluteLeft = Math.max(0, rect.left + currentPageOffset());
|
var absoluteLeft = Math.max(0, rect.left + currentPageOffset());
|
||||||
return Math.max(0, Math.floor(absoluteLeft / effectivePageStride()));
|
return Math.max(0, Math.floor(absoluteLeft / effectivePageStride()));
|
||||||
}
|
}
|
||||||
function unwrapHighlights() {
|
var storedHighlights = [];
|
||||||
document.querySelectorAll('mark.ss-reader-highlight').forEach(function(mark) {
|
var storedSearchPresentation = null;
|
||||||
var parent = mark.parentNode;
|
function normalizeHref(rawHref) {
|
||||||
if (!parent) { return; }
|
if (!rawHref) { return ''; }
|
||||||
while (mark.firstChild) {
|
var value = String(rawHref);
|
||||||
parent.insertBefore(mark.firstChild, mark);
|
value = value.replace(/^.*?:\/\/[^/]+\//, '');
|
||||||
}
|
value = value.split('#')[0];
|
||||||
parent.removeChild(mark);
|
value = value.replace(/^\/+/, '');
|
||||||
});
|
try {
|
||||||
|
value = decodeURIComponent(value);
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function clearHighlights() {
|
||||||
|
storedHighlights = [];
|
||||||
}
|
}
|
||||||
function setHighlights(items) {
|
function setHighlights(items) {
|
||||||
unwrapHighlights();
|
storedHighlights = Array.isArray(items) ? items.slice() : [];
|
||||||
if (!Array.isArray(items)) { return; }
|
}
|
||||||
items.forEach(function(item) {
|
function setSearchPresentation(payload) {
|
||||||
if (!item || !item.rangeInfo) { return; }
|
storedSearchPresentation = payload || null;
|
||||||
var range = rangeFromInfo(item.rangeInfo);
|
}
|
||||||
if (!range || range.collapsed) { return; }
|
function textNodes(doc) {
|
||||||
|
if (!doc || !doc.body) { return []; }
|
||||||
|
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode: function(node) {
|
||||||
|
if (!node || !node.nodeValue || !node.nodeValue.trim()) {
|
||||||
|
return NodeFilter.FILTER_REJECT;
|
||||||
|
}
|
||||||
|
var parent = node.parentNode;
|
||||||
|
if (!parent || !parent.nodeName) {
|
||||||
|
return NodeFilter.FILTER_REJECT;
|
||||||
|
}
|
||||||
|
var name = parent.nodeName.toLowerCase();
|
||||||
|
if (['script', 'style', 'noscript'].indexOf(name) >= 0) {
|
||||||
|
return NodeFilter.FILTER_REJECT;
|
||||||
|
}
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var nodes = [];
|
||||||
|
while (walker.nextNode()) {
|
||||||
|
nodes.push(walker.currentNode);
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
function documentContexts(rootDoc, baseOffsetX, baseOffsetY) {
|
||||||
|
var items = [];
|
||||||
|
if (!rootDoc) { return items; }
|
||||||
|
var offsetX = baseOffsetX || 0;
|
||||||
|
var offsetY = baseOffsetY || 0;
|
||||||
|
items.push({
|
||||||
|
doc: rootDoc,
|
||||||
|
href: normalizeHref(rootDoc.location && rootDoc.location.href),
|
||||||
|
offsetX: offsetX,
|
||||||
|
offsetY: offsetY
|
||||||
|
});
|
||||||
|
var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : [];
|
||||||
|
frames.forEach(function(frame) {
|
||||||
try {
|
try {
|
||||||
var mark = document.createElement('mark');
|
if (!frame.contentDocument) { return; }
|
||||||
var style = item.style || 'highlight';
|
var frameRect = frame.getBoundingClientRect();
|
||||||
mark.className = 'ss-reader-highlight ss-reader-highlight-' + style;
|
documentContexts(
|
||||||
mark.style.color = 'inherit';
|
frame.contentDocument,
|
||||||
if (style === 'underline') {
|
offsetX + frameRect.left,
|
||||||
mark.style.background = 'transparent';
|
offsetY + frameRect.top
|
||||||
mark.style.textDecorationLine = 'underline';
|
).forEach(function(item) {
|
||||||
mark.style.textDecorationStyle = 'solid';
|
items.push(item);
|
||||||
mark.style.textDecorationThickness = '2px';
|
});
|
||||||
mark.style.textDecorationColor = item.color || '#F8E16C';
|
|
||||||
} else {
|
|
||||||
mark.style.background = item.color || '#F8E16C';
|
|
||||||
}
|
|
||||||
mark.dataset.highlightId = item.id || '';
|
|
||||||
var fragment = range.extractContents();
|
|
||||||
mark.appendChild(fragment);
|
|
||||||
range.insertNode(mark);
|
|
||||||
if (mark.parentNode && mark.parentNode.normalize) {
|
|
||||||
mark.parentNode.normalize();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
function rectPayloadForRange(range, offsetX, offsetY) {
|
||||||
|
if (!range || range.collapsed) { return []; }
|
||||||
|
var rects = [];
|
||||||
|
Array.prototype.forEach.call(range.getClientRects(), function(rect) {
|
||||||
|
if (!rect || rect.width < 0.5 || rect.height < 0.5) { return; }
|
||||||
|
rects.push({
|
||||||
|
x: rect.left + (offsetX || 0),
|
||||||
|
y: rect.top + (offsetY || 0),
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return rects;
|
||||||
|
}
|
||||||
|
function collectHighlightDecorations() {
|
||||||
|
return storedHighlights.map(function(item, index) {
|
||||||
|
if (!item || !item.rangeInfo) { return null; }
|
||||||
|
var range = rangeFromInfo(item.rangeInfo);
|
||||||
|
var rects = rectPayloadForRange(range, 0, 0);
|
||||||
|
if (!rects.length) { return null; }
|
||||||
|
return {
|
||||||
|
key: item.id || ('highlight-' + index),
|
||||||
|
kind: item.style === 'underline' ? 'underline' : 'highlight',
|
||||||
|
color: item.color || '#F8E16C',
|
||||||
|
rangeInfo: item.rangeInfo,
|
||||||
|
rects: rects
|
||||||
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
function collectSearchDecorations() {
|
||||||
|
if (!storedSearchPresentation || !storedSearchPresentation.keyword) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var resources = new Map();
|
||||||
|
if (Array.isArray(storedSearchPresentation.resources)) {
|
||||||
|
storedSearchPresentation.resources.forEach(function(resource) {
|
||||||
|
resources.set(normalizeHref(resource.href), resource);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyword = String(storedSearchPresentation.keyword);
|
||||||
|
var lowerKeyword = keyword.toLowerCase();
|
||||||
|
var decorations = [];
|
||||||
|
|
||||||
|
documentContexts(document, 0, 0).forEach(function(context) {
|
||||||
|
var resourceState = resources.get(context.href);
|
||||||
|
if (!resourceState || resourceState.matchCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeIndex = typeof resourceState.activeLocalMatchIndex === 'number' ? resourceState.activeLocalMatchIndex : null;
|
||||||
|
var currentLocalIndex = 0;
|
||||||
|
var activeRect = null;
|
||||||
|
var contextStartIndex = decorations.length;
|
||||||
|
|
||||||
|
textNodes(context.doc).forEach(function(node) {
|
||||||
|
var source = node.nodeValue || '';
|
||||||
|
var lowerSource = source.toLowerCase();
|
||||||
|
var cursor = 0;
|
||||||
|
|
||||||
|
while (cursor < source.length) {
|
||||||
|
var foundIndex = lowerSource.indexOf(lowerKeyword, cursor);
|
||||||
|
if (foundIndex < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var range = context.doc.createRange();
|
||||||
|
range.setStart(node, foundIndex);
|
||||||
|
range.setEnd(node, foundIndex + keyword.length);
|
||||||
|
var rects = rectPayloadForRange(range, context.offsetX, context.offsetY);
|
||||||
|
if (rects.length) {
|
||||||
|
var isActive = currentLocalIndex === activeIndex;
|
||||||
|
decorations.push({
|
||||||
|
key: context.href + ':' + currentLocalIndex,
|
||||||
|
kind: isActive ? 'activeSearch' : 'search',
|
||||||
|
rects: rects
|
||||||
|
});
|
||||||
|
if (isActive) {
|
||||||
|
activeRect = rects[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLocalIndex += 1;
|
||||||
|
cursor = foundIndex + keyword.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeRect && !isFixedLayoutDocument()) {
|
||||||
|
var previousPageIndex = visiblePageIndex;
|
||||||
|
var activePageIndex = Math.max(0, Math.floor(Math.max(0, activeRect.x + currentPageOffset()) / effectivePageStride()));
|
||||||
|
setVisiblePageIndex(activePageIndex);
|
||||||
|
var deltaX = (activePageIndex - previousPageIndex) * effectivePageStride();
|
||||||
|
if (deltaX !== 0) {
|
||||||
|
for (var index = contextStartIndex; index < decorations.length; index += 1) {
|
||||||
|
decorations[index].rects = decorations[index].rects.map(function(rect) {
|
||||||
|
return {
|
||||||
|
x: rect.x - deltaX,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return decorations;
|
||||||
|
}
|
||||||
|
function resolveDecorations() {
|
||||||
|
return {
|
||||||
|
highlights: collectHighlightDecorations(),
|
||||||
|
search: collectSearchDecorations()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
function selectionPayload() {
|
function selectionPayload() {
|
||||||
var selection = window.getSelection();
|
var selection = window.getSelection();
|
||||||
@@ -290,7 +438,9 @@
|
|||||||
this.scrollToPage(derivedPageIndex);
|
this.scrollToPage(derivedPageIndex);
|
||||||
},
|
},
|
||||||
setHighlights: setHighlights,
|
setHighlights: setHighlights,
|
||||||
clearHighlights: unwrapHighlights,
|
clearHighlights: clearHighlights,
|
||||||
|
setSearchPresentation: setSearchPresentation,
|
||||||
|
resolveDecorations: resolveDecorations,
|
||||||
reportProgression: reportProgression,
|
reportProgression: reportProgression,
|
||||||
selectionPayload: selectionPayload
|
selectionPayload: selectionPayload
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
}
|
||||||
@@ -177,6 +177,7 @@ public struct RDEPUBTextBook {
|
|||||||
public final class RDEPUBTextBookBuilder {
|
public final class RDEPUBTextBookBuilder {
|
||||||
private let renderer: RDEPUBTextRenderer
|
private let renderer: RDEPUBTextRenderer
|
||||||
private let cache: RDEPUBTextBookCache?
|
private let cache: RDEPUBTextBookCache?
|
||||||
|
private let layoutConfig: RDEPUBTextLayoutConfig
|
||||||
private let sampler: RDEPUBTextPerformanceSampler
|
private let sampler: RDEPUBTextPerformanceSampler
|
||||||
|
|
||||||
/// 最后一次构建的资源引用诊断(样式表、图片等)
|
/// 最后一次构建的资源引用诊断(样式表、图片等)
|
||||||
@@ -188,9 +189,14 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
/// 最后一次构建的缓存命中/未命中统计
|
/// 最后一次构建的缓存命中/未命中统计
|
||||||
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
|
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
|
||||||
|
|
||||||
public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil) {
|
public init(
|
||||||
|
renderer: RDEPUBTextRenderer,
|
||||||
|
cache: RDEPUBTextBookCache? = nil,
|
||||||
|
layoutConfig: RDEPUBTextLayoutConfig = .default
|
||||||
|
) {
|
||||||
self.renderer = renderer
|
self.renderer = renderer
|
||||||
self.cache = cache
|
self.cache = cache
|
||||||
|
self.layoutConfig = layoutConfig
|
||||||
self.sampler = RDEPUBTextPerformanceSampler()
|
self.sampler = RDEPUBTextPerformanceSampler()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +274,8 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
rawHTML: rawHTML,
|
rawHTML: rawHTML,
|
||||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||||
style: style,
|
style: style,
|
||||||
resourceResolver: publication.resourceResolver
|
resourceResolver: publication.resourceResolver,
|
||||||
|
contentLanguageCode: publication.metadata.language
|
||||||
)
|
)
|
||||||
|
|
||||||
// 渲染 HTML → NSAttributedString
|
// 渲染 HTML → NSAttributedString
|
||||||
@@ -339,7 +346,7 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
} else {
|
} else {
|
||||||
// 缓存未命中:调用 CoreText 分页引擎
|
// 缓存未命中:调用 CoreText 分页引擎
|
||||||
layoutFrames = content.length > 0
|
layoutFrames = content.length > 0
|
||||||
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
|
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets, config: layoutConfig)
|
||||||
: []
|
: []
|
||||||
isCacheHit = false
|
isCacheHit = false
|
||||||
}
|
}
|
||||||
@@ -711,7 +718,8 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
fontSize: style.font.pointSize,
|
fontSize: style.font.pointSize,
|
||||||
lineHeightMultiple: style.lineSpacing,
|
lineHeightMultiple: style.lineSpacing,
|
||||||
contentInsets: .zero,
|
contentInsets: .zero,
|
||||||
pageSize: pageSize
|
pageSize: pageSize,
|
||||||
|
layoutConfigSignature: layoutConfig.cacheSignature
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,9 +152,10 @@ public final class RDEPUBTextBookCache {
|
|||||||
fontSize: CGFloat,
|
fontSize: CGFloat,
|
||||||
lineHeightMultiple: CGFloat,
|
lineHeightMultiple: CGFloat,
|
||||||
contentInsets: UIEdgeInsets,
|
contentInsets: UIEdgeInsets,
|
||||||
pageSize: CGSize
|
pageSize: CGSize,
|
||||||
|
layoutConfigSignature: String = RDEPUBTextLayoutConfig.default.cacheSignature
|
||||||
) -> String {
|
) -> String {
|
||||||
let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_v\(schemaVersion)"
|
let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_\(layoutConfigSignature)_v\(schemaVersion)"
|
||||||
let digest = SHA256.hash(data: Data(raw.utf8))
|
let digest = SHA256.hash(data: Data(raw.utf8))
|
||||||
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
||||||
return hex + ".cache"
|
return hex + ".cache"
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ struct RDEPUBTextLayouter {
|
|||||||
private let framesetter: CTFramesetter
|
private let framesetter: CTFramesetter
|
||||||
/// 页面矩形路径(用于 CTFrame 排版)
|
/// 页面矩形路径(用于 CTFrame 排版)
|
||||||
private let path: CGPath
|
private let path: CGPath
|
||||||
|
/// DTCoreText 路径可直接消费的单矩形布局区域
|
||||||
|
private let dtLayoutRect: CGRect
|
||||||
/// 布局配置(avoidPageBreakInside、孤行控制等)
|
/// 布局配置(avoidPageBreakInside、孤行控制等)
|
||||||
private let config: RDEPUBTextLayoutConfig
|
private let config: RDEPUBTextLayoutConfig
|
||||||
|
|
||||||
@@ -35,7 +37,8 @@ struct RDEPUBTextLayouter {
|
|||||||
self.pageSize = pageSize
|
self.pageSize = pageSize
|
||||||
self.config = config
|
self.config = config
|
||||||
self.framesetter = CTFramesetterCreateWithAttributedString(attributedString)
|
self.framesetter = CTFramesetterCreateWithAttributedString(attributedString)
|
||||||
self.path = CGPath(rect: CGRect(origin: .zero, size: pageSize), transform: nil)
|
self.dtLayoutRect = config.contentRect(fallback: pageSize)
|
||||||
|
self.path = Self.makeLayoutPath(pageSize: pageSize, config: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 执行分页,返回布局帧列表(每帧对应一页)。
|
/// 执行分页,返回布局帧列表(每帧对应一页)。
|
||||||
@@ -119,6 +122,10 @@ struct RDEPUBTextLayouter {
|
|||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
/// 使用 DTCoreTextLayouter 进行分页,提供更精确的行级语义处理。
|
/// 使用 DTCoreTextLayouter 进行分页,提供更精确的行级语义处理。
|
||||||
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
|
||||||
|
guard config.numberOfColumns == 1 else {
|
||||||
|
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||||
|
}
|
||||||
|
|
||||||
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
|
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
|
||||||
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
|
||||||
}
|
}
|
||||||
@@ -127,7 +134,7 @@ struct RDEPUBTextLayouter {
|
|||||||
|
|
||||||
var frames: [RDEPUBTextLayoutFrame] = []
|
var frames: [RDEPUBTextLayoutFrame] = []
|
||||||
var location = 0
|
var location = 0
|
||||||
let pageRect = CGRect(origin: .zero, size: pageSize)
|
let pageRect = dtLayoutRect
|
||||||
|
|
||||||
while location < attributedString.length {
|
while location < attributedString.length {
|
||||||
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
|
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
|
||||||
@@ -180,6 +187,19 @@ struct RDEPUBTextLayouter {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
|
||||||
|
let columnRects = config.columnRects(fallback: pageSize)
|
||||||
|
guard columnRects.count > 1 else {
|
||||||
|
return CGPath(rect: columnRects.first ?? CGRect(origin: .zero, size: pageSize), transform: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = CGMutablePath()
|
||||||
|
for rect in columnRects {
|
||||||
|
path.addRect(rect)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 语义边界调整
|
// MARK: - 语义边界调整
|
||||||
|
|
||||||
/// 对 CoreText 提出的分页范围进行语义边界调整。
|
/// 对 CoreText 提出的分页范围进行语义边界调整。
|
||||||
|
|||||||
@@ -80,29 +80,101 @@ public struct RDEPUBTextRenderStyle {
|
|||||||
|
|
||||||
/// 分页引擎的布局控制参数。
|
/// 分页引擎的布局控制参数。
|
||||||
public struct RDEPUBTextLayoutConfig: Equatable {
|
public struct RDEPUBTextLayoutConfig: Equatable {
|
||||||
|
/// 页面帧宽度;传 0 时回退为分页入口传入的 pageSize.width
|
||||||
|
public var frameWidth: CGFloat
|
||||||
|
/// 页面帧高度;传 0 时回退为分页入口传入的 pageSize.height
|
||||||
|
public var frameHeight: CGFloat
|
||||||
|
/// 页面内容内边距,对标 WXRead 的 WRCoreTextLayoutConfig.edgeInsets
|
||||||
|
public var edgeInsets: UIEdgeInsets
|
||||||
|
/// 栏数,对标 WXRead 的 numberOfColumns
|
||||||
|
public var numberOfColumns: Int
|
||||||
|
/// 栏间距,对标 WXRead 的 columnGap
|
||||||
|
public var columnGap: CGFloat
|
||||||
/// 是否避免孤行(段落最后一行单独在下一页顶部)
|
/// 是否避免孤行(段落最后一行单独在下一页顶部)
|
||||||
public var avoidOrphans: Bool
|
public var avoidOrphans: Bool
|
||||||
/// 是否避免寡行(段落第一行单独在上一页底部)
|
/// 是否避免寡行(段落第一行单独在上一页底部)
|
||||||
public var avoidWidows: Bool
|
public var avoidWidows: Bool
|
||||||
/// 是否启用 avoidPageBreakInside 保护(对标 WXRead 的行级回退扫描)
|
/// 是否启用 avoidPageBreakInside 保护(对标 WXRead 的行级回退扫描)
|
||||||
public var avoidPageBreakInsideEnabled: Bool
|
public var avoidPageBreakInsideEnabled: Bool
|
||||||
|
/// 是否启用连字符断字,对标 WXRead 的 hyphenation
|
||||||
|
public var hyphenation: Bool
|
||||||
/// 图片最大高度占页面高度的比例
|
/// 图片最大高度占页面高度的比例
|
||||||
public var imageMaxHeightRatio: CGFloat
|
public var imageMaxHeightRatio: CGFloat
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
frameWidth: CGFloat = 0,
|
||||||
|
frameHeight: CGFloat = 0,
|
||||||
|
edgeInsets: UIEdgeInsets = .zero,
|
||||||
|
numberOfColumns: Int = 1,
|
||||||
|
columnGap: CGFloat = 20,
|
||||||
avoidOrphans: Bool = true,
|
avoidOrphans: Bool = true,
|
||||||
avoidWidows: Bool = true,
|
avoidWidows: Bool = true,
|
||||||
avoidPageBreakInsideEnabled: Bool = true,
|
avoidPageBreakInsideEnabled: Bool = true,
|
||||||
|
hyphenation: Bool = true,
|
||||||
imageMaxHeightRatio: CGFloat = 0.85
|
imageMaxHeightRatio: CGFloat = 0.85
|
||||||
) {
|
) {
|
||||||
|
self.frameWidth = frameWidth
|
||||||
|
self.frameHeight = frameHeight
|
||||||
|
self.edgeInsets = edgeInsets
|
||||||
|
self.numberOfColumns = max(1, numberOfColumns)
|
||||||
|
self.columnGap = max(0, columnGap)
|
||||||
self.avoidOrphans = avoidOrphans
|
self.avoidOrphans = avoidOrphans
|
||||||
self.avoidWidows = avoidWidows
|
self.avoidWidows = avoidWidows
|
||||||
self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
|
self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
|
||||||
|
self.hyphenation = hyphenation
|
||||||
self.imageMaxHeightRatio = imageMaxHeightRatio
|
self.imageMaxHeightRatio = imageMaxHeightRatio
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 默认配置
|
/// 默认配置
|
||||||
public static let `default` = RDEPUBTextLayoutConfig()
|
public static let `default` = RDEPUBTextLayoutConfig()
|
||||||
|
|
||||||
|
/// 结合调用方 pageSize 解析后的实际页面尺寸。
|
||||||
|
public func resolvedFrameSize(fallback pageSize: CGSize) -> CGSize {
|
||||||
|
CGSize(
|
||||||
|
width: max(frameWidth > 0 ? frameWidth : pageSize.width, 1),
|
||||||
|
height: max(frameHeight > 0 ? frameHeight : pageSize.height, 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实际内容区域;对标 WXRead 的 frame + edgeInsets 组合。
|
||||||
|
public func contentRect(fallback pageSize: CGSize) -> CGRect {
|
||||||
|
let size = resolvedFrameSize(fallback: pageSize)
|
||||||
|
return CGRect(origin: .zero, size: size).inset(by: edgeInsets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 多栏布局时的列矩形数组。
|
||||||
|
public func columnRects(fallback pageSize: CGSize) -> [CGRect] {
|
||||||
|
let rect = contentRect(fallback: pageSize)
|
||||||
|
let columns = max(1, numberOfColumns)
|
||||||
|
guard columns > 1 else { return [rect] }
|
||||||
|
|
||||||
|
let totalGap = CGFloat(columns - 1) * columnGap
|
||||||
|
let columnWidth = max((rect.width - totalGap) / CGFloat(columns), 1)
|
||||||
|
|
||||||
|
return (0..<columns).map { index in
|
||||||
|
let originX = rect.minX + CGFloat(index) * (columnWidth + columnGap)
|
||||||
|
return CGRect(x: originX, y: rect.minY, width: columnWidth, height: rect.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 持久化/缓存键使用的稳定签名。
|
||||||
|
public var cacheSignature: String {
|
||||||
|
[
|
||||||
|
String(format: "%.3f", frameWidth),
|
||||||
|
String(format: "%.3f", frameHeight),
|
||||||
|
String(format: "%.3f", edgeInsets.top),
|
||||||
|
String(format: "%.3f", edgeInsets.left),
|
||||||
|
String(format: "%.3f", edgeInsets.bottom),
|
||||||
|
String(format: "%.3f", edgeInsets.right),
|
||||||
|
String(numberOfColumns),
|
||||||
|
String(format: "%.3f", columnGap),
|
||||||
|
avoidOrphans ? "1" : "0",
|
||||||
|
avoidWidows ? "1" : "0",
|
||||||
|
avoidPageBreakInsideEnabled ? "1" : "0",
|
||||||
|
hyphenation ? "1" : "0",
|
||||||
|
String(format: "%.3f", imageMaxHeightRatio)
|
||||||
|
].joined(separator: "|")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CSS 样式表层级
|
// MARK: - CSS 样式表层级
|
||||||
|
|||||||
@@ -193,7 +193,8 @@ enum RDEPUBTextRendererSupport {
|
|||||||
rawHTML: String,
|
rawHTML: String,
|
||||||
baseURL: URL?,
|
baseURL: URL?,
|
||||||
style: RDEPUBTextRenderStyle,
|
style: RDEPUBTextRenderStyle,
|
||||||
resourceResolver: RDEPUBResourceResolver?
|
resourceResolver: RDEPUBResourceResolver?,
|
||||||
|
contentLanguageCode: String? = nil
|
||||||
) -> RDEPUBTextChapterRenderRequest {
|
) -> RDEPUBTextChapterRenderRequest {
|
||||||
let normalizedHTML = injectPaginationSemanticMarkers(into: normalizeHTML(rawHTML))
|
let normalizedHTML = injectPaginationSemanticMarkers(into: normalizeHTML(rawHTML))
|
||||||
let stylesheetHrefReplacements = inlineLinkedStyleSheets(
|
let stylesheetHrefReplacements = inlineLinkedStyleSheets(
|
||||||
@@ -204,7 +205,9 @@ enum RDEPUBTextRendererSupport {
|
|||||||
)
|
)
|
||||||
let layers = makeStyleSheetLayers(
|
let layers = makeStyleSheetLayers(
|
||||||
style: style,
|
style: style,
|
||||||
epubCSS: stylesheetHrefReplacements.inlinedCSS
|
epubCSS: stylesheetHrefReplacements.inlinedCSS,
|
||||||
|
contentLanguageCode: contentLanguageCode,
|
||||||
|
sourceHTML: rawHTML
|
||||||
)
|
)
|
||||||
let htmlWithBase = injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
|
let htmlWithBase = injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
|
||||||
let htmlWithDefaultLayers = injectStyleTag(
|
let htmlWithDefaultLayers = injectStyleTag(
|
||||||
@@ -293,11 +296,17 @@ enum RDEPUBTextRendererSupport {
|
|||||||
|
|
||||||
private static func makeStyleSheetLayers(
|
private static func makeStyleSheetLayers(
|
||||||
style: RDEPUBTextRenderStyle,
|
style: RDEPUBTextRenderStyle,
|
||||||
epubCSS: String
|
epubCSS: String,
|
||||||
|
contentLanguageCode: String?,
|
||||||
|
sourceHTML: String
|
||||||
) -> [RDEPUBTextStyleSheetLayer] {
|
) -> [RDEPUBTextStyleSheetLayer] {
|
||||||
|
let useLatinReplace = prefersLatinLanguageCSS(
|
||||||
|
languageCode: contentLanguageCode,
|
||||||
|
sourceHTML: sourceHTML
|
||||||
|
)
|
||||||
var layers: [RDEPUBTextStyleSheetLayer] = [
|
var layers: [RDEPUBTextStyleSheetLayer] = [
|
||||||
.init(kind: .default, css: defaultCSS()),
|
.init(kind: .default, css: defaultCSS()),
|
||||||
.init(kind: .replace, css: replaceCSS())
|
.init(kind: .replace, css: replaceCSS(useLatinVariant: useLatinReplace))
|
||||||
]
|
]
|
||||||
if isDarkTheme(style: style) {
|
if isDarkTheme(style: style) {
|
||||||
layers.append(.init(kind: .dark, css: darkCSS(style: style)))
|
layers.append(.init(kind: .dark, css: darkCSS(style: style)))
|
||||||
@@ -310,65 +319,12 @@ enum RDEPUBTextRendererSupport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func defaultCSS() -> String {
|
private static func defaultCSS() -> String {
|
||||||
"""
|
RDEPUBAssetRepository.string(for: .wxReadDefaultCSS)
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
word-break: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
p, div, li, blockquote {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 0.75em;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func replaceCSS() -> String {
|
private static func replaceCSS(useLatinVariant: Bool) -> String {
|
||||||
"""
|
let asset: RDEPUBAsset = useLatinVariant ? .wxReadLatinReplaceCSS : .wxReadReplaceCSS
|
||||||
img, svg, video, canvas {
|
return RDEPUBAssetRepository.string(for: asset)
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
.frontCover,
|
|
||||||
.bodyPic,
|
|
||||||
.qrbodyPic,
|
|
||||||
figure {
|
|
||||||
text-align: center;
|
|
||||||
text-indent: 0;
|
|
||||||
}
|
|
||||||
.frontCover img,
|
|
||||||
.rd-front-cover-image,
|
|
||||||
.bodyPic img,
|
|
||||||
.qrbodyPic img,
|
|
||||||
figure img {
|
|
||||||
display: block;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
.qqreader-footnote,
|
|
||||||
.s-pic,
|
|
||||||
.h-pic,
|
|
||||||
.g-pic {
|
|
||||||
display: inline;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
page-break-after: avoid;
|
|
||||||
break-after: avoid;
|
|
||||||
}
|
|
||||||
pre, code {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
|
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
|
||||||
@@ -517,7 +473,7 @@ enum RDEPUBTextRendererSupport {
|
|||||||
private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
|
private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
|
||||||
let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)"
|
let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)"
|
||||||
let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)"
|
let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)"
|
||||||
return """
|
return RDEPUBAssetRepository.string(for: .wxReadDarkCSS) + "\n\n" + """
|
||||||
html, body {
|
html, body {
|
||||||
background: \(background) !important;
|
background: \(background) !important;
|
||||||
color: \(text) !important;
|
color: \(text) !important;
|
||||||
@@ -556,6 +512,118 @@ enum RDEPUBTextRendererSupport {
|
|||||||
return luminance < 0.5
|
return luminance < 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func prefersLatinLanguageCSS(
|
||||||
|
languageCode: String?,
|
||||||
|
sourceHTML: String
|
||||||
|
) -> Bool {
|
||||||
|
let candidateCodes = inferredLanguageCodes(
|
||||||
|
explicitLanguageCode: languageCode,
|
||||||
|
sourceHTML: sourceHTML
|
||||||
|
)
|
||||||
|
|
||||||
|
if candidateCodes.contains(where: isExplicitLatinLanguageCode) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if candidateCodes.contains(where: isExplicitCJKLanguageCode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let textSample = plainTextSample(from: sourceHTML)
|
||||||
|
guard !textSample.isEmpty else { return false }
|
||||||
|
|
||||||
|
var alphabeticCount = 0
|
||||||
|
var latinCount = 0
|
||||||
|
for scalar in textSample.unicodeScalars {
|
||||||
|
guard CharacterSet.letters.contains(scalar) else { continue }
|
||||||
|
alphabeticCount += 1
|
||||||
|
if isLatinScalar(scalar) {
|
||||||
|
latinCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard alphabeticCount >= 80 else { return false }
|
||||||
|
return (Double(latinCount) / Double(alphabeticCount)) >= 0.6
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func inferredLanguageCodes(
|
||||||
|
explicitLanguageCode: String?,
|
||||||
|
sourceHTML: String
|
||||||
|
) -> [String] {
|
||||||
|
var codes: [String] = []
|
||||||
|
if let explicitLanguageCode {
|
||||||
|
let normalized = explicitLanguageCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if !normalized.isEmpty {
|
||||||
|
codes.append(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let regex = try? NSRegularExpression(
|
||||||
|
pattern: #"\b(?:xml:lang|lang)\s*=\s*["']([^"']+)["']"#,
|
||||||
|
options: [.caseInsensitive]
|
||||||
|
) {
|
||||||
|
let nsHTML = sourceHTML as NSString
|
||||||
|
let range = NSRange(location: 0, length: min(nsHTML.length, 8_000))
|
||||||
|
for match in regex.matches(in: sourceHTML, options: [], range: range) {
|
||||||
|
guard match.numberOfRanges > 1 else { continue }
|
||||||
|
let code = nsHTML.substring(with: match.range(at: 1))
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
if !code.isEmpty {
|
||||||
|
codes.append(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array(NSOrderedSet(array: codes)) as? [String] ?? codes
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isExplicitLatinLanguageCode(_ code: String) -> Bool {
|
||||||
|
let normalized = code.lowercased()
|
||||||
|
if normalized.contains("latn") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = normalized.split(separator: "-").first.map(String.init) ?? normalized
|
||||||
|
let latinPrefixes: Set<String> = [
|
||||||
|
"en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi",
|
||||||
|
"is", "ga", "cy", "pl", "cs", "sk", "sl", "hr", "hu", "ro", "tr",
|
||||||
|
"vi", "id", "ms", "tl", "sw", "af", "sq", "et", "lv", "lt"
|
||||||
|
]
|
||||||
|
return latinPrefixes.contains(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isExplicitCJKLanguageCode(_ code: String) -> Bool {
|
||||||
|
let prefix = code.lowercased().split(separator: "-").first.map(String.init) ?? code.lowercased()
|
||||||
|
return ["zh", "ja", "ko"].contains(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func plainTextSample(from html: String) -> String {
|
||||||
|
let maxLength = min(html.count, 20_000)
|
||||||
|
let sample = String(html.prefix(maxLength))
|
||||||
|
let withoutTags = sample.replacingOccurrences(
|
||||||
|
of: #"<[^>]+>"#,
|
||||||
|
with: " ",
|
||||||
|
options: .regularExpression
|
||||||
|
)
|
||||||
|
return withoutTags.replacingOccurrences(
|
||||||
|
of: #"&[A-Za-z0-9#]+;"#,
|
||||||
|
with: " ",
|
||||||
|
options: .regularExpression
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isLatinScalar(_ scalar: UnicodeScalar) -> Bool {
|
||||||
|
switch scalar.value {
|
||||||
|
case 0x0041...0x007A,
|
||||||
|
0x00C0...0x00FF,
|
||||||
|
0x0100...0x024F,
|
||||||
|
0x1E00...0x1EFF:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func injectBaseHref(into html: String, baseURL: URL?) -> String {
|
private static func injectBaseHref(into html: String, baseURL: URL?) -> String {
|
||||||
guard let baseURL else {
|
guard let baseURL else {
|
||||||
return html
|
return html
|
||||||
|
|||||||
@@ -13,9 +13,14 @@ import UIKit
|
|||||||
/// 5. CoreText 分页 → RDEPUBTextBook
|
/// 5. CoreText 分页 → RDEPUBTextBook
|
||||||
public final class RDPlainTextBookBuilder {
|
public final class RDPlainTextBookBuilder {
|
||||||
private let renderer: RDEPUBTextRenderer
|
private let renderer: RDEPUBTextRenderer
|
||||||
|
private let layoutConfig: RDEPUBTextLayoutConfig
|
||||||
|
|
||||||
public init(renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer()) {
|
public init(
|
||||||
|
renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer(),
|
||||||
|
layoutConfig: RDEPUBTextLayoutConfig = .default
|
||||||
|
) {
|
||||||
self.renderer = renderer
|
self.renderer = renderer
|
||||||
|
self.layoutConfig = layoutConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从纯文本文件构建分页书籍。
|
/// 从纯文本文件构建分页书籍。
|
||||||
@@ -41,7 +46,7 @@ public final class RDPlainTextBookBuilder {
|
|||||||
let rendered = try renderer.renderChapter(html: html, baseURL: nil, style: style)
|
let rendered = try renderer.renderChapter(html: html, baseURL: nil, style: style)
|
||||||
|
|
||||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||||
let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize) : []
|
let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize, config: layoutConfig) : []
|
||||||
let effectiveFrames = layoutFrames.isEmpty && content.length > 0
|
let effectiveFrames = layoutFrames.isEmpty && content.length > 0
|
||||||
? [
|
? [
|
||||||
RDEPUBTextLayoutFrame(
|
RDEPUBTextLayoutFrame(
|
||||||
|
|||||||
@@ -679,8 +679,9 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
|
|
||||||
if publication.readingProfile == .textReflowable {
|
if publication.readingProfile == .textReflowable {
|
||||||
let renderer = resolvedTextRenderer()
|
let renderer = resolvedTextRenderer()
|
||||||
let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache)
|
|
||||||
let pageSize = currentTextPageSize()
|
let pageSize = currentTextPageSize()
|
||||||
|
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||||
|
let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache, layoutConfig: layoutConfig)
|
||||||
let renderStyle = currentTextRenderStyle()
|
let renderStyle = currentTextRenderStyle()
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
@@ -913,7 +914,7 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
let restoreLocation = currentVisibleLocation() ?? persistenceLocation()
|
let restoreLocation = currentVisibleLocation() ?? persistenceLocation()
|
||||||
let pageSize = currentTextPageSize()
|
let pageSize = currentTextPageSize()
|
||||||
let style = currentTextRenderStyle()
|
let style = currentTextRenderStyle()
|
||||||
let builder = RDPlainTextBookBuilder()
|
let builder = RDPlainTextBookBuilder(layoutConfig: currentTextLayoutConfig(pageSize: pageSize))
|
||||||
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
|
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
|
||||||
applyTextBook(newBook, restoreLocation: restoreLocation)
|
applyTextBook(newBook, restoreLocation: restoreLocation)
|
||||||
}
|
}
|
||||||
@@ -1231,6 +1232,26 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
|
||||||
|
let viewportSize = currentLayoutContext().viewportSize
|
||||||
|
let derivedFrameSize = CGSize(
|
||||||
|
width: max(viewportSize.width, pageSize.width),
|
||||||
|
height: max(viewportSize.height, pageSize.height)
|
||||||
|
)
|
||||||
|
return RDEPUBTextLayoutConfig(
|
||||||
|
frameWidth: derivedFrameSize.width,
|
||||||
|
frameHeight: derivedFrameSize.height,
|
||||||
|
edgeInsets: .zero,
|
||||||
|
numberOfColumns: 1,
|
||||||
|
columnGap: 20,
|
||||||
|
avoidOrphans: true,
|
||||||
|
avoidWidows: true,
|
||||||
|
avoidPageBreakInsideEnabled: true,
|
||||||
|
hyphenation: true,
|
||||||
|
imageMaxHeightRatio: 0.85
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func resolvedTextRenderer() -> RDEPUBTextRenderer {
|
private func resolvedTextRenderer() -> RDEPUBTextRenderer {
|
||||||
RDEPUBDTCoreTextRenderer()
|
RDEPUBDTCoreTextRenderer()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,12 +445,12 @@ final class RDEPUBTextContentView: UIView {
|
|||||||
case .highlight:
|
case .highlight:
|
||||||
content.addAttribute(
|
content.addAttribute(
|
||||||
.backgroundColor,
|
.backgroundColor,
|
||||||
value: UIColor(hexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
|
value: UIColor(rdHexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
|
||||||
range: relativeRange
|
range: relativeRange
|
||||||
)
|
)
|
||||||
case .underline:
|
case .underline:
|
||||||
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
|
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
|
||||||
if let color = UIColor(hexString: highlight.color, alpha: 1) {
|
if let color = UIColor(rdHexString: highlight.color, alpha: 1) {
|
||||||
content.addAttribute(.underlineColor, value: color, range: relativeRange)
|
content.addAttribute(.underlineColor, value: color, range: relativeRange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -545,7 +545,7 @@ final class RDEPUBTextContentView: UIView {
|
|||||||
let rects = interactionController.selectionRects(for: absoluteRange)
|
let rects = interactionController.selectionRects(for: absoluteRange)
|
||||||
guard !rects.isEmpty else { continue }
|
guard !rects.isEmpty else { continue }
|
||||||
|
|
||||||
let color = UIColor(hexString: highlight.color, alpha: 0.45)
|
let color = UIColor(rdHexString: highlight.color, alpha: 0.45)
|
||||||
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45)
|
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45)
|
||||||
let decoration = RDEPUBTextOverlayDecoration(
|
let decoration = RDEPUBTextOverlayDecoration(
|
||||||
kind: highlight.style == .underline ? .underline : .highlight,
|
kind: highlight.style == .underline ? .underline : .highlight,
|
||||||
@@ -712,17 +712,3 @@ extension RDEPUBTextContentView: UITextViewDelegate {
|
|||||||
delegate?.textContentView(self, didChangeSelection: selection)
|
delegate?.textContentView(self, didChangeSelection: selection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension UIColor {
|
|
||||||
convenience init?(hexString: String, alpha: CGFloat) {
|
|
||||||
var value = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
value = value.replacingOccurrences(of: "#", with: "")
|
|
||||||
guard value.count == 6, let hex = Int(value, radix: 16) else { return nil }
|
|
||||||
self.init(
|
|
||||||
red: CGFloat((hex >> 16) & 0xFF) / 255,
|
|
||||||
green: CGFloat((hex >> 8) & 0xFF) / 255,
|
|
||||||
blue: CGFloat(hex & 0xFF) / 255,
|
|
||||||
alpha: alpha
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ final class RDEPUBWebContentView: UIView {
|
|||||||
weak var delegate: RDEPUBWebContentViewDelegate?
|
weak var delegate: RDEPUBWebContentViewDelegate?
|
||||||
|
|
||||||
private let epubWebView = RDEPUBWebView()
|
private let epubWebView = RDEPUBWebView()
|
||||||
|
private let decorationOverlayView = RDEPUBWebDecorationOverlayView()
|
||||||
private let pageNumberLabel: UILabel = {
|
private let pageNumberLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.font = UIFont.systemFont(ofSize: 13)
|
label.font = UIFont.systemFont(ofSize: 13)
|
||||||
@@ -40,8 +41,12 @@ final class RDEPUBWebContentView: UIView {
|
|||||||
layer.masksToBounds = true
|
layer.masksToBounds = true
|
||||||
|
|
||||||
addSubview(epubWebView)
|
addSubview(epubWebView)
|
||||||
|
addSubview(decorationOverlayView)
|
||||||
addSubview(pageNumberLabel)
|
addSubview(pageNumberLabel)
|
||||||
epubWebView.delegate = self
|
epubWebView.delegate = self
|
||||||
|
epubWebView.onDecorationsResolved = { [weak self] decorations in
|
||||||
|
self?.decorationOverlayView.applyDecorations(decorations)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
@@ -51,6 +56,7 @@ final class RDEPUBWebContentView: UIView {
|
|||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
epubWebView.frame = bounds
|
epubWebView.frame = bounds
|
||||||
|
decorationOverlayView.frame = bounds
|
||||||
|
|
||||||
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
||||||
pageNumberLabel.frame = CGRect(
|
pageNumberLabel.frame = CGRect(
|
||||||
@@ -71,13 +77,18 @@ final class RDEPUBWebContentView: UIView {
|
|||||||
backgroundColor = theme.contentBackgroundColor
|
backgroundColor = theme.contentBackgroundColor
|
||||||
pageNumberLabel.textColor = theme.contentTextColor
|
pageNumberLabel.textColor = theme.contentTextColor
|
||||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||||
|
decorationOverlayView.applyDecorations([])
|
||||||
epubWebView.load(publication: publication, request: request)
|
epubWebView.load(publication: publication, request: request)
|
||||||
setNeedsLayout()
|
setNeedsLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
func releaseResources() {
|
func releaseResources() {
|
||||||
|
decorationOverlayView.applyDecorations([])
|
||||||
epubWebView.reset()
|
epubWebView.reset()
|
||||||
epubWebView.delegate = self
|
epubWebView.delegate = self
|
||||||
|
epubWebView.onDecorationsResolved = { [weak self] decorations in
|
||||||
|
self?.decorationOverlayView.applyDecorations(decorations)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +116,5 @@ extension RDEPUBWebContentView: RDEPUBWebViewDelegate {
|
|||||||
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) {
|
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) {
|
||||||
delegate?.epubWebContentView(self, didLogJavaScriptError: message)
|
delegate?.epubWebContentView(self, didLogJavaScriptError: message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {}
|
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// WebView 路径的原生装饰覆盖层。
|
||||||
|
/// 对标 WXRead 的页面级装饰绘制思路:JS 只负责提供 rect,实际视觉绘制由原生 CGContext 完成。
|
||||||
|
final class RDEPUBWebDecorationOverlayView: UIView {
|
||||||
|
private var decorations: [RDEPUBTextOverlayDecoration] = []
|
||||||
|
private let verticalAdjustment: CGFloat = -1
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = .clear
|
||||||
|
isOpaque = false
|
||||||
|
isUserInteractionEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDecorations(_ decorations: [RDEPUBTextOverlayDecoration]) {
|
||||||
|
self.decorations = decorations.filter { !$0.rects.isEmpty }
|
||||||
|
setNeedsDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draw(_ rect: CGRect) {
|
||||||
|
guard let context = UIGraphicsGetCurrentContext() else { return }
|
||||||
|
|
||||||
|
for decoration in decorations {
|
||||||
|
switch decoration.kind {
|
||||||
|
case .underline:
|
||||||
|
context.setStrokeColor(decoration.color.cgColor)
|
||||||
|
context.setLineWidth(2)
|
||||||
|
for underlineRect in decoration.rects {
|
||||||
|
let y = underlineRect.maxY - 1
|
||||||
|
context.move(to: CGPoint(x: underlineRect.minX, y: y))
|
||||||
|
context.addLine(to: CGPoint(x: underlineRect.maxX, y: y))
|
||||||
|
context.strokePath()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
context.setFillColor(decoration.color.cgColor)
|
||||||
|
for selectionRect in decoration.rects {
|
||||||
|
let adjustedRect = selectionRect.offsetBy(dx: 0, dy: verticalAdjustment)
|
||||||
|
let path = UIBezierPath(roundedRect: adjustedRect.insetBy(dx: -1, dy: -1), cornerRadius: 4)
|
||||||
|
context.addPath(path.cgPath)
|
||||||
|
context.fillPath()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,7 +119,20 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
|
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
|
||||||
let pageSize = currentTextPageSize()
|
let pageSize = currentTextPageSize()
|
||||||
let renderStyle = currentTextRenderStyle()
|
let renderStyle = currentTextRenderStyle()
|
||||||
let builder = RDPlainTextBookBuilder()
|
let builder = RDPlainTextBookBuilder(
|
||||||
|
layoutConfig: RDEPUBTextLayoutConfig(
|
||||||
|
frameWidth: pageSize.width,
|
||||||
|
frameHeight: pageSize.height,
|
||||||
|
edgeInsets: .zero,
|
||||||
|
numberOfColumns: 1,
|
||||||
|
columnGap: 20,
|
||||||
|
avoidOrphans: true,
|
||||||
|
avoidWidows: true,
|
||||||
|
avoidPageBreakInsideEnabled: true,
|
||||||
|
hyphenation: true,
|
||||||
|
imageMaxHeightRatio: 0.85
|
||||||
|
)
|
||||||
|
)
|
||||||
if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) {
|
if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) {
|
||||||
controller = RDEPUBReaderController(
|
controller = RDEPUBReaderController(
|
||||||
textBook: textBook,
|
textBook: textBook,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIColor {
|
||||||
|
convenience init?(rdHexString: String, alpha: CGFloat) {
|
||||||
|
var value = rdHexString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
value = value.replacingOccurrences(of: "#", with: "")
|
||||||
|
guard value.count == 6, let hex = Int(value, radix: 16) else { return nil }
|
||||||
|
self.init(
|
||||||
|
red: CGFloat((hex >> 16) & 0xFF) / 255,
|
||||||
|
green: CGFloat((hex >> 8) & 0xFF) / 255,
|
||||||
|
blue: CGFloat(hex & 0xFF) / 255,
|
||||||
|
alpha: alpha
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user