Compare commits
No commits in common. "ea21c6a831c31b6f47761e3a4bc5f821c24a8005" and "0e7d952fe5057c4e64e766604b6b3b404146e7ef" have entirely different histories.
ea21c6a831
...
0e7d952fe5
@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| [ReflowableEPUB_WXReadRenderer_Design.md](FeatureSolution/ReflowableEPUB_WXReadRenderer_Design.md) | 基于读书的 CoreText 渲染架构,设计 Reflowable EPUB 的增强文本渲染方案(CSS 分层、类型器升级) |
|
| [ReflowableEPUB_WXReadRenderer_Design.md](FeatureSolution/ReflowableEPUB_WXReadRenderer_Design.md) | 基于读书反编译的 CoreText 渲染架构,设计 Reflowable EPUB 的增强文本渲染方案(CSS 分层、类型器升级) |
|
||||||
|
|
||||||
## 目录约定
|
## 目录约定
|
||||||
|
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
Binary file not shown.
@ -104,8 +104,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
|||||||
guard let element else { return }
|
guard let element else { return }
|
||||||
RDEPUBTextRendererSupport.prepareHTMLElementForReaderRendering(
|
RDEPUBTextRendererSupport.prepareHTMLElementForReaderRendering(
|
||||||
element,
|
element,
|
||||||
style: request.style,
|
style: request.style
|
||||||
maxImageSize: resolvedMaxImageSize(for: request)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return builder?.generatedAttributedString()
|
return builder?.generatedAttributedString()
|
||||||
@ -117,7 +116,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
|||||||
/// 确保最终行距与用户设置的 style.lineSpacing 一致。
|
/// 确保最终行距与用户设置的 style.lineSpacing 一致。
|
||||||
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
|
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
|
||||||
let style = request.style
|
let style = request.style
|
||||||
let maxImageSize = resolvedMaxImageSize(for: request)
|
let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28)
|
||||||
var options: [AnyHashable: Any] = [
|
var options: [AnyHashable: Any] = [
|
||||||
NSTextSizeMultiplierDocumentOption: 1.0,
|
NSTextSizeMultiplierDocumentOption: 1.0,
|
||||||
DTDefaultFontFamily: style.font.familyName,
|
DTDefaultFontFamily: style.font.familyName,
|
||||||
@ -125,7 +124,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
|||||||
DTDefaultFontSize: style.font.pointSize,
|
DTDefaultFontSize: style.font.pointSize,
|
||||||
DTDefaultLineHeightMultiplier: max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1),
|
DTDefaultLineHeightMultiplier: max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1),
|
||||||
DTUseiOS6Attributes: true,
|
DTUseiOS6Attributes: true,
|
||||||
DTMaxImageSize: NSValue(cgSize: maxImageSize)
|
DTMaxImageSize: NSValue(cgSize: screenBounds.size)
|
||||||
]
|
]
|
||||||
|
|
||||||
if let baseURL = request.context.baseURL {
|
if let baseURL = request.context.baseURL {
|
||||||
@ -137,18 +136,5 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
|||||||
|
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolvedMaxImageSize(for request: RDEPUBTextChapterRenderRequest) -> CGSize {
|
|
||||||
if let pageSize = request.pageSize {
|
|
||||||
let layoutConfig = request.layoutConfig ?? .default
|
|
||||||
let contentRect = layoutConfig.contentRect(fallback: pageSize)
|
|
||||||
let maxWidth = max(round(contentRect.width), 1)
|
|
||||||
let maxHeight = max(round(contentRect.height * layoutConfig.imageMaxHeightRatio), 1)
|
|
||||||
return CGSize(width: maxWidth, height: maxHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28)
|
|
||||||
return CGSize(width: max(round(screenBounds.width), 1), height: max(round(screenBounds.height * 0.85), 1))
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@ -223,10 +223,6 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
self.init(renderer: RDEPUBDTCoreTextRenderer())
|
self.init(renderer: RDEPUBDTCoreTextRenderer())
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPaginationDebugEnabled: Bool {
|
|
||||||
ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 生成最近一次构建的语义摘要,用于 Phase 7 质量检测日志
|
/// 生成最近一次构建的语义摘要,用于 Phase 7 质量检测日志
|
||||||
public func phase7SemanticSummary(title: String? = nil) -> String? {
|
public func phase7SemanticSummary(title: String? = nil) -> String? {
|
||||||
guard !lastBuildPaginationDiagnostics.isEmpty else { return nil }
|
guard !lastBuildPaginationDiagnostics.isEmpty else { return nil }
|
||||||
@ -267,9 +263,6 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
pageSize: CGSize,
|
pageSize: CGSize,
|
||||||
style: RDEPUBTextRenderStyle
|
style: RDEPUBTextRenderStyle
|
||||||
) throws -> RDEPUBTextBook {
|
) throws -> RDEPUBTextBook {
|
||||||
if isPaginationDebugEnabled {
|
|
||||||
print("[PaginationDebug] build pageSize=\(NSCoder.string(for: pageSize)) layoutInsets=\(NSCoder.string(for: layoutConfig.edgeInsets))")
|
|
||||||
}
|
|
||||||
var chapters: [RDEPUBTextChapter] = []
|
var chapters: [RDEPUBTextChapter] = []
|
||||||
var flatPages: [RDEPUBTextPage] = []
|
var flatPages: [RDEPUBTextPage] = []
|
||||||
lastBuildResourceDiagnostics = []
|
lastBuildResourceDiagnostics = []
|
||||||
@ -300,9 +293,7 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
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,
|
contentLanguageCode: publication.metadata.language
|
||||||
pageSize: pageSize,
|
|
||||||
layoutConfig: layoutConfig
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 渲染 HTML → NSAttributedString
|
// 渲染 HTML → NSAttributedString
|
||||||
@ -444,18 +435,6 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isPaginationDebugEnabled,
|
|
||||||
item.href.contains("Chapter_3.xhtml") {
|
|
||||||
print("[PaginationDebug] href=\(item.href) pages=\(pages.count)")
|
|
||||||
for page in pages {
|
|
||||||
let preview = debugPreview(for: page.content, limit: 36)
|
|
||||||
print("[PaginationDebug] absPage=\(page.absolutePageIndex + 1) localPage=\(page.pageIndexInChapter + 1) range=\(NSStringFromRange(page.contentRange)) break=\(page.metadata.breakReason.rawValue) preview=\(preview)")
|
|
||||||
for note in page.metadata.diagnostics.prefix(4) {
|
|
||||||
print("[PaginationDebug] note=\(note)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chapters.append(
|
chapters.append(
|
||||||
RDEPUBTextChapter(
|
RDEPUBTextChapter(
|
||||||
chapterIndex: chapterIndex,
|
chapterIndex: chapterIndex,
|
||||||
@ -594,26 +573,11 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
return attachmentCount(in: content) > 0 && trimmed.count <= 1
|
return attachmentCount(in: content) > 0 && trimmed.count <= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
private func debugPreview(for content: NSAttributedString, limit: Int) -> String {
|
|
||||||
let collapsed = content.string
|
|
||||||
.replacingOccurrences(of: "\n", with: " ")
|
|
||||||
.replacingOccurrences(of: "\r", with: " ")
|
|
||||||
.replacingOccurrences(of: "\t", with: " ")
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !collapsed.isEmpty else { return "<empty>" }
|
|
||||||
if collapsed.count <= limit {
|
|
||||||
return collapsed
|
|
||||||
}
|
|
||||||
let head = collapsed.prefix(limit)
|
|
||||||
return "\(head)…"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 尾页规范化
|
// MARK: - 尾页规范化
|
||||||
|
|
||||||
/// 规范化分页结果:
|
/// 规范化分页结果的尾部页面:
|
||||||
/// 1. 移除章节中间误产生的纯空白页
|
/// 1. 丢弃纯空白的尾页
|
||||||
/// 2. 丢弃纯空白的尾页
|
/// 2. 将过短的尾页(≤2 字符)合并到前一页
|
||||||
/// 3. 将过短的尾页(≤2 字符)合并到前一页
|
|
||||||
private func normalizeTrailingFrames(
|
private func normalizeTrailingFrames(
|
||||||
_ frames: [RDEPUBTextLayoutFrame],
|
_ frames: [RDEPUBTextLayoutFrame],
|
||||||
content: NSAttributedString,
|
content: NSAttributedString,
|
||||||
@ -623,28 +587,9 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
|
|
||||||
var normalized = frames
|
var normalized = frames
|
||||||
|
|
||||||
// 先移除章节中间的纯空白页。这类页通常只包含换行/占位空白,
|
|
||||||
// 会把相邻正文页硬生生拆开,直接表现为“上一页底部空很多”。
|
|
||||||
var compacted: [RDEPUBTextLayoutFrame] = []
|
|
||||||
compacted.reserveCapacity(normalized.count)
|
|
||||||
for frame in normalized {
|
|
||||||
if shouldDropWhitespaceOnlyFrame(frame, in: content) {
|
|
||||||
let note = "normalized: dropped whitespace-only intermediate page \(NSStringFromRange(frame.contentRange))"
|
|
||||||
if var previous = compacted.popLast() {
|
|
||||||
previous.diagnostics.append(note)
|
|
||||||
compacted.append(previous)
|
|
||||||
} else {
|
|
||||||
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
compacted.append(frame)
|
|
||||||
}
|
|
||||||
normalized = compacted
|
|
||||||
|
|
||||||
// 丢弃纯空白的尾页
|
// 丢弃纯空白的尾页
|
||||||
while let lastFrame = normalized.last,
|
while let lastFrame = normalized.last,
|
||||||
shouldDropWhitespaceOnlyFrame(lastFrame, in: content) {
|
shouldDropWhitespaceOnlyTrailingFrame(lastFrame, in: content) {
|
||||||
normalized.removeLast()
|
normalized.removeLast()
|
||||||
let note = "normalized: dropped whitespace-only trailing page \(NSStringFromRange(lastFrame.contentRange))"
|
let note = "normalized: dropped whitespace-only trailing page \(NSStringFromRange(lastFrame.contentRange))"
|
||||||
if var previousFrame = normalized.popLast() {
|
if var previousFrame = normalized.popLast() {
|
||||||
@ -670,7 +615,7 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 判断尾页是否为纯空白(无可显示字符且无附件)
|
/// 判断尾页是否为纯空白(无可显示字符且无附件)
|
||||||
private func shouldDropWhitespaceOnlyFrame(
|
private func shouldDropWhitespaceOnlyTrailingFrame(
|
||||||
_ frame: RDEPUBTextLayoutFrame,
|
_ frame: RDEPUBTextLayoutFrame,
|
||||||
in content: NSAttributedString
|
in content: NSAttributedString
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
@ -790,7 +735,7 @@ public final class RDEPUBTextBookBuilder {
|
|||||||
bookID: bookID,
|
bookID: bookID,
|
||||||
fontSize: style.font.pointSize,
|
fontSize: style.font.pointSize,
|
||||||
lineHeightMultiple: style.lineSpacing,
|
lineHeightMultiple: style.lineSpacing,
|
||||||
contentInsets: layoutConfig.edgeInsets,
|
contentInsets: .zero,
|
||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
layoutConfigSignature: layoutConfig.cacheSignature
|
layoutConfigSignature: layoutConfig.cacheSignature
|
||||||
)
|
)
|
||||||
|
|||||||
@ -123,8 +123,7 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
|
|||||||
public final class RDEPUBTextBookCache {
|
public final class RDEPUBTextBookCache {
|
||||||
|
|
||||||
/// 缓存模式版本号,变更时旧缓存自动失效
|
/// 缓存模式版本号,变更时旧缓存自动失效
|
||||||
// 分页算法调整后需要提升版本,避免继续复用旧页范围缓存。
|
public var schemaVersion: Int = 1
|
||||||
public var schemaVersion: Int = 6
|
|
||||||
|
|
||||||
/// 串行队列,保证缓存读写的线程安全
|
/// 串行队列,保证缓存读写的线程安全
|
||||||
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
|
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
|
||||||
|
|||||||
@ -67,42 +67,23 @@ struct RDEPUBTextLayouter {
|
|||||||
|
|
||||||
var frames: [RDEPUBTextLayoutFrame] = []
|
var frames: [RDEPUBTextLayoutFrame] = []
|
||||||
var location = 0
|
var location = 0
|
||||||
let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
|
|
||||||
let usableWidth = resolvedSize.width - config.edgeInsets.left - config.edgeInsets.right
|
|
||||||
let usableHeight = resolvedSize.height - config.edgeInsets.top - config.edgeInsets.bottom
|
|
||||||
|
|
||||||
guard usableWidth > 0, usableHeight > 0 else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
while location < attributedString.length {
|
while location < attributedString.length {
|
||||||
let framePath = CGMutablePath()
|
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), path, nil)
|
||||||
// 对齐 WXRead WRChapterPageCount:
|
let visibleRange = CTFrameGetVisibleStringRange(frame)
|
||||||
// CoreText 分页路径使用 bottom inset 作为 y 原点,而不是 UIKit 语义的 top inset。
|
guard visibleRange.length > 0 else {
|
||||||
let pageRect = CGRect(
|
|
||||||
x: config.edgeInsets.left,
|
|
||||||
y: config.edgeInsets.bottom,
|
|
||||||
width: usableWidth,
|
|
||||||
height: usableHeight
|
|
||||||
)
|
|
||||||
framePath.addRect(pageRect)
|
|
||||||
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), framePath, nil)
|
|
||||||
let proposedRange = proposedRangeUsingWXReadPageCount(
|
|
||||||
from: frame,
|
|
||||||
start: location,
|
|
||||||
usableHeight: usableHeight,
|
|
||||||
totalLength: attributedString.length
|
|
||||||
)
|
|
||||||
guard proposedRange.length > 0 else {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
||||||
|
|
||||||
// 行级 avoidPageBreakInside 处理(WXRead 方案:从最后一行向前扫描)
|
// 行级 avoidPageBreakInside 处理(WXRead 方案:从最后一行向前扫描)
|
||||||
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
|
||||||
|
let lineAdjusted = trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
|
||||||
let lineRanges = lineRanges(from: frame)
|
let lineRanges = lineRanges(from: frame)
|
||||||
|
|
||||||
let adjusted = adjustedRange(
|
let adjusted = adjustedRange(
|
||||||
from: avoidAdjusted,
|
from: lineAdjusted,
|
||||||
totalLength: attributedString.length,
|
totalLength: attributedString.length,
|
||||||
lineRanges: lineRanges
|
lineRanges: lineRanges
|
||||||
)
|
)
|
||||||
@ -127,7 +108,7 @@ struct RDEPUBTextLayouter {
|
|||||||
|
|
||||||
let nextLocation = adjusted.range.location + adjusted.range.length
|
let nextLocation = adjusted.range.location + adjusted.range.length
|
||||||
guard nextLocation > location else {
|
guard nextLocation > location else {
|
||||||
location += max(proposedRange.length, 1)
|
location += max(visibleRange.length, 1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
location = nextLocation
|
location = nextLocation
|
||||||
@ -174,36 +155,27 @@ struct RDEPUBTextLayouter {
|
|||||||
totalLength: attributedString.length,
|
totalLength: attributedString.length,
|
||||||
lineRanges: lineRanges
|
lineRanges: lineRanges
|
||||||
)
|
)
|
||||||
let verifiedRange: NSRange
|
|
||||||
if adjusted.breakReason == .attachmentBoundary {
|
|
||||||
verifiedRange = verifiedDisplayRange(for: adjusted.range)
|
|
||||||
} else {
|
|
||||||
verifiedRange = adjusted.range
|
|
||||||
}
|
|
||||||
let trailingFragmentID = nearestTrailingFragmentID(
|
let trailingFragmentID = nearestTrailingFragmentID(
|
||||||
endingAt: verifiedRange.location + verifiedRange.length,
|
endingAt: adjusted.range.location + adjusted.range.length,
|
||||||
fragmentOffsets: fragmentOffsets
|
fragmentOffsets: fragmentOffsets
|
||||||
)
|
)
|
||||||
let diagnostics = verifiedRange == adjusted.range
|
|
||||||
? adjusted.diagnostics
|
|
||||||
: adjusted.diagnostics + ["verified-display-range \(NSStringFromRange(adjusted.range)) -> \(NSStringFromRange(verifiedRange))"]
|
|
||||||
|
|
||||||
frames.append(
|
frames.append(
|
||||||
RDEPUBTextLayoutFrame(
|
RDEPUBTextLayoutFrame(
|
||||||
contentRange: verifiedRange,
|
contentRange: adjusted.range,
|
||||||
breakReason: adjusted.breakReason,
|
breakReason: adjusted.breakReason,
|
||||||
blockRange: blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)),
|
blockRange: adjusted.blockRange,
|
||||||
attachmentRanges: attachmentRanges(in: verifiedRange),
|
attachmentRanges: adjusted.attachmentRanges,
|
||||||
attachmentKinds: attachmentKinds(in: verifiedRange),
|
attachmentKinds: adjusted.attachmentKinds,
|
||||||
blockKinds: blockKinds(in: verifiedRange),
|
blockKinds: adjusted.blockKinds,
|
||||||
semanticHints: semanticHints(in: verifiedRange),
|
semanticHints: adjusted.semanticHints,
|
||||||
attachmentPlacements: attachmentPlacements(in: verifiedRange),
|
attachmentPlacements: adjusted.attachmentPlacements,
|
||||||
trailingFragmentID: trailingFragmentID,
|
trailingFragmentID: trailingFragmentID,
|
||||||
diagnostics: diagnostics
|
diagnostics: adjusted.diagnostics
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
let nextLocation = verifiedRange.location + verifiedRange.length
|
let nextLocation = adjusted.range.location + adjusted.range.length
|
||||||
guard nextLocation > location else {
|
guard nextLocation > location else {
|
||||||
location += max(visibleRange.length, 1)
|
location += max(visibleRange.length, 1)
|
||||||
continue
|
continue
|
||||||
@ -213,31 +185,6 @@ struct RDEPUBTextLayouter {
|
|||||||
|
|
||||||
return frames
|
return frames
|
||||||
}
|
}
|
||||||
|
|
||||||
private func verifiedDisplayRange(for range: NSRange) -> NSRange {
|
|
||||||
guard let clampedRange = clampedRange(range),
|
|
||||||
clampedRange.length > 0,
|
|
||||||
!attachmentRanges(in: clampedRange).isEmpty else {
|
|
||||||
return range
|
|
||||||
}
|
|
||||||
|
|
||||||
let pageContent = attributedString.attributedSubstring(from: clampedRange)
|
|
||||||
guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else {
|
|
||||||
return clampedRange
|
|
||||||
}
|
|
||||||
|
|
||||||
layouter.shouldCacheLayoutFrames = false
|
|
||||||
guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else {
|
|
||||||
return clampedRange
|
|
||||||
}
|
|
||||||
|
|
||||||
let visibleRange = layoutFrame.visibleStringRange()
|
|
||||||
guard visibleRange.length > 0, visibleRange.length < clampedRange.length else {
|
|
||||||
return clampedRange
|
|
||||||
}
|
|
||||||
|
|
||||||
return NSRange(location: clampedRange.location, length: visibleRange.length)
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
|
private static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
|
||||||
@ -306,6 +253,10 @@ struct RDEPUBTextLayouter {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 最小分页长度:原始范围的 55%
|
||||||
|
let minLength = max(Int(Double(proposedRange.length) * 0.55), 1)
|
||||||
|
let minimumEnd = proposedRange.location + minLength
|
||||||
|
|
||||||
let currentBlockRange = blockRange(at: max(proposedRange.location, pageEnd - 1))
|
let currentBlockRange = blockRange(at: max(proposedRange.location, pageEnd - 1))
|
||||||
let currentAttachmentRanges = attachmentRanges(in: proposedRange)
|
let currentAttachmentRanges = attachmentRanges(in: proposedRange)
|
||||||
let currentAttachmentKinds = attachmentKinds(in: proposedRange)
|
let currentAttachmentKinds = attachmentKinds(in: proposedRange)
|
||||||
@ -313,12 +264,38 @@ struct RDEPUBTextLayouter {
|
|||||||
let currentSemanticHints = proposedSemanticHints
|
let currentSemanticHints = proposedSemanticHints
|
||||||
let currentAttachmentPlacements = proposedAttachmentPlacements
|
let currentAttachmentPlacements = proposedAttachmentPlacements
|
||||||
|
|
||||||
// 对齐 WXRead:默认按 CTFrame 已经容纳的行数分页,仅保留 pageRelate 这种
|
// 1. 语义边界(pageBreakBefore/After)
|
||||||
// 微信读书特有的跨页关联规则;其他 keepWithNext/attachmentBoundary/通用语义边界
|
if let semanticBoundary = preferredSemanticBoundary(
|
||||||
// 先不参与截断,避免提前裁短页尾内容。
|
in: proposedRange,
|
||||||
|
minimumEnd: minimumEnd
|
||||||
|
) {
|
||||||
|
let adjustedRange = NSRange(location: proposedRange.location, length: semanticBoundary.location - proposedRange.location)
|
||||||
|
return (
|
||||||
|
range: adjustedRange,
|
||||||
|
breakReason: .semanticBoundary,
|
||||||
|
blockRange: currentBlockRange,
|
||||||
|
attachmentRanges: currentAttachmentRanges,
|
||||||
|
attachmentKinds: currentAttachmentKinds,
|
||||||
|
blockKinds: currentBlockKinds,
|
||||||
|
semanticHints: currentSemanticHints,
|
||||||
|
attachmentPlacements: currentAttachmentPlacements,
|
||||||
|
diagnostics: diagnostics(
|
||||||
|
reason: .semanticBoundary,
|
||||||
|
range: adjustedRange,
|
||||||
|
attachmentRanges: currentAttachmentRanges,
|
||||||
|
blockRange: currentBlockRange,
|
||||||
|
blockKinds: currentBlockKinds,
|
||||||
|
semanticHints: currentSemanticHints,
|
||||||
|
attachmentPlacements: currentAttachmentPlacements,
|
||||||
|
trigger: semanticBoundary.trigger
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. pageRelate 跨页关联边界
|
||||||
if let pageRelateBoundary = preferredPageRelateBoundary(
|
if let pageRelateBoundary = preferredPageRelateBoundary(
|
||||||
after: proposedRange,
|
after: proposedRange,
|
||||||
minimumEnd: proposedRange.location + 1,
|
minimumEnd: minimumEnd,
|
||||||
lineRanges: lineRanges
|
lineRanges: lineRanges
|
||||||
) {
|
) {
|
||||||
let adjustedRange = NSRange(location: proposedRange.location, length: pageRelateBoundary - proposedRange.location)
|
let adjustedRange = NSRange(location: proposedRange.location, length: pageRelateBoundary - proposedRange.location)
|
||||||
@ -344,6 +321,33 @@ struct RDEPUBTextLayouter {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 附件边界(块级附件整体移动)
|
||||||
|
if let attachmentBoundary = preferredAttachmentBoundary(
|
||||||
|
in: proposedRange,
|
||||||
|
minimumEnd: minimumEnd
|
||||||
|
) {
|
||||||
|
let adjustedRange = NSRange(location: proposedRange.location, length: attachmentBoundary - proposedRange.location)
|
||||||
|
return (
|
||||||
|
range: adjustedRange,
|
||||||
|
breakReason: .attachmentBoundary,
|
||||||
|
blockRange: currentBlockRange,
|
||||||
|
attachmentRanges: currentAttachmentRanges,
|
||||||
|
attachmentKinds: currentAttachmentKinds,
|
||||||
|
blockKinds: currentBlockKinds,
|
||||||
|
semanticHints: currentSemanticHints,
|
||||||
|
attachmentPlacements: currentAttachmentPlacements,
|
||||||
|
diagnostics: diagnostics(
|
||||||
|
reason: .attachmentBoundary,
|
||||||
|
range: adjustedRange,
|
||||||
|
attachmentRanges: currentAttachmentRanges,
|
||||||
|
blockRange: currentBlockRange,
|
||||||
|
blockKinds: currentBlockKinds,
|
||||||
|
semanticHints: currentSemanticHints,
|
||||||
|
attachmentPlacements: currentAttachmentPlacements
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 帧限制(默认分页)
|
// 4. 帧限制(默认分页)
|
||||||
return (
|
return (
|
||||||
range: proposedRange,
|
range: proposedRange,
|
||||||
@ -375,11 +379,8 @@ struct RDEPUBTextLayouter {
|
|||||||
in range: NSRange,
|
in range: NSRange,
|
||||||
minimumEnd: Int
|
minimumEnd: Int
|
||||||
) -> (location: Int, trigger: String)? {
|
) -> (location: Int, trigger: String)? {
|
||||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var boundary: (location: Int, trigger: String)?
|
var boundary: (location: Int, trigger: String)?
|
||||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, attributeRange, stop in
|
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, attributeRange, stop in
|
||||||
guard let rawValue = value as? String else { return }
|
guard let rawValue = value as? String else { return }
|
||||||
let hints = rawValue
|
let hints = rawValue
|
||||||
.split(separator: ",")
|
.split(separator: ",")
|
||||||
@ -387,7 +388,7 @@ struct RDEPUBTextLayouter {
|
|||||||
guard !hints.isEmpty else { return }
|
guard !hints.isEmpty else { return }
|
||||||
|
|
||||||
if hints.contains(.pageBreakBefore),
|
if hints.contains(.pageBreakBefore),
|
||||||
attributeRange.location > safeRange.location,
|
attributeRange.location > range.location,
|
||||||
attributeRange.location >= minimumEnd {
|
attributeRange.location >= minimumEnd {
|
||||||
boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue)
|
boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue)
|
||||||
stop.pointee = true
|
stop.pointee = true
|
||||||
@ -397,7 +398,7 @@ struct RDEPUBTextLayouter {
|
|||||||
let attributeEnd = attributeRange.location + attributeRange.length
|
let attributeEnd = attributeRange.location + attributeRange.length
|
||||||
if hints.contains(.pageBreakAfter),
|
if hints.contains(.pageBreakAfter),
|
||||||
attributeEnd > minimumEnd,
|
attributeEnd > minimumEnd,
|
||||||
attributeEnd < safeRange.location + safeRange.length {
|
attributeEnd < range.location + range.length {
|
||||||
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
|
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
|
||||||
stop.pointee = true
|
stop.pointee = true
|
||||||
return
|
return
|
||||||
@ -409,11 +410,8 @@ struct RDEPUBTextLayouter {
|
|||||||
/// 查找附件边界:只有块级附件(.attachment 或 .centered)才触发分页,
|
/// 查找附件边界:只有块级附件(.attachment 或 .centered)才触发分页,
|
||||||
/// 行内脚注图标等不应导致整段移动。
|
/// 行内脚注图标等不应导致整段移动。
|
||||||
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
|
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
|
||||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var boundary: Int?
|
var boundary: Int?
|
||||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, stop in
|
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
|
||||||
guard value != nil else { return }
|
guard value != nil else { return }
|
||||||
|
|
||||||
let location = attributeRange.location
|
let location = attributeRange.location
|
||||||
@ -422,21 +420,11 @@ struct RDEPUBTextLayouter {
|
|||||||
|
|
||||||
// 对标 WXRead:只有块级附件才应将整个块推到下一页。
|
// 对标 WXRead:只有块级附件才应将整个块推到下一页。
|
||||||
// 行内脚注图标等行内附件不应导致整段移动。
|
// 行内脚注图标等行内附件不应导致整段移动。
|
||||||
// 只有真正的块级附件才需要整块挪页。
|
let isBlockLevelAttachment = blockKind == .attachment || placement == .centered
|
||||||
// 行内/基线对齐的附件(例如脚注 note.png)不能触发附件边界分页。
|
|
||||||
let isBlockLevelAttachment: Bool
|
|
||||||
switch placement {
|
|
||||||
case .centered:
|
|
||||||
isBlockLevelAttachment = true
|
|
||||||
case .inline, .baseline:
|
|
||||||
isBlockLevelAttachment = false
|
|
||||||
case nil:
|
|
||||||
isBlockLevelAttachment = blockKind == .attachment
|
|
||||||
}
|
|
||||||
guard isBlockLevelAttachment else { return }
|
guard isBlockLevelAttachment else { return }
|
||||||
|
|
||||||
let boundaryRange = blockRange(at: location) ?? paragraphRange(containing: location)
|
let boundaryRange = blockRange(at: location) ?? paragraphRange(containing: location)
|
||||||
if boundaryRange.location > safeRange.location, boundaryRange.location >= minimumEnd {
|
if boundaryRange.location > range.location, boundaryRange.location >= minimumEnd {
|
||||||
boundary = boundaryRange.location
|
boundary = boundaryRange.location
|
||||||
stop.pointee = true
|
stop.pointee = true
|
||||||
}
|
}
|
||||||
@ -507,11 +495,8 @@ struct RDEPUBTextLayouter {
|
|||||||
|
|
||||||
/// 获取指定范围内的所有附件字符范围
|
/// 获取指定范围内的所有附件字符范围
|
||||||
private func attachmentRanges(in range: NSRange) -> [NSRange] {
|
private func attachmentRanges(in range: NSRange) -> [NSRange] {
|
||||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
var results: [NSRange] = []
|
var results: [NSRange] = []
|
||||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, _ in
|
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, _ in
|
||||||
guard value != nil else { return }
|
guard value != nil else { return }
|
||||||
results.append(attributeRange)
|
results.append(attributeRange)
|
||||||
}
|
}
|
||||||
@ -530,11 +515,8 @@ struct RDEPUBTextLayouter {
|
|||||||
|
|
||||||
/// 获取指定范围内的附件类型列表(去重)
|
/// 获取指定范围内的附件类型列表(去重)
|
||||||
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
|
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
|
||||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
var kinds: [RDEPUBTextAttachmentKind] = []
|
var kinds: [RDEPUBTextAttachmentKind] = []
|
||||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, _, _ in
|
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in
|
||||||
guard let rawValue = value as? String,
|
guard let rawValue = value as? String,
|
||||||
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
|
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
|
||||||
!kinds.contains(kind) else {
|
!kinds.contains(kind) else {
|
||||||
@ -547,11 +529,8 @@ struct RDEPUBTextLayouter {
|
|||||||
|
|
||||||
/// 获取指定范围内的块级元素类型列表(去重)
|
/// 获取指定范围内的块级元素类型列表(去重)
|
||||||
private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
|
private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
|
||||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
var kinds: [RDEPUBTextBlockKind] = []
|
var kinds: [RDEPUBTextBlockKind] = []
|
||||||
attributedString.enumerateAttribute(.rdPageBlockKind, in: safeRange) { value, _, _ in
|
attributedString.enumerateAttribute(.rdPageBlockKind, in: range) { value, _, _ in
|
||||||
guard let rawValue = value as? String,
|
guard let rawValue = value as? String,
|
||||||
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
|
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
|
||||||
!kinds.contains(kind) else {
|
!kinds.contains(kind) else {
|
||||||
@ -564,11 +543,8 @@ struct RDEPUBTextLayouter {
|
|||||||
|
|
||||||
/// 获取指定范围内的语义提示列表(去重)
|
/// 获取指定范围内的语义提示列表(去重)
|
||||||
private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
|
private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
|
||||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
var hints: [RDEPUBTextSemanticHint] = []
|
var hints: [RDEPUBTextSemanticHint] = []
|
||||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, _ in
|
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, _, _ in
|
||||||
guard let rawValue = value as? String else { return }
|
guard let rawValue = value as? String else { return }
|
||||||
for hint in rawValue.split(separator: ",").compactMap({ RDEPUBTextSemanticHint(rawValue: String($0)) }) where !hints.contains(hint) {
|
for hint in rawValue.split(separator: ",").compactMap({ RDEPUBTextSemanticHint(rawValue: String($0)) }) where !hints.contains(hint) {
|
||||||
hints.append(hint)
|
hints.append(hint)
|
||||||
@ -579,11 +555,8 @@ struct RDEPUBTextLayouter {
|
|||||||
|
|
||||||
/// 获取指定范围内的附件布局方式列表(去重)
|
/// 获取指定范围内的附件布局方式列表(去重)
|
||||||
private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
|
private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
|
||||||
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
var placements: [RDEPUBTextAttachmentPlacement] = []
|
var placements: [RDEPUBTextAttachmentPlacement] = []
|
||||||
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: safeRange) { value, _, _ in
|
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: range) { value, _, _ in
|
||||||
guard let rawValue = value as? String,
|
guard let rawValue = value as? String,
|
||||||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
|
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
|
||||||
!placements.contains(placement) else {
|
!placements.contains(placement) else {
|
||||||
@ -594,22 +567,6 @@ struct RDEPUBTextLayouter {
|
|||||||
return placements
|
return placements
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将范围裁剪到 attributedString 的合法边界内,避免属性枚举与子串提取越界。
|
|
||||||
private func clampedRange(_ range: NSRange) -> NSRange? {
|
|
||||||
guard range.location >= 0, range.length >= 0 else { return nil }
|
|
||||||
guard attributedString.length > 0 else {
|
|
||||||
return range.location == 0 ? NSRange(location: 0, length: 0) : nil
|
|
||||||
}
|
|
||||||
guard range.location < attributedString.length else { return nil }
|
|
||||||
|
|
||||||
let maxLength = attributedString.length - range.location
|
|
||||||
return NSRange(location: range.location, length: min(range.length, maxLength))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clampedProbeRange(for lineRange: NSRange) -> NSRange? {
|
|
||||||
clampedRange(NSRange(location: lineRange.location, length: max(lineRange.length, 1)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 查找指定位置之前最近的 fragment ID(用于阅读位置恢复)
|
/// 查找指定位置之前最近的 fragment ID(用于阅读位置恢复)
|
||||||
private func nearestTrailingFragmentID(
|
private func nearestTrailingFragmentID(
|
||||||
endingAt location: Int,
|
endingAt location: Int,
|
||||||
@ -785,15 +742,10 @@ struct RDEPUBTextLayouter {
|
|||||||
|
|
||||||
/// 检查某行是否落在 avoidPageBreakInside 保护块内
|
/// 检查某行是否落在 avoidPageBreakInside 保护块内
|
||||||
private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
|
private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
|
||||||
guard let probeRange = clampedProbeRange(for: lineRange) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var found = false
|
var found = false
|
||||||
attributedString.enumerateAttributes(in: probeRange) { attributes, _, stop in
|
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
|
||||||
guard shouldTreatAvoidHintAsBlockProtection(attributes) else {
|
attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in
|
||||||
return
|
guard let rawValue = value as? String else { return }
|
||||||
}
|
|
||||||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return }
|
|
||||||
let hints = rawValue
|
let hints = rawValue
|
||||||
.split(separator: ",")
|
.split(separator: ",")
|
||||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||||
@ -805,37 +757,10 @@ struct RDEPUBTextLayouter {
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 只有真正的块级内容才应触发 avoidPageBreakInside。
|
|
||||||
/// 像脚注 note.png 这类行内图片会被标记为 `img`,但不该把整行推到下一页。
|
|
||||||
private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
|
|
||||||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
let hints = rawValue
|
|
||||||
.split(separator: ",")
|
|
||||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
|
||||||
guard hints.contains(.avoidPageBreakInside) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let placement = (attributes[.rdPageAttachmentPlacement] as? String)
|
|
||||||
.flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
|
|
||||||
let blockKind = (attributes[.rdPageBlockKind] as? String)
|
|
||||||
.flatMap(RDEPUBTextBlockKind.init(rawValue:))
|
|
||||||
|
|
||||||
if blockKind == .attachment, placement != .centered {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检查某行是否落在 keepWithNext 保护块内
|
/// 检查某行是否落在 keepWithNext 保护块内
|
||||||
private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
|
private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
|
||||||
guard let probeRange = clampedProbeRange(for: lineRange) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var found = false
|
var found = false
|
||||||
|
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
|
||||||
attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in
|
attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in
|
||||||
guard let rawValue = value as? String else { return }
|
guard let rawValue = value as? String else { return }
|
||||||
let hints = rawValue
|
let hints = rawValue
|
||||||
@ -858,47 +783,6 @@ struct RDEPUBTextLayouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 逐句对齐 WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString` 的分页循环:
|
|
||||||
/// 1. 取出 CTFrame 当前页的所有行
|
|
||||||
/// 2. 读取每行 origin
|
|
||||||
/// 3. 用 `lineY - ascent > usableHeight` 判断是否越过可用高度
|
|
||||||
/// 4. 累计能容纳行的字符数得到本页范围
|
|
||||||
private func proposedRangeUsingWXReadPageCount(
|
|
||||||
from frame: CTFrame,
|
|
||||||
start location: Int,
|
|
||||||
usableHeight: CGFloat,
|
|
||||||
totalLength: Int
|
|
||||||
) -> NSRange {
|
|
||||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
|
||||||
guard !lines.isEmpty else {
|
|
||||||
return NSRange(location: location, length: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
var origins = [CGPoint](repeating: .zero, count: lines.count)
|
|
||||||
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
|
|
||||||
|
|
||||||
var pageCharCount = 0
|
|
||||||
for (index, line) in lines.enumerated() {
|
|
||||||
let lineRange = CTLineGetStringRange(line)
|
|
||||||
let lineY = origins[index].y
|
|
||||||
var ascent: CGFloat = 0
|
|
||||||
var descent: CGFloat = 0
|
|
||||||
CTLineGetTypographicBounds(line, &ascent, &descent, nil)
|
|
||||||
|
|
||||||
if lineY - ascent > usableHeight {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
pageCharCount += lineRange.length
|
|
||||||
}
|
|
||||||
|
|
||||||
if pageCharCount == 0 {
|
|
||||||
pageCharCount = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return NSRange(location: location, length: min(pageCharCount, totalLength - location))
|
|
||||||
}
|
|
||||||
|
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
/// 获取 DTCoreTextLayoutFrame 中所有行的字符范围
|
/// 获取 DTCoreTextLayoutFrame 中所有行的字符范围
|
||||||
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
|
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
|
||||||
|
|||||||
@ -286,21 +286,10 @@ public struct RDEPUBTextChapterContext: Equatable {
|
|||||||
public struct RDEPUBTextChapterRenderRequest {
|
public struct RDEPUBTextChapterRenderRequest {
|
||||||
public var context: RDEPUBTextChapterContext
|
public var context: RDEPUBTextChapterContext
|
||||||
public var style: RDEPUBTextRenderStyle
|
public var style: RDEPUBTextRenderStyle
|
||||||
/// 当前章节将被分页到的页面尺寸;用于让附件缩放贴近真实 page size。
|
|
||||||
public var pageSize: CGSize?
|
|
||||||
/// 当前分页布局配置;用于生成与 WXRead 更接近的内容区尺寸。
|
|
||||||
public var layoutConfig: RDEPUBTextLayoutConfig?
|
|
||||||
|
|
||||||
public init(
|
public init(context: RDEPUBTextChapterContext, style: RDEPUBTextRenderStyle) {
|
||||||
context: RDEPUBTextChapterContext,
|
|
||||||
style: RDEPUBTextRenderStyle,
|
|
||||||
pageSize: CGSize? = nil,
|
|
||||||
layoutConfig: RDEPUBTextLayoutConfig? = nil
|
|
||||||
) {
|
|
||||||
self.context = context
|
self.context = context
|
||||||
self.style = style
|
self.style = style
|
||||||
self.pageSize = pageSize
|
|
||||||
self.layoutConfig = layoutConfig
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -197,9 +197,7 @@ enum RDEPUBTextRendererSupport {
|
|||||||
baseURL: URL?,
|
baseURL: URL?,
|
||||||
style: RDEPUBTextRenderStyle,
|
style: RDEPUBTextRenderStyle,
|
||||||
resourceResolver: RDEPUBResourceResolver?,
|
resourceResolver: RDEPUBResourceResolver?,
|
||||||
contentLanguageCode: String? = nil,
|
contentLanguageCode: String? = nil
|
||||||
pageSize: CGSize? = nil,
|
|
||||||
layoutConfig: RDEPUBTextLayoutConfig? = nil
|
|
||||||
) -> RDEPUBTextChapterRenderRequest {
|
) -> RDEPUBTextChapterRenderRequest {
|
||||||
let normalizedHTML = injectPaginationSemanticMarkers(into: normalizeHTML(rawHTML))
|
let normalizedHTML = injectPaginationSemanticMarkers(into: normalizeHTML(rawHTML))
|
||||||
let stylesheetHrefReplacements = inlineLinkedStyleSheets(
|
let stylesheetHrefReplacements = inlineLinkedStyleSheets(
|
||||||
@ -257,12 +255,7 @@ enum RDEPUBTextRendererSupport {
|
|||||||
stylesheet: RDEPUBTextStyleSheetPackage(layers: layers),
|
stylesheet: RDEPUBTextStyleSheetPackage(layers: layers),
|
||||||
resourceDiagnostics: resourceDiagnostics
|
resourceDiagnostics: resourceDiagnostics
|
||||||
)
|
)
|
||||||
return RDEPUBTextChapterRenderRequest(
|
return RDEPUBTextChapterRenderRequest(context: context, style: style)
|
||||||
context: context,
|
|
||||||
style: style,
|
|
||||||
pageSize: pageSize,
|
|
||||||
layoutConfig: layoutConfig
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HTML 规范化:清理冗余字符(CR、多余换行),规范化附件 HTML 标记
|
/// HTML 规范化:清理冗余字符(CR、多余换行),规范化附件 HTML 标记
|
||||||
@ -292,20 +285,15 @@ enum RDEPUBTextRendererSupport {
|
|||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
static func prepareHTMLElementForReaderRendering(
|
static func prepareHTMLElementForReaderRendering(
|
||||||
_ element: DTHTMLElement,
|
_ element: DTHTMLElement,
|
||||||
style: RDEPUBTextRenderStyle,
|
style: RDEPUBTextRenderStyle
|
||||||
maxImageSize: CGSize? = nil
|
|
||||||
) {
|
) {
|
||||||
guard let attachment = element.textAttachment else { return }
|
guard let attachment = element.textAttachment else { return }
|
||||||
let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize)
|
let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize)
|
||||||
let fallbackSize = CGSize(
|
let maxSize = CGSize(
|
||||||
width: round(UIScreen.main.bounds.insetBy(dx: 20, dy: 28).width),
|
width: round(UIScreen.main.bounds.insetBy(dx: 20, dy: 28).width),
|
||||||
height: round(UIScreen.main.bounds.insetBy(dx: 20, dy: 28).height * 0.85)
|
height: round(UIScreen.main.bounds.insetBy(dx: 20, dy: 28).height * 0.85)
|
||||||
)
|
)
|
||||||
normalizeAttachmentLayoutForWXRead(
|
normalizeAttachmentLayoutForWXRead(attachment, fontPointSize: pointSize, maxImageSize: maxSize)
|
||||||
attachment,
|
|
||||||
fontPointSize: pointSize,
|
|
||||||
maxImageSize: maxImageSize ?? fallbackSize
|
|
||||||
)
|
|
||||||
if isFootnoteAttachment(attachment) {
|
if isFootnoteAttachment(attachment) {
|
||||||
element.displayStyle = .inline
|
element.displayStyle = .inline
|
||||||
} else if isCoverAttachment(attachment) {
|
} else if isCoverAttachment(attachment) {
|
||||||
@ -402,38 +390,6 @@ enum RDEPUBTextRendererSupport {
|
|||||||
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
|
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
|
||||||
var normalized = html
|
var normalized = html
|
||||||
|
|
||||||
if let bodyPicContainerRegex = try? NSRegularExpression(
|
|
||||||
pattern: #"<div\b([^>]*class\s*=\s*["'][^"']*\b(?:qrbodyPic|bodyPic)\b[^"']*["'][^>]*)>([\s\S]*?)</div>"#,
|
|
||||||
options: [.caseInsensitive]
|
|
||||||
) {
|
|
||||||
normalized = replaceMatches(
|
|
||||||
using: bodyPicContainerRegex,
|
|
||||||
in: normalized
|
|
||||||
) { tag in
|
|
||||||
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, options: [.caseInsensitive]) else {
|
|
||||||
return tag
|
|
||||||
}
|
|
||||||
|
|
||||||
return replaceMatches(
|
|
||||||
using: imageTagRegex,
|
|
||||||
in: tag
|
|
||||||
) { imageTag in
|
|
||||||
mergeHTMLAttributes(
|
|
||||||
into: imageTag,
|
|
||||||
requiredClass: "bodyPic",
|
|
||||||
styleFragments: [
|
|
||||||
"wr-vertical-center-style:2",
|
|
||||||
"max-width:100%",
|
|
||||||
"height:auto",
|
|
||||||
"display:block",
|
|
||||||
"margin-left:auto",
|
|
||||||
"margin-right:auto"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let footnoteRegex = try? NSRegularExpression(
|
if let footnoteRegex = try? NSRegularExpression(
|
||||||
pattern: #"<img\b([^>]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#,
|
pattern: #"<img\b([^>]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#,
|
||||||
options: [.caseInsensitive]
|
options: [.caseInsensitive]
|
||||||
|
|||||||
@ -139,10 +139,9 @@ struct RDEPUBPageLayoutSnapshot {
|
|||||||
var lines: [RDEPUBPageLine] = []
|
var lines: [RDEPUBPageLine] = []
|
||||||
var runs: [RDEPUBPageRun] = []
|
var runs: [RDEPUBPageRun] = []
|
||||||
var attachments: [RDEPUBPageAttachment] = []
|
var attachments: [RDEPUBPageAttachment] = []
|
||||||
let pageOffset = page.pageStartOffset
|
|
||||||
|
|
||||||
for dtLine in dtLines {
|
for dtLine in dtLines {
|
||||||
let lineRange = offset(dtLine.stringRange(), by: pageOffset)
|
let lineRange = dtLine.stringRange()
|
||||||
let line = RDEPUBPageLine(
|
let line = RDEPUBPageLine(
|
||||||
stringRange: lineRange,
|
stringRange: lineRange,
|
||||||
frame: dtLine.frame,
|
frame: dtLine.frame,
|
||||||
@ -155,7 +154,7 @@ struct RDEPUBPageLayoutSnapshot {
|
|||||||
|
|
||||||
if let glyphRuns = dtLine.glyphRuns as? [DTCoreTextGlyphRun] {
|
if let glyphRuns = dtLine.glyphRuns as? [DTCoreTextGlyphRun] {
|
||||||
for run in glyphRuns {
|
for run in glyphRuns {
|
||||||
let runRange = offset(run.stringRange(), by: pageOffset)
|
let runRange = run.stringRange()
|
||||||
let isAttachment = run.attachment != nil
|
let isAttachment = run.attachment != nil
|
||||||
runs.append(
|
runs.append(
|
||||||
RDEPUBPageRun(
|
RDEPUBPageRun(
|
||||||
@ -183,7 +182,7 @@ struct RDEPUBPageLayoutSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let visibleRange = offset(layoutFrame.visibleStringRange(), by: pageOffset)
|
let visibleRange = layoutFrame.visibleStringRange()
|
||||||
|
|
||||||
return RDEPUBPageLayoutSnapshot(
|
return RDEPUBPageLayoutSnapshot(
|
||||||
page: page,
|
page: page,
|
||||||
@ -211,12 +210,5 @@ struct RDEPUBPageLayoutSnapshot {
|
|||||||
: nil
|
: nil
|
||||||
return (placement, kind)
|
return (placement, kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func offset(_ range: NSRange, by offset: Int) -> NSRange {
|
|
||||||
guard range.location != NSNotFound else {
|
|
||||||
return range
|
|
||||||
}
|
|
||||||
return NSRange(location: range.location + offset, length: range.length)
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@ -165,8 +165,6 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
private var currentBrightness: CGFloat
|
private var currentBrightness: CGFloat
|
||||||
private var didStartInitialLoad = false
|
private var didStartInitialLoad = false
|
||||||
private var isRepaginating = false
|
private var isRepaginating = false
|
||||||
private var lastTextPaginationPageSize: CGSize?
|
|
||||||
private var isReconcilingTextPaginationSize = false
|
|
||||||
private var paginationToken = UUID()
|
private var paginationToken = UUID()
|
||||||
private var paginator: RDEPUBPaginator?
|
private var paginator: RDEPUBPaginator?
|
||||||
private var searchState: RDEPUBSearchState?
|
private var searchState: RDEPUBSearchState?
|
||||||
@ -697,7 +695,6 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
if publication.readingProfile == .textReflowable {
|
if publication.readingProfile == .textReflowable {
|
||||||
let renderer = resolvedTextRenderer()
|
let renderer = resolvedTextRenderer()
|
||||||
let pageSize = currentTextPageSize()
|
let pageSize = currentTextPageSize()
|
||||||
lastTextPaginationPageSize = pageSize
|
|
||||||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||||
let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache, layoutConfig: layoutConfig)
|
let builder = RDEPUBTextBookBuilder(renderer: renderer, cache: textBookCache, layoutConfig: layoutConfig)
|
||||||
let renderStyle = currentTextRenderStyle()
|
let renderStyle = currentTextRenderStyle()
|
||||||
@ -1162,10 +1159,6 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
navigationController.popViewController(animated: true)
|
navigationController.popViewController(animated: true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let navigationController, navigationController.presentingViewController != nil {
|
|
||||||
navigationController.dismiss(animated: true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1265,15 +1258,12 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func currentTextPageSize() -> CGSize {
|
private func currentTextPageSize() -> CGSize {
|
||||||
let pageNum = readerView.currentPage >= 0 ? readerView.currentPage : nil
|
let viewportSize = currentLayoutContext().viewportSize
|
||||||
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
|
let insets = configuration.reflowableContentInsets
|
||||||
if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
|
return CGSize(
|
||||||
print("[PaginationDebug] currentTextPageSize currentPage=\(readerView.currentPage) resolved=\(NSCoder.string(for: resolvedSize)) readerBounds=\(NSCoder.string(for: readerView.bounds)) viewBounds=\(NSCoder.string(for: view.bounds)) safe=\(NSCoder.string(for: view.safeAreaInsets))")
|
width: max(viewportSize.width - insets.left - insets.right, 1),
|
||||||
}
|
height: max(viewportSize.height - insets.top - insets.bottom, 1)
|
||||||
if resolvedSize.width > 0, resolvedSize.height > 0 {
|
)
|
||||||
return resolvedSize
|
|
||||||
}
|
|
||||||
return currentLayoutContext().viewportSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||||
@ -1288,10 +1278,15 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
|
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(
|
return RDEPUBTextLayoutConfig(
|
||||||
frameWidth: max(pageSize.width, 1),
|
frameWidth: derivedFrameSize.width,
|
||||||
frameHeight: max(pageSize.height, 1),
|
frameHeight: derivedFrameSize.height,
|
||||||
edgeInsets: configuration.reflowableContentInsets,
|
edgeInsets: .zero,
|
||||||
numberOfColumns: configuration.numberOfColumns,
|
numberOfColumns: configuration.numberOfColumns,
|
||||||
columnGap: configuration.columnGap,
|
columnGap: configuration.columnGap,
|
||||||
avoidOrphans: true,
|
avoidOrphans: true,
|
||||||
@ -1839,7 +1834,6 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
|||||||
|
|
||||||
public func pageNum(readerView: RDReaderView, pageNum: Int) {
|
public func pageNum(readerView: RDReaderView, pageNum: Int) {
|
||||||
updateCurrentSelection(nil)
|
updateCurrentSelection(nil)
|
||||||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
|
||||||
|
|
||||||
let totalPages = pageCountOfReaderView(readerView: readerView)
|
let totalPages = pageCountOfReaderView(readerView: readerView)
|
||||||
if totalPages > 0, pageNum == totalPages - 1 {
|
if totalPages > 0, pageNum == totalPages - 1 {
|
||||||
@ -1865,36 +1859,6 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
|||||||
_ = isLandscape
|
_ = isLandscape
|
||||||
pendingPresentationRestoreLocation = currentVisibleLocation() ?? persistenceLocation()
|
pendingPresentationRestoreLocation = currentVisibleLocation() ?? persistenceLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
|
|
||||||
guard textBook != nil,
|
|
||||||
!isRepaginating,
|
|
||||||
!isReconcilingTextPaginationSize,
|
|
||||||
pageNum >= 0,
|
|
||||||
let lastTextPaginationPageSize else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
|
|
||||||
guard resolvedSize.width > 0,
|
|
||||||
resolvedSize.height > 0 else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let sizeChanged = abs(resolvedSize.width - lastTextPaginationPageSize.width) > 0.5
|
|
||||||
|| abs(resolvedSize.height - lastTextPaginationPageSize.height) > 0.5
|
|
||||||
guard sizeChanged else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isReconcilingTextPaginationSize = true
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.isReconcilingTextPaginationSize = false
|
|
||||||
guard self.textBook != nil, !self.isRepaginating else { return }
|
|
||||||
self.repaginatePreservingCurrentLocation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Web 内容视图代理(EPUB 固定布局/Web 渲染路径)
|
// MARK: - Web 内容视图代理(EPUB 固定布局/Web 渲染路径)
|
||||||
|
|||||||
@ -256,7 +256,7 @@ final class RDEPUBTextContentView: UIView {
|
|||||||
coverImageView.isHidden = true
|
coverImageView.isHidden = true
|
||||||
coverImageView.image = nil
|
coverImageView.image = nil
|
||||||
|
|
||||||
let selectionContent = normalizedPageContent(from: page)
|
let selectionContent = NSMutableAttributedString(attributedString: page.content)
|
||||||
let selectionRange = NSRange(location: 0, length: selectionContent.length)
|
let selectionRange = NSRange(location: 0, length: selectionContent.length)
|
||||||
selectionContent.addAttribute(
|
selectionContent.addAttribute(
|
||||||
.foregroundColor,
|
.foregroundColor,
|
||||||
@ -265,7 +265,7 @@ final class RDEPUBTextContentView: UIView {
|
|||||||
)
|
)
|
||||||
|
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
let displayContent = normalizedPageContent(from: page)
|
let displayContent = NSMutableAttributedString(attributedString: page.chapterContent)
|
||||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
let fullRange = NSRange(location: 0, length: displayContent.length)
|
||||||
displayContent.addAttribute(
|
displayContent.addAttribute(
|
||||||
.foregroundColor,
|
.foregroundColor,
|
||||||
@ -275,7 +275,7 @@ final class RDEPUBTextContentView: UIView {
|
|||||||
coreTextContentView.isHidden = false
|
coreTextContentView.isHidden = false
|
||||||
coreTextContentView.backgroundColor = .clear
|
coreTextContentView.backgroundColor = .clear
|
||||||
coreTextDisplayContent = displayContent
|
coreTextDisplayContent = displayContent
|
||||||
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
|
coreTextDisplayRange = page.contentRange
|
||||||
textView.isHidden = true
|
textView.isHidden = true
|
||||||
textView.isUserInteractionEnabled = false
|
textView.isUserInteractionEnabled = false
|
||||||
textView.attributedText = nil
|
textView.attributedText = nil
|
||||||
@ -646,43 +646,6 @@ final class RDEPUBTextContentView: UIView {
|
|||||||
return proxy
|
return proxy
|
||||||
}
|
}
|
||||||
|
|
||||||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
|
||||||
let content = NSMutableAttributedString(attributedString: page.content)
|
|
||||||
guard shouldNormalizeContinuationParagraph(for: page) else {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = content.string as NSString
|
|
||||||
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
|
|
||||||
guard firstParagraphRange.length > 0 else {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
|
|
||||||
guard let style = value as? NSParagraphStyle else { return }
|
|
||||||
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
|
|
||||||
mutableStyle.firstLineHeadIndent = mutableStyle.headIndent
|
|
||||||
mutableStyle.paragraphSpacingBefore = 0
|
|
||||||
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range)
|
|
||||||
}
|
|
||||||
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
|
|
||||||
let pageStart = page.pageStartOffset
|
|
||||||
guard pageStart > 0, pageStart < page.chapterContent.length else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let chapterText = page.chapterContent.string as NSString
|
|
||||||
guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return !CharacterSet.newlines.contains(previousScalar)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if canImport(DTCoreText)
|
#if canImport(DTCoreText)
|
||||||
private func updateCoreTextLayoutFrameIfNeeded() {
|
private func updateCoreTextLayoutFrameIfNeeded() {
|
||||||
guard !coreTextContentView.isHidden,
|
guard !coreTextContentView.isHidden,
|
||||||
|
|||||||
@ -123,7 +123,7 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
layoutConfig: RDEPUBTextLayoutConfig(
|
layoutConfig: RDEPUBTextLayoutConfig(
|
||||||
frameWidth: pageSize.width,
|
frameWidth: pageSize.width,
|
||||||
frameHeight: pageSize.height,
|
frameHeight: pageSize.height,
|
||||||
edgeInsets: epubConfiguration.reflowableContentInsets,
|
edgeInsets: .zero,
|
||||||
numberOfColumns: 1,
|
numberOfColumns: 1,
|
||||||
columnGap: 20,
|
columnGap: 20,
|
||||||
avoidOrphans: true,
|
avoidOrphans: true,
|
||||||
@ -179,11 +179,15 @@ public final class RDURLReaderController: UIViewController {
|
|||||||
print("[ReadViewDemo] automation \(prefix) -> page \(page) href \(href) progression \(progression)")
|
print("[ReadViewDemo] automation \(prefix) -> page \(page) href \(href) progression \(progression)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 计算文本分页的页面尺寸。
|
/// 计算文本分页的页面尺寸
|
||||||
/// 分页器使用完整 viewport,真实可绘制区域由 layoutConfig.edgeInsets 控制,
|
/// 基于屏幕尺寸和配置的内边距
|
||||||
/// 以保持与页面展示时的内容几何一致。
|
|
||||||
private func currentTextPageSize() -> CGSize {
|
private func currentTextPageSize() -> CGSize {
|
||||||
UIScreen.main.bounds.size
|
let viewportSize = UIScreen.main.bounds.size
|
||||||
|
let insets = epubConfiguration.reflowableContentInsets
|
||||||
|
return CGSize(
|
||||||
|
width: max(viewportSize.width - insets.left - insets.right, 1),
|
||||||
|
height: max(viewportSize.height - insets.top - insets.bottom, 1)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据当前配置生成文本渲染样式
|
/// 根据当前配置生成文本渲染样式
|
||||||
|
|||||||
@ -309,10 +309,6 @@ public class RDReaderView: UIView {
|
|||||||
private var topToolView: UIView?
|
private var topToolView: UIView?
|
||||||
/// 底部工具栏视图
|
/// 底部工具栏视图
|
||||||
private var bottomToolView: UIView?
|
private var bottomToolView: UIView?
|
||||||
/// 顶部工具栏高度约束
|
|
||||||
private var topToolViewHeightConstraint: NSLayoutConstraint?
|
|
||||||
/// 底部工具栏高度约束
|
|
||||||
private var bottomToolViewHeightConstraint: NSLayoutConstraint?
|
|
||||||
/// 工具栏是否正在显示
|
/// 工具栏是否正在显示
|
||||||
private var isShowToolView: Bool = false
|
private var isShowToolView: Bool = false
|
||||||
/// 是否正在执行页面转场动画
|
/// 是否正在执行页面转场动画
|
||||||
@ -368,7 +364,6 @@ public class RDReaderView: UIView {
|
|||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||||
preloadHostView.frame = bounds
|
preloadHostView.frame = bounds
|
||||||
updateToolViewHeightConstraintsIfNeeded()
|
|
||||||
let nowLandscape = isLandscape
|
let nowLandscape = isLandscape
|
||||||
if let prev = previousIsLandscape, prev != nowLandscape {
|
if let prev = previousIsLandscape, prev != nowLandscape {
|
||||||
previousIsLandscape = nowLandscape
|
previousIsLandscape = nowLandscape
|
||||||
@ -629,29 +624,17 @@ public class RDReaderView: UIView {
|
|||||||
/// 获取用于显示的页面视图
|
/// 获取用于显示的页面视图
|
||||||
/// 优先从缓存中获取,其次从预加载缓存中获取,最后新建
|
/// 优先从缓存中获取,其次从预加载缓存中获取,最后新建
|
||||||
private func pageViewForDisplay(pageNum: Int) -> UIView {
|
private func pageViewForDisplay(pageNum: Int) -> UIView {
|
||||||
let reusableView = detachedReusablePageView(for: pageNum)
|
if let cached = pageCurlCachedViews[pageNum] {
|
||||||
let view = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: reusableView)
|
return cached
|
||||||
?? reusableView
|
|
||||||
?? UIView()
|
|
||||||
pageCurlCachedViews[pageNum] = view
|
|
||||||
return view
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 返回可安全复用的 page view。
|
|
||||||
/// 只有已经脱离视图层级的 view 才能复用,避免 pageCurl 转场期间同一个 UIView
|
|
||||||
/// 被多个 child controller 争用,从而导致前一个页面瞬时空白。
|
|
||||||
private func detachedReusablePageView(for pageNum: Int) -> UIView? {
|
|
||||||
if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) {
|
if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) {
|
||||||
preloaded.removeFromSuperview()
|
preloaded.removeFromSuperview()
|
||||||
|
pageCurlCachedViews[pageNum] = preloaded
|
||||||
return preloaded
|
return preloaded
|
||||||
}
|
}
|
||||||
|
let view = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: nil) ?? UIView()
|
||||||
guard let cached = pageCurlCachedViews[pageNum], cached.superview == nil else {
|
pageCurlCachedViews[pageNum] = view
|
||||||
return nil
|
return view
|
||||||
}
|
|
||||||
|
|
||||||
pageCurlCachedViews.removeValue(forKey: pageNum)
|
|
||||||
return cached
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 裁剪缓存,只保留指定页码集合中的页面
|
/// 裁剪缓存,只保留指定页码集合中的页面
|
||||||
@ -722,7 +705,7 @@ public class RDReaderView: UIView {
|
|||||||
preloadHostView.frame = bounds
|
preloadHostView.frame = bounds
|
||||||
|
|
||||||
for targetPage in targets {
|
for targetPage in targets {
|
||||||
let existing = detachedReusablePageView(for: targetPage)
|
let existing = preloadedPageViews[targetPage] ?? pageCurlCachedViews.removeValue(forKey: targetPage)
|
||||||
let contentView = dataSource?.pageContentView(readerView: self, pageNum: targetPage, containerView: existing) ?? existing ?? UIView()
|
let contentView = dataSource?.pageContentView(readerView: self, pageNum: targetPage, containerView: existing) ?? existing ?? UIView()
|
||||||
preloadedPageViews[targetPage] = contentView
|
preloadedPageViews[targetPage] = contentView
|
||||||
if contentView.superview !== preloadHostView {
|
if contentView.superview !== preloadHostView {
|
||||||
@ -854,7 +837,13 @@ public class RDReaderView: UIView {
|
|||||||
isShowToolView = !isShowToolView
|
isShowToolView = !isShowToolView
|
||||||
if isShowToolView {
|
if isShowToolView {
|
||||||
if let topToolView = topToolView {
|
if let topToolView = topToolView {
|
||||||
installToolViewIfNeeded(topToolView, position: .top)
|
addSubview(topToolView)
|
||||||
|
topToolView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
topToolView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
topToolView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
topToolView.topAnchor.constraint(equalTo: topAnchor)
|
||||||
|
])
|
||||||
layoutIfNeeded()
|
layoutIfNeeded()
|
||||||
topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height)
|
topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height)
|
||||||
UIView.animate(withDuration: toolViewAnimationDuration) {
|
UIView.animate(withDuration: toolViewAnimationDuration) {
|
||||||
@ -863,7 +852,13 @@ public class RDReaderView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let bottomToolView = bottomToolView {
|
if let bottomToolView = bottomToolView {
|
||||||
installToolViewIfNeeded(bottomToolView, position: .bottom)
|
addSubview(bottomToolView)
|
||||||
|
bottomToolView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
bottomToolView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
bottomToolView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
bottomToolView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||||
|
])
|
||||||
layoutIfNeeded()
|
layoutIfNeeded()
|
||||||
bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height)
|
bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height)
|
||||||
UIView.animate(withDuration: toolViewAnimationDuration) {
|
UIView.animate(withDuration: toolViewAnimationDuration) {
|
||||||
@ -905,58 +900,6 @@ public class RDReaderView: UIView {
|
|||||||
return hitView === toolView || hitView.isDescendant(of: toolView)
|
return hitView === toolView || hitView.isDescendant(of: toolView)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum ToolViewPosition {
|
|
||||||
case top
|
|
||||||
case bottom
|
|
||||||
}
|
|
||||||
|
|
||||||
private func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition) {
|
|
||||||
guard toolView.superview !== self else { return }
|
|
||||||
|
|
||||||
toolView.removeFromSuperview()
|
|
||||||
addSubview(toolView)
|
|
||||||
toolView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
let heightConstraint: NSLayoutConstraint
|
|
||||||
switch position {
|
|
||||||
case .top:
|
|
||||||
topToolViewHeightConstraint?.isActive = false
|
|
||||||
heightConstraint = toolView.heightAnchor.constraint(equalToConstant: resolvedToolViewHeight(for: .top))
|
|
||||||
topToolViewHeightConstraint = heightConstraint
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
toolView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
toolView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
toolView.topAnchor.constraint(equalTo: topAnchor),
|
|
||||||
heightConstraint
|
|
||||||
])
|
|
||||||
case .bottom:
|
|
||||||
bottomToolViewHeightConstraint?.isActive = false
|
|
||||||
heightConstraint = toolView.heightAnchor.constraint(equalToConstant: resolvedToolViewHeight(for: .bottom))
|
|
||||||
bottomToolViewHeightConstraint = heightConstraint
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
toolView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
||||||
toolView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
||||||
toolView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
heightConstraint
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateToolViewHeightConstraintsIfNeeded() {
|
|
||||||
topToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .top)
|
|
||||||
bottomToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .bottom)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolvedToolViewHeight(for position: ToolViewPosition) -> CGFloat {
|
|
||||||
let contentHeight: CGFloat = 52
|
|
||||||
switch position {
|
|
||||||
case .top:
|
|
||||||
return safeAreaInsets.top + contentHeight
|
|
||||||
case .bottom:
|
|
||||||
return safeAreaInsets.bottom + contentHeight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换翻页模式(仿真/水平滚动/上下滚动)
|
/// 切换翻页模式(仿真/水平滚动/上下滚动)
|
||||||
/// 会重建底层视图(PageViewController 或 CollectionView),并恢复到当前页
|
/// 会重建底层视图(PageViewController 或 CollectionView),并恢复到当前页
|
||||||
/// - Parameter displayType: 目标翻页模式
|
/// - Parameter displayType: 目标翻页模式
|
||||||
@ -1322,47 +1265,6 @@ extension RDReaderView {
|
|||||||
return cell?.containerView
|
return cell?.containerView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 返回当前阅读模式下单页内容容器的实际尺寸。
|
|
||||||
/// 优先使用已经显示出来的内容视图尺寸;若页面尚未装载,则回退到当前模式下的理论单页尺寸。
|
|
||||||
public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize {
|
|
||||||
let targetPage = pageNum ?? (currentPage >= 0 ? currentPage : nil)
|
|
||||||
if let targetPage,
|
|
||||||
let contentView = pageContentView(pageNum: targetPage),
|
|
||||||
contentView.bounds.width > 0,
|
|
||||||
contentView.bounds.height > 0 {
|
|
||||||
return contentView.bounds.size
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentDisplayType == .pageCurl {
|
|
||||||
if let childViewController = pageViewController.viewControllers?.first as? RDReaderPageChildViewController,
|
|
||||||
childViewController.view.bounds.width > 0,
|
|
||||||
childViewController.view.bounds.height > 0 {
|
|
||||||
return childViewController.view.bounds.size
|
|
||||||
}
|
|
||||||
if pageViewController.view.bounds.width > 0, pageViewController.view.bounds.height > 0 {
|
|
||||||
return pageViewController.view.bounds.size
|
|
||||||
}
|
|
||||||
return bounds.size
|
|
||||||
}
|
|
||||||
|
|
||||||
let containerBounds = collectionView.bounds.size == .zero ? bounds.size : collectionView.bounds.size
|
|
||||||
guard containerBounds.width > 0, containerBounds.height > 0 else {
|
|
||||||
return bounds.size
|
|
||||||
}
|
|
||||||
|
|
||||||
switch currentDisplayType {
|
|
||||||
case .horizontalScroll:
|
|
||||||
return CGSize(
|
|
||||||
width: containerBounds.width / CGFloat(max(pagesPerScreen, 1)),
|
|
||||||
height: containerBounds.height
|
|
||||||
)
|
|
||||||
case .verticalScroll:
|
|
||||||
return containerBounds
|
|
||||||
case .pageCurl:
|
|
||||||
return bounds.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var cellViewKey: Int8 = 0
|
private var cellViewKey: Int8 = 0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user