epub问题修改

This commit is contained in:
shen 2026-05-26 23:27:52 +08:00
parent 22adb76332
commit ea21c6a831
8 changed files with 257 additions and 144 deletions

View File

@ -223,6 +223,10 @@ 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 }
@ -263,6 +267,9 @@ 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 = []
@ -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( chapters.append(
RDEPUBTextChapter( RDEPUBTextChapter(
chapterIndex: chapterIndex, chapterIndex: chapterIndex,
@ -575,11 +594,26 @@ 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,
@ -589,9 +623,28 @@ 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,
shouldDropWhitespaceOnlyTrailingFrame(lastFrame, in: content) { shouldDropWhitespaceOnlyFrame(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() {
@ -617,7 +670,7 @@ public final class RDEPUBTextBookBuilder {
} }
/// ///
private func shouldDropWhitespaceOnlyTrailingFrame( private func shouldDropWhitespaceOnlyFrame(
_ frame: RDEPUBTextLayoutFrame, _ frame: RDEPUBTextLayoutFrame,
in content: NSAttributedString in content: NSAttributedString
) -> Bool { ) -> Bool {
@ -737,7 +790,7 @@ public final class RDEPUBTextBookBuilder {
bookID: bookID, bookID: bookID,
fontSize: style.font.pointSize, fontSize: style.font.pointSize,
lineHeightMultiple: style.lineSpacing, lineHeightMultiple: style.lineSpacing,
contentInsets: .zero, contentInsets: layoutConfig.edgeInsets,
pageSize: pageSize, pageSize: pageSize,
layoutConfigSignature: layoutConfig.cacheSignature layoutConfigSignature: layoutConfig.cacheSignature
) )

View File

@ -123,7 +123,8 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
public final class RDEPUBTextBookCache { public final class RDEPUBTextBookCache {
/// ///
public var schemaVersion: Int = 4 //
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)

View File

@ -67,23 +67,42 @@ 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 frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), path, nil) let framePath = CGMutablePath()
let visibleRange = CTFrameGetVisibleStringRange(frame) // WXRead WRChapterPageCount
guard visibleRange.length > 0 else { // 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 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: lineAdjusted, from: avoidAdjusted,
totalLength: attributedString.length, totalLength: attributedString.length,
lineRanges: lineRanges lineRanges: lineRanges
) )
@ -108,7 +127,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(visibleRange.length, 1) location += max(proposedRange.length, 1)
continue continue
} }
location = nextLocation location = nextLocation
@ -155,7 +174,12 @@ struct RDEPUBTextLayouter {
totalLength: attributedString.length, totalLength: attributedString.length,
lineRanges: lineRanges 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( let trailingFragmentID = nearestTrailingFragmentID(
endingAt: verifiedRange.location + verifiedRange.length, endingAt: verifiedRange.location + verifiedRange.length,
fragmentOffsets: fragmentOffsets fragmentOffsets: fragmentOffsets
@ -191,27 +215,28 @@ struct RDEPUBTextLayouter {
} }
private func verifiedDisplayRange(for range: NSRange) -> NSRange { private func verifiedDisplayRange(for range: NSRange) -> NSRange {
guard range.length > 0, guard let clampedRange = clampedRange(range),
!attachmentRanges(in: range).isEmpty else { clampedRange.length > 0,
!attachmentRanges(in: clampedRange).isEmpty else {
return range return range
} }
let pageContent = attributedString.attributedSubstring(from: range) let pageContent = attributedString.attributedSubstring(from: clampedRange)
guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else { guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else {
return range return clampedRange
} }
layouter.shouldCacheLayoutFrames = false layouter.shouldCacheLayoutFrames = false
guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else { guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else {
return range return clampedRange
} }
let visibleRange = layoutFrame.visibleStringRange() let visibleRange = layoutFrame.visibleStringRange()
guard visibleRange.length > 0, visibleRange.length < range.length else { guard visibleRange.length > 0, visibleRange.length < clampedRange.length else {
return range return clampedRange
} }
return NSRange(location: range.location, length: visibleRange.length) return NSRange(location: clampedRange.location, length: visibleRange.length)
} }
#endif #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 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)
@ -292,38 +313,12 @@ struct RDEPUBTextLayouter {
let currentSemanticHints = proposedSemanticHints let currentSemanticHints = proposedSemanticHints
let currentAttachmentPlacements = proposedAttachmentPlacements let currentAttachmentPlacements = proposedAttachmentPlacements
// 1. pageBreakBefore/After // WXRead CTFrame pageRelate
if let semanticBoundary = preferredSemanticBoundary( // keepWithNext/attachmentBoundary/
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: minimumEnd, minimumEnd: proposedRange.location + 1,
lineRanges: lineRanges lineRanges: lineRanges
) { ) {
let adjustedRange = NSRange(location: proposedRange.location, length: pageRelateBoundary - proposedRange.location) 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. // 4.
return ( return (
range: proposedRange, range: proposedRange,
@ -407,8 +375,11 @@ 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: range) { value, attributeRange, stop in attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { 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: ",")
@ -416,7 +387,7 @@ struct RDEPUBTextLayouter {
guard !hints.isEmpty else { return } guard !hints.isEmpty else { return }
if hints.contains(.pageBreakBefore), if hints.contains(.pageBreakBefore),
attributeRange.location > range.location, attributeRange.location > safeRange.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
@ -426,7 +397,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 < range.location + range.length { attributeEnd < safeRange.location + safeRange.length {
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue) boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
stop.pointee = true stop.pointee = true
return return
@ -438,8 +409,11 @@ 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: range) { value, attributeRange, stop in attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, stop in
guard value != nil else { return } guard value != nil else { return }
let location = attributeRange.location let location = attributeRange.location
@ -448,11 +422,21 @@ 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 > range.location, boundaryRange.location >= minimumEnd { if boundaryRange.location > safeRange.location, boundaryRange.location >= minimumEnd {
boundary = boundaryRange.location boundary = boundaryRange.location
stop.pointee = true stop.pointee = true
} }
@ -523,8 +507,11 @@ 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: range) { value, attributeRange, _ in attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, _ in
guard value != nil else { return } guard value != nil else { return }
results.append(attributeRange) results.append(attributeRange)
} }
@ -543,8 +530,11 @@ 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: range) { value, _, _ in attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { 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 {
@ -557,8 +547,11 @@ 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: range) { value, _, _ in attributedString.enumerateAttribute(.rdPageBlockKind, in: safeRange) { 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 {
@ -571,8 +564,11 @@ 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: range) { value, _, _ in attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { 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)
@ -583,8 +579,11 @@ 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: range) { value, _, _ in attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: safeRange) { 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 {
@ -595,6 +594,22 @@ 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,
@ -770,10 +785,15 @@ 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
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1)) attributedString.enumerateAttributes(in: probeRange) { attributes, _, stop in
attributedString.enumerateAttribute(.rdPageSemanticHints, in: probeRange) { value, _, stop in guard shouldTreatAvoidHintAsBlockProtection(attributes) else {
guard let rawValue = value as? String else { return } 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)) }
@ -785,10 +805,37 @@ 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
@ -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) #if canImport(DTCoreText)
/// DTCoreTextLayoutFrame /// DTCoreTextLayoutFrame
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] { private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {

View File

@ -13,18 +13,12 @@ final class RDEPUBPageInteractionController {
var snapshot: RDEPUBPageLayoutSnapshot? var snapshot: RDEPUBPageLayoutSnapshot?
private var dtLayoutFrame: DTCoreTextLayoutFrame? private var dtLayoutFrame: DTCoreTextLayoutFrame?
private var contentOffsetBase: Int = 0
#if canImport(DTCoreText) #if canImport(DTCoreText)
func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?, contentOffsetBase: Int = 0) { func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?) {
dtLayoutFrame = layoutFrame dtLayoutFrame = layoutFrame
self.contentOffsetBase = contentOffsetBase
if let layoutFrame, let page { if let layoutFrame, let page {
snapshot = RDEPUBPageLayoutSnapshot.build( snapshot = RDEPUBPageLayoutSnapshot.build(from: layoutFrame, page: page)
from: layoutFrame,
page: page,
contentOffsetBase: contentOffsetBase
)
} else { } else {
snapshot = nil snapshot = nil
} }
@ -53,11 +47,7 @@ final class RDEPUBPageInteractionController {
) )
let idx = dtLine.stringIndex(forPosition: relativePoint) let idx = dtLine.stringIndex(forPosition: relativePoint)
guard idx != NSNotFound, idx >= 0 else { return nil } guard idx != NSNotFound, idx >= 0 else { return nil }
return normalizedIndex( return normalizedIndex(idx, lineRange: line.stringRange, pageRange: snapshot.pageContentRange)
absoluteIndex(for: idx),
lineRange: line.stringRange,
pageRange: snapshot.pageContentRange
)
#else #else
return nil return nil
#endif #endif
@ -85,9 +75,9 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText) #if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { continue } 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 endIdx = overlap.location + overlap.length
let endX = dtLine.offset(forStringIndex: localIndex(for: endIdx)) let endX = dtLine.offset(forStringIndex: endIdx)
#else #else
let startX: CGFloat = 0 let startX: CGFloat = 0
let endX: CGFloat = line.frame.width let endX: CGFloat = line.frame.width
@ -145,7 +135,7 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText) #if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil } 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 #else
let offsetX: CGFloat = 0 let offsetX: CGFloat = 0
#endif #endif
@ -197,18 +187,10 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText) #if canImport(DTCoreText)
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? { private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
guard let dtLayoutFrame else { return nil } guard let dtLayoutFrame else { return nil }
return dtLayoutFrame.lineContaining(UInt(max(localIndex(for: range.location), 0))) return dtLayoutFrame.lineContaining(UInt(range.location))
} }
#endif #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] { private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] {
guard rects.count > 1 else { return rects } guard rects.count > 1 else { return rects }

View File

@ -130,8 +130,7 @@ struct RDEPUBPageLayoutSnapshot {
#if canImport(DTCoreText) #if canImport(DTCoreText)
static func build( static func build(
from layoutFrame: DTCoreTextLayoutFrame, from layoutFrame: DTCoreTextLayoutFrame,
page: RDEPUBTextPage, page: RDEPUBTextPage
contentOffsetBase: Int
) -> RDEPUBPageLayoutSnapshot? { ) -> RDEPUBPageLayoutSnapshot? {
guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else { guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else {
return nil return nil
@ -140,7 +139,7 @@ struct RDEPUBPageLayoutSnapshot {
var lines: [RDEPUBPageLine] = [] var lines: [RDEPUBPageLine] = []
var runs: [RDEPUBPageRun] = [] var runs: [RDEPUBPageRun] = []
var attachments: [RDEPUBPageAttachment] = [] var attachments: [RDEPUBPageAttachment] = []
let pageOffset = contentOffsetBase let pageOffset = page.pageStartOffset
for dtLine in dtLines { for dtLine in dtLines {
let lineRange = offset(dtLine.stringRange(), by: pageOffset) let lineRange = offset(dtLine.stringRange(), by: pageOffset)

View File

@ -1267,6 +1267,9 @@ public final class RDEPUBReaderController: UIViewController {
private func currentTextPageSize() -> CGSize { private func currentTextPageSize() -> CGSize {
let pageNum = readerView.currentPage >= 0 ? readerView.currentPage : nil let pageNum = readerView.currentPage >= 0 ? readerView.currentPage : nil
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum) 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 { if resolvedSize.width > 0, resolvedSize.height > 0 {
return resolvedSize return resolvedSize
} }

View File

@ -114,7 +114,6 @@ final class RDEPUBTextContentView: UIView {
private var coreTextDisplayContent: NSAttributedString? private var coreTextDisplayContent: NSAttributedString?
private var coreTextDisplayRange: NSRange? private var coreTextDisplayRange: NSRange?
private var coreTextDisplayOffsetBase: Int = 0
#endif #endif
private let interactionController = RDEPUBPageInteractionController() private let interactionController = RDEPUBPageInteractionController()
@ -266,10 +265,7 @@ final class RDEPUBTextContentView: UIView {
) )
#if canImport(DTCoreText) #if canImport(DTCoreText)
let usesChapterContextDisplay = shouldUseChapterContextDisplay(for: page) let displayContent = normalizedPageContent(from: page)
let displayContent = usesChapterContextDisplay
? NSMutableAttributedString(attributedString: page.chapterContent)
: normalizedPageContent(from: page)
let fullRange = NSRange(location: 0, length: displayContent.length) let fullRange = NSRange(location: 0, length: displayContent.length)
displayContent.addAttribute( displayContent.addAttribute(
.foregroundColor, .foregroundColor,
@ -279,8 +275,7 @@ final class RDEPUBTextContentView: UIView {
coreTextContentView.isHidden = false coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear coreTextContentView.backgroundColor = .clear
coreTextDisplayContent = displayContent coreTextDisplayContent = displayContent
coreTextDisplayRange = usesChapterContextDisplay ? page.contentRange : NSRange(location: 0, length: displayContent.length) coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
coreTextDisplayOffsetBase = usesChapterContextDisplay ? 0 : page.pageStartOffset
textView.isHidden = true textView.isHidden = true
textView.isUserInteractionEnabled = false textView.isUserInteractionEnabled = false
textView.attributedText = nil textView.attributedText = nil
@ -688,10 +683,6 @@ final class RDEPUBTextContentView: UIView {
return !CharacterSet.newlines.contains(previousScalar) return !CharacterSet.newlines.contains(previousScalar)
} }
private func shouldUseChapterContextDisplay(for page: RDEPUBTextPage) -> Bool {
page.metadata.attachmentRanges.isEmpty
}
#if canImport(DTCoreText) #if canImport(DTCoreText)
private func updateCoreTextLayoutFrameIfNeeded() { private func updateCoreTextLayoutFrameIfNeeded() {
guard !coreTextContentView.isHidden, guard !coreTextContentView.isHidden,
@ -712,11 +703,7 @@ final class RDEPUBTextContentView: UIView {
layouter.shouldCacheLayoutFrames = false layouter.shouldCacheLayoutFrames = false
let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange) let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
coreTextContentView.layoutFrame = layoutFrame coreTextContentView.layoutFrame = layoutFrame
interactionController.configure( interactionController.configure(layoutFrame: layoutFrame, page: page)
layoutFrame: layoutFrame,
page: page,
contentOffsetBase: coreTextDisplayOffsetBase
)
overlayView.updateSnapshot(interactionController.snapshot) overlayView.updateSnapshot(interactionController.snapshot)
backgroundOverlayView.updateSnapshot(interactionController.snapshot) backgroundOverlayView.updateSnapshot(interactionController.snapshot)
} }