diff --git a/ReadViewDemo/ReadViewDemo.xcworkspace/xcuserdata/shen.xcuserdatad/UserInterfaceState.xcuserstate b/ReadViewDemo/ReadViewDemo.xcworkspace/xcuserdata/shen.xcuserdatad/UserInterfaceState.xcuserstate index add3653..904f89c 100644 Binary files a/ReadViewDemo/ReadViewDemo.xcworkspace/xcuserdata/shen.xcuserdatad/UserInterfaceState.xcuserstate and b/ReadViewDemo/ReadViewDemo.xcworkspace/xcuserdata/shen.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift index f0f7090..e22c841 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift @@ -223,6 +223,10 @@ public final class RDEPUBTextBookBuilder { self.init(renderer: RDEPUBDTCoreTextRenderer()) } + private var isPaginationDebugEnabled: Bool { + ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") + } + /// 生成最近一次构建的语义摘要,用于 Phase 7 质量检测日志 public func phase7SemanticSummary(title: String? = nil) -> String? { guard !lastBuildPaginationDiagnostics.isEmpty else { return nil } @@ -263,6 +267,9 @@ public final class RDEPUBTextBookBuilder { pageSize: CGSize, style: RDEPUBTextRenderStyle ) throws -> RDEPUBTextBook { + if isPaginationDebugEnabled { + print("[PaginationDebug] build pageSize=\(NSCoder.string(for: pageSize)) layoutInsets=\(NSCoder.string(for: layoutConfig.edgeInsets))") + } var chapters: [RDEPUBTextChapter] = [] var flatPages: [RDEPUBTextPage] = [] lastBuildResourceDiagnostics = [] @@ -437,6 +444,18 @@ 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( RDEPUBTextChapter( chapterIndex: chapterIndex, @@ -575,11 +594,26 @@ public final class RDEPUBTextBookBuilder { 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 "" } + if collapsed.count <= limit { + return collapsed + } + let head = collapsed.prefix(limit) + return "\(head)…" + } + // MARK: - 尾页规范化 - /// 规范化分页结果的尾部页面: - /// 1. 丢弃纯空白的尾页 - /// 2. 将过短的尾页(≤2 字符)合并到前一页 + /// 规范化分页结果: + /// 1. 移除章节中间误产生的纯空白页 + /// 2. 丢弃纯空白的尾页 + /// 3. 将过短的尾页(≤2 字符)合并到前一页 private func normalizeTrailingFrames( _ frames: [RDEPUBTextLayoutFrame], content: NSAttributedString, @@ -589,9 +623,28 @@ public final class RDEPUBTextBookBuilder { 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, - shouldDropWhitespaceOnlyTrailingFrame(lastFrame, in: content) { + shouldDropWhitespaceOnlyFrame(lastFrame, in: content) { normalized.removeLast() let note = "normalized: dropped whitespace-only trailing page \(NSStringFromRange(lastFrame.contentRange))" if var previousFrame = normalized.popLast() { @@ -617,7 +670,7 @@ public final class RDEPUBTextBookBuilder { } /// 判断尾页是否为纯空白(无可显示字符且无附件) - private func shouldDropWhitespaceOnlyTrailingFrame( + private func shouldDropWhitespaceOnlyFrame( _ frame: RDEPUBTextLayoutFrame, in content: NSAttributedString ) -> Bool { @@ -737,7 +790,7 @@ public final class RDEPUBTextBookBuilder { bookID: bookID, fontSize: style.font.pointSize, lineHeightMultiple: style.lineSpacing, - contentInsets: .zero, + contentInsets: layoutConfig.edgeInsets, pageSize: pageSize, layoutConfigSignature: layoutConfig.cacheSignature ) diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift index 3c9d021..2322f11 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift @@ -123,7 +123,8 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding { public final class RDEPUBTextBookCache { /// 缓存模式版本号,变更时旧缓存自动失效 - public var schemaVersion: Int = 4 + // 分页算法调整后需要提升版本,避免继续复用旧页范围缓存。 + public var schemaVersion: Int = 6 /// 串行队列,保证缓存读写的线程安全 private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility) diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift index 1930713..177a501 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextLayouter.swift @@ -67,23 +67,42 @@ struct RDEPUBTextLayouter { var frames: [RDEPUBTextLayoutFrame] = [] 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 { - let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), path, nil) - let visibleRange = CTFrameGetVisibleStringRange(frame) - guard visibleRange.length > 0 else { + let framePath = CGMutablePath() + // 对齐 WXRead WRChapterPageCount: + // CoreText 分页路径使用 bottom inset 作为 y 原点,而不是 UIKit 语义的 top inset。 + 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 } - let proposedRange = NSRange(location: location, length: visibleRange.length) - // 行级 avoidPageBreakInside 处理(WXRead 方案:从最后一行向前扫描) let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange) - let lineAdjusted = trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted) let lineRanges = lineRanges(from: frame) let adjusted = adjustedRange( - from: lineAdjusted, + from: avoidAdjusted, totalLength: attributedString.length, lineRanges: lineRanges ) @@ -108,7 +127,7 @@ struct RDEPUBTextLayouter { let nextLocation = adjusted.range.location + adjusted.range.length guard nextLocation > location else { - location += max(visibleRange.length, 1) + location += max(proposedRange.length, 1) continue } location = nextLocation @@ -155,7 +174,12 @@ struct RDEPUBTextLayouter { totalLength: attributedString.length, lineRanges: lineRanges ) - let verifiedRange = verifiedDisplayRange(for: adjusted.range) + let verifiedRange: NSRange + if adjusted.breakReason == .attachmentBoundary { + verifiedRange = verifiedDisplayRange(for: adjusted.range) + } else { + verifiedRange = adjusted.range + } let trailingFragmentID = nearestTrailingFragmentID( endingAt: verifiedRange.location + verifiedRange.length, fragmentOffsets: fragmentOffsets @@ -191,27 +215,28 @@ struct RDEPUBTextLayouter { } private func verifiedDisplayRange(for range: NSRange) -> NSRange { - guard range.length > 0, - !attachmentRanges(in: range).isEmpty else { + guard let clampedRange = clampedRange(range), + clampedRange.length > 0, + !attachmentRanges(in: clampedRange).isEmpty else { return range } - let pageContent = attributedString.attributedSubstring(from: range) + let pageContent = attributedString.attributedSubstring(from: clampedRange) guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else { - return range + return clampedRange } layouter.shouldCacheLayoutFrames = false guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else { - return range + return clampedRange } let visibleRange = layoutFrame.visibleStringRange() - guard visibleRange.length > 0, visibleRange.length < range.length else { - return range + guard visibleRange.length > 0, visibleRange.length < clampedRange.length else { + return clampedRange } - return NSRange(location: range.location, length: visibleRange.length) + return NSRange(location: clampedRange.location, length: visibleRange.length) } #endif @@ -281,10 +306,6 @@ 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 currentAttachmentRanges = attachmentRanges(in: proposedRange) let currentAttachmentKinds = attachmentKinds(in: proposedRange) @@ -292,38 +313,12 @@ struct RDEPUBTextLayouter { let currentSemanticHints = proposedSemanticHints let currentAttachmentPlacements = proposedAttachmentPlacements - // 1. 语义边界(pageBreakBefore/After) - 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 跨页关联边界 + // 对齐 WXRead:默认按 CTFrame 已经容纳的行数分页,仅保留 pageRelate 这种 + // 微信读书特有的跨页关联规则;其他 keepWithNext/attachmentBoundary/通用语义边界 + // 先不参与截断,避免提前裁短页尾内容。 if let pageRelateBoundary = preferredPageRelateBoundary( after: proposedRange, - minimumEnd: minimumEnd, + minimumEnd: proposedRange.location + 1, lineRanges: lineRanges ) { let adjustedRange = NSRange(location: proposedRange.location, length: pageRelateBoundary - proposedRange.location) @@ -349,33 +344,6 @@ 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. 帧限制(默认分页) return ( range: proposedRange, @@ -407,8 +375,11 @@ struct RDEPUBTextLayouter { in range: NSRange, minimumEnd: Int ) -> (location: Int, trigger: String)? { + guard let safeRange = clampedRange(range), safeRange.length > 0 else { + return nil + } var boundary: (location: Int, trigger: String)? - attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, attributeRange, stop in + attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, attributeRange, stop in guard let rawValue = value as? String else { return } let hints = rawValue .split(separator: ",") @@ -416,7 +387,7 @@ struct RDEPUBTextLayouter { guard !hints.isEmpty else { return } if hints.contains(.pageBreakBefore), - attributeRange.location > range.location, + attributeRange.location > safeRange.location, attributeRange.location >= minimumEnd { boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue) stop.pointee = true @@ -426,7 +397,7 @@ struct RDEPUBTextLayouter { let attributeEnd = attributeRange.location + attributeRange.length if hints.contains(.pageBreakAfter), attributeEnd > minimumEnd, - attributeEnd < range.location + range.length { + attributeEnd < safeRange.location + safeRange.length { boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue) stop.pointee = true return @@ -438,8 +409,11 @@ struct RDEPUBTextLayouter { /// 查找附件边界:只有块级附件(.attachment 或 .centered)才触发分页, /// 行内脚注图标等不应导致整段移动。 private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? { + guard let safeRange = clampedRange(range), safeRange.length > 0 else { + return nil + } var boundary: Int? - attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in + attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, stop in guard value != nil else { return } let location = attributeRange.location @@ -448,11 +422,21 @@ struct RDEPUBTextLayouter { // 对标 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 } let boundaryRange = blockRange(at: location) ?? paragraphRange(containing: location) - if boundaryRange.location > range.location, boundaryRange.location >= minimumEnd { + if boundaryRange.location > safeRange.location, boundaryRange.location >= minimumEnd { boundary = boundaryRange.location stop.pointee = true } @@ -523,8 +507,11 @@ struct RDEPUBTextLayouter { /// 获取指定范围内的所有附件字符范围 private func attachmentRanges(in range: NSRange) -> [NSRange] { + guard let safeRange = clampedRange(range), safeRange.length > 0 else { + return [] + } var results: [NSRange] = [] - attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, _ in + attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, _ in guard value != nil else { return } results.append(attributeRange) } @@ -543,8 +530,11 @@ struct RDEPUBTextLayouter { /// 获取指定范围内的附件类型列表(去重) private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] { + guard let safeRange = clampedRange(range), safeRange.length > 0 else { + return [] + } var kinds: [RDEPUBTextAttachmentKind] = [] - attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in + attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, _, _ in guard let rawValue = value as? String, let kind = RDEPUBTextAttachmentKind(rawValue: rawValue), !kinds.contains(kind) else { @@ -557,8 +547,11 @@ struct RDEPUBTextLayouter { /// 获取指定范围内的块级元素类型列表(去重) private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] { + guard let safeRange = clampedRange(range), safeRange.length > 0 else { + return [] + } var kinds: [RDEPUBTextBlockKind] = [] - attributedString.enumerateAttribute(.rdPageBlockKind, in: range) { value, _, _ in + attributedString.enumerateAttribute(.rdPageBlockKind, in: safeRange) { value, _, _ in guard let rawValue = value as? String, let kind = RDEPUBTextBlockKind(rawValue: rawValue), !kinds.contains(kind) else { @@ -571,8 +564,11 @@ struct RDEPUBTextLayouter { /// 获取指定范围内的语义提示列表(去重) private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] { + guard let safeRange = clampedRange(range), safeRange.length > 0 else { + return [] + } var hints: [RDEPUBTextSemanticHint] = [] - attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, _, _ in + attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, _ in guard let rawValue = value as? String else { return } for hint in rawValue.split(separator: ",").compactMap({ RDEPUBTextSemanticHint(rawValue: String($0)) }) where !hints.contains(hint) { hints.append(hint) @@ -583,8 +579,11 @@ struct RDEPUBTextLayouter { /// 获取指定范围内的附件布局方式列表(去重) private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] { + guard let safeRange = clampedRange(range), safeRange.length > 0 else { + return [] + } var placements: [RDEPUBTextAttachmentPlacement] = [] - attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: range) { value, _, _ in + attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: safeRange) { value, _, _ in guard let rawValue = value as? String, let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue), !placements.contains(placement) else { @@ -595,6 +594,22 @@ struct RDEPUBTextLayouter { 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(用于阅读位置恢复) private func nearestTrailingFragmentID( endingAt location: Int, @@ -770,10 +785,15 @@ struct RDEPUBTextLayouter { /// 检查某行是否落在 avoidPageBreakInside 保护块内 private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool { + guard let probeRange = clampedProbeRange(for: lineRange) else { + return false + } var found = false - let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1)) - attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in - guard let rawValue = value as? String else { return } + attributedString.enumerateAttributes(in: probeRange) { attributes, _, stop in + guard shouldTreatAvoidHintAsBlockProtection(attributes) else { + return + } + guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return } let hints = rawValue .split(separator: ",") .compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } @@ -785,10 +805,37 @@ struct RDEPUBTextLayouter { 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 保护块内 private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool { + guard let probeRange = clampedProbeRange(for: lineRange) else { + return false + } var found = false - let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1)) attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in guard let rawValue = value as? String else { return } let hints = rawValue @@ -811,6 +858,47 @@ 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) /// 获取 DTCoreTextLayoutFrame 中所有行的字符范围 private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] { diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBPageInteractionController.swift b/Sources/RDReaderView/EPUBUI/RDEPUBPageInteractionController.swift index eef42e3..c02cb42 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBPageInteractionController.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBPageInteractionController.swift @@ -13,18 +13,12 @@ final class RDEPUBPageInteractionController { var snapshot: RDEPUBPageLayoutSnapshot? private var dtLayoutFrame: DTCoreTextLayoutFrame? - private var contentOffsetBase: Int = 0 #if canImport(DTCoreText) - func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?, contentOffsetBase: Int = 0) { + func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?) { dtLayoutFrame = layoutFrame - self.contentOffsetBase = contentOffsetBase if let layoutFrame, let page { - snapshot = RDEPUBPageLayoutSnapshot.build( - from: layoutFrame, - page: page, - contentOffsetBase: contentOffsetBase - ) + snapshot = RDEPUBPageLayoutSnapshot.build(from: layoutFrame, page: page) } else { snapshot = nil } @@ -53,11 +47,7 @@ final class RDEPUBPageInteractionController { ) let idx = dtLine.stringIndex(forPosition: relativePoint) guard idx != NSNotFound, idx >= 0 else { return nil } - return normalizedIndex( - absoluteIndex(for: idx), - lineRange: line.stringRange, - pageRange: snapshot.pageContentRange - ) + return normalizedIndex(idx, lineRange: line.stringRange, pageRange: snapshot.pageContentRange) #else return nil #endif @@ -85,9 +75,9 @@ final class RDEPUBPageInteractionController { #if canImport(DTCoreText) guard let dtLine = dtLineContaining(range: line.stringRange) else { continue } - let startX = dtLine.offset(forStringIndex: localIndex(for: overlap.location)) + let startX = dtLine.offset(forStringIndex: overlap.location) let endIdx = overlap.location + overlap.length - let endX = dtLine.offset(forStringIndex: localIndex(for: endIdx)) + let endX = dtLine.offset(forStringIndex: endIdx) #else let startX: CGFloat = 0 let endX: CGFloat = line.frame.width @@ -145,7 +135,7 @@ final class RDEPUBPageInteractionController { #if canImport(DTCoreText) guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil } - let offsetX = dtLine.offset(forStringIndex: localIndex(for: index)) + let offsetX = dtLine.offset(forStringIndex: index) #else let offsetX: CGFloat = 0 #endif @@ -197,18 +187,10 @@ final class RDEPUBPageInteractionController { #if canImport(DTCoreText) private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? { guard let dtLayoutFrame else { return nil } - return dtLayoutFrame.lineContaining(UInt(max(localIndex(for: range.location), 0))) + return dtLayoutFrame.lineContaining(UInt(range.location)) } #endif - private func localIndex(for absoluteIndex: Int) -> Int { - absoluteIndex - contentOffsetBase - } - - private func absoluteIndex(for localIndex: Int) -> Int { - localIndex + contentOffsetBase - } - private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] { guard rects.count > 1 else { return rects } diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBPageLayoutSnapshot.swift b/Sources/RDReaderView/EPUBUI/RDEPUBPageLayoutSnapshot.swift index 9186c7b..c78808e 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBPageLayoutSnapshot.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBPageLayoutSnapshot.swift @@ -130,8 +130,7 @@ struct RDEPUBPageLayoutSnapshot { #if canImport(DTCoreText) static func build( from layoutFrame: DTCoreTextLayoutFrame, - page: RDEPUBTextPage, - contentOffsetBase: Int + page: RDEPUBTextPage ) -> RDEPUBPageLayoutSnapshot? { guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else { return nil @@ -140,7 +139,7 @@ struct RDEPUBPageLayoutSnapshot { var lines: [RDEPUBPageLine] = [] var runs: [RDEPUBPageRun] = [] var attachments: [RDEPUBPageAttachment] = [] - let pageOffset = contentOffsetBase + let pageOffset = page.pageStartOffset for dtLine in dtLines { let lineRange = offset(dtLine.stringRange(), by: pageOffset) diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift index cef6047..9c77276 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift @@ -1267,6 +1267,9 @@ public final class RDEPUBReaderController: UIViewController { private func currentTextPageSize() -> CGSize { let pageNum = readerView.currentPage >= 0 ? readerView.currentPage : nil let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum) + if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") { + 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))") + } if resolvedSize.width > 0, resolvedSize.height > 0 { return resolvedSize } diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift index f0ab590..f09a11e 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift @@ -114,7 +114,6 @@ final class RDEPUBTextContentView: UIView { private var coreTextDisplayContent: NSAttributedString? private var coreTextDisplayRange: NSRange? - private var coreTextDisplayOffsetBase: Int = 0 #endif private let interactionController = RDEPUBPageInteractionController() @@ -266,10 +265,7 @@ final class RDEPUBTextContentView: UIView { ) #if canImport(DTCoreText) - let usesChapterContextDisplay = shouldUseChapterContextDisplay(for: page) - let displayContent = usesChapterContextDisplay - ? NSMutableAttributedString(attributedString: page.chapterContent) - : normalizedPageContent(from: page) + let displayContent = normalizedPageContent(from: page) let fullRange = NSRange(location: 0, length: displayContent.length) displayContent.addAttribute( .foregroundColor, @@ -279,8 +275,7 @@ final class RDEPUBTextContentView: UIView { coreTextContentView.isHidden = false coreTextContentView.backgroundColor = .clear coreTextDisplayContent = displayContent - coreTextDisplayRange = usesChapterContextDisplay ? page.contentRange : NSRange(location: 0, length: displayContent.length) - coreTextDisplayOffsetBase = usesChapterContextDisplay ? 0 : page.pageStartOffset + coreTextDisplayRange = NSRange(location: 0, length: displayContent.length) textView.isHidden = true textView.isUserInteractionEnabled = false textView.attributedText = nil @@ -688,10 +683,6 @@ final class RDEPUBTextContentView: UIView { return !CharacterSet.newlines.contains(previousScalar) } - private func shouldUseChapterContextDisplay(for page: RDEPUBTextPage) -> Bool { - page.metadata.attachmentRanges.isEmpty - } - #if canImport(DTCoreText) private func updateCoreTextLayoutFrameIfNeeded() { guard !coreTextContentView.isHidden, @@ -712,11 +703,7 @@ final class RDEPUBTextContentView: UIView { layouter.shouldCacheLayoutFrames = false let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange) coreTextContentView.layoutFrame = layoutFrame - interactionController.configure( - layoutFrame: layoutFrame, - page: page, - contentOffsetBase: coreTextDisplayOffsetBase - ) + interactionController.configure(layoutFrame: layoutFrame, page: page) overlayView.updateSnapshot(interactionController.snapshot) backgroundOverlayView.updateSnapshot(interactionController.snapshot) }