feat(wxread): align pagination, rendering, and docs
This commit is contained in:
@@ -6,19 +6,22 @@ public struct RDEPUBLocation: Codable, Equatable {
|
||||
public var progression: Double
|
||||
public var lastProgression: Double?
|
||||
public var fragment: String?
|
||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
||||
|
||||
public init(
|
||||
bookIdentifier: String? = nil,
|
||||
href: String,
|
||||
progression: Double,
|
||||
lastProgression: Double? = nil,
|
||||
fragment: String? = nil
|
||||
fragment: String? = nil,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil
|
||||
) {
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.href = href
|
||||
self.progression = Self.clamp(progression)
|
||||
self.lastProgression = lastProgression.map(Self.clamp)
|
||||
self.fragment = fragment?.nilIfEmpty
|
||||
self.rangeAnchor = rangeAnchor
|
||||
}
|
||||
|
||||
public var navigationProgression: Double {
|
||||
|
||||
@@ -114,7 +114,8 @@ public final class RDEPUBResourceResolver {
|
||||
href: normalizedTargetHref,
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: fragment
|
||||
fragment: fragment,
|
||||
rangeAnchor: location.rangeAnchor
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
public var localMatchIndex: Int
|
||||
public var rangeLocation: Int?
|
||||
public var rangeLength: Int
|
||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
||||
|
||||
public init(
|
||||
href: String,
|
||||
@@ -14,7 +15,8 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
previewText: String,
|
||||
localMatchIndex: Int,
|
||||
rangeLocation: Int? = nil,
|
||||
rangeLength: Int
|
||||
rangeLength: Int,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil
|
||||
) {
|
||||
self.href = href
|
||||
self.progression = progression
|
||||
@@ -22,6 +24,7 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
self.localMatchIndex = localMatchIndex
|
||||
self.rangeLocation = rangeLocation
|
||||
self.rangeLength = rangeLength
|
||||
self.rangeAnchor = rangeAnchor
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBTextAnchor: Codable, Equatable {
|
||||
public let fileIndex: Int
|
||||
public let row: Int
|
||||
public let column: Int
|
||||
public let chapterOffset: Int
|
||||
public let fragmentID: String?
|
||||
|
||||
public var spineIndex: Int { fileIndex }
|
||||
|
||||
public init(
|
||||
fileIndex: Int,
|
||||
row: Int,
|
||||
column: Int,
|
||||
chapterOffset: Int,
|
||||
fragmentID: String? = nil
|
||||
) {
|
||||
self.fileIndex = fileIndex
|
||||
self.row = row
|
||||
self.column = column
|
||||
self.chapterOffset = chapterOffset
|
||||
self.fragmentID = fragmentID
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileIndex
|
||||
case spineIndex
|
||||
case row
|
||||
case column
|
||||
case chapterOffset
|
||||
case fragmentID
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let decodedFileIndex = try container.decodeIfPresent(Int.self, forKey: .fileIndex)
|
||||
?? container.decode(Int.self, forKey: .spineIndex)
|
||||
self.fileIndex = decodedFileIndex
|
||||
self.row = try container.decodeIfPresent(Int.self, forKey: .row) ?? 0
|
||||
self.column = try container.decodeIfPresent(Int.self, forKey: .column) ?? 0
|
||||
self.chapterOffset = try container.decode(Int.self, forKey: .chapterOffset)
|
||||
self.fragmentID = try container.decodeIfPresent(String.self, forKey: .fragmentID)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(fileIndex, forKey: .fileIndex)
|
||||
try container.encode(row, forKey: .row)
|
||||
try container.encode(column, forKey: .column)
|
||||
try container.encode(chapterOffset, forKey: .chapterOffset)
|
||||
try container.encodeIfPresent(fragmentID, forKey: .fragmentID)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBTextRangeAnchor: Codable, Equatable {
|
||||
public let start: RDEPUBTextAnchor
|
||||
public let end: RDEPUBTextAnchor
|
||||
|
||||
public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) {
|
||||
self.start = start
|
||||
self.end = end
|
||||
}
|
||||
|
||||
public var nsRange: NSRange {
|
||||
NSRange(location: start.chapterOffset, length: max(end.chapterOffset - start.chapterOffset, 0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBRowColumnIndex: Codable, Equatable {
|
||||
public let row: Int
|
||||
public let startOffset: Int
|
||||
public let endOffset: Int
|
||||
|
||||
public init(row: Int, startOffset: Int, endOffset: Int) {
|
||||
self.row = row
|
||||
self.startOffset = startOffset
|
||||
self.endOffset = endOffset
|
||||
}
|
||||
|
||||
public func contains(_ offset: Int) -> Bool {
|
||||
offset >= startOffset && offset <= endOffset
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBTextIndexTable {
|
||||
public let chapterStartOffsets: [Int]
|
||||
public let chapterLengths: [Int]
|
||||
public let hrefToChapterIndex: [String: Int]
|
||||
public let hrefToFileIndex: [String: Int]
|
||||
public let fragmentOffsetsByHref: [String: [String: Int]]
|
||||
public let fileIndexToHref: [Int: String]
|
||||
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
|
||||
|
||||
public init(chapters: [RDEPUBTextChapter]) {
|
||||
var offsets: [Int] = []
|
||||
var lengths: [Int] = []
|
||||
var hrefMap: [String: Int] = [:]
|
||||
var hrefToFileMap: [String: Int] = [:]
|
||||
var fragmentMap: [String: [String: Int]] = [:]
|
||||
var fileIndexMap: [Int: String] = [:]
|
||||
var rowColumnMap: [Int: [RDEPUBRowColumnIndex]] = [:]
|
||||
var running = 0
|
||||
|
||||
for (index, chapter) in chapters.enumerated() {
|
||||
hrefMap[chapter.href] = index
|
||||
hrefToFileMap[chapter.href] = chapter.spineIndex
|
||||
offsets.append(running)
|
||||
lengths.append(chapter.attributedContent.length)
|
||||
running += chapter.attributedContent.length
|
||||
fragmentMap[chapter.href] = chapter.fragmentOffsets
|
||||
fileIndexMap[chapter.spineIndex] = chapter.href
|
||||
rowColumnMap[chapter.spineIndex] = Self.makeRowColumnIndices(for: chapter.attributedContent.string)
|
||||
}
|
||||
|
||||
self.chapterStartOffsets = offsets
|
||||
self.chapterLengths = lengths
|
||||
self.hrefToChapterIndex = hrefMap
|
||||
self.hrefToFileIndex = hrefToFileMap
|
||||
self.fragmentOffsetsByHref = fragmentMap
|
||||
self.fileIndexToHref = fileIndexMap
|
||||
self.fileRowColumnMap = rowColumnMap
|
||||
}
|
||||
|
||||
public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor {
|
||||
let normalizedIndex = clampedOffset(index, in: chapter)
|
||||
let fragmentID = nearestFragmentID(beforeOrAt: normalizedIndex, in: chapter)
|
||||
let row = row(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
|
||||
let column = column(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
|
||||
return RDEPUBTextAnchor(
|
||||
fileIndex: chapter.spineIndex,
|
||||
row: row,
|
||||
column: column,
|
||||
chapterOffset: normalizedIndex,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
|
||||
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
return anchor
|
||||
}
|
||||
|
||||
guard let chapterIndex = hrefToChapterIndex[location.href],
|
||||
let fileIndex = hrefToFileIndex[location.href] else { return nil }
|
||||
let fragments = fragmentOffsetsByHref[location.href] ?? [:]
|
||||
let chapterOffset: Int
|
||||
|
||||
if let fragment = location.fragment, let fragmentOffset = fragments[fragment] {
|
||||
chapterOffset = fragmentOffset
|
||||
} else {
|
||||
let estimatedLength = max(chapterLengths.indices.contains(chapterIndex) ? chapterLengths[chapterIndex] : 0, 1)
|
||||
let lastOffset = max(estimatedLength - 1, 0)
|
||||
chapterOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * location.navigationProgression))))
|
||||
}
|
||||
|
||||
return RDEPUBTextAnchor(
|
||||
fileIndex: fileIndex,
|
||||
row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
|
||||
chapterOffset: chapterOffset,
|
||||
fragmentID: location.fragment
|
||||
)
|
||||
}
|
||||
|
||||
public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
|
||||
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
|
||||
let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
|
||||
let resolvedOffset = absoluteIndex(for: anchor)
|
||||
return chapter.pages.firstIndex { page in
|
||||
NSLocationInRange(resolvedOffset, page.contentRange)
|
||||
}.map { $0 + basePageIndex }
|
||||
}
|
||||
|
||||
public func href(for fileIndex: Int) -> String? {
|
||||
fileIndexToHref[fileIndex]
|
||||
}
|
||||
|
||||
public func chapterIndex(for href: String) -> Int? {
|
||||
hrefToChapterIndex[href]
|
||||
}
|
||||
|
||||
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
absoluteIndex(
|
||||
fileIndex: anchor.fileIndex,
|
||||
row: anchor.row,
|
||||
column: anchor.column
|
||||
) ?? anchor.chapterOffset
|
||||
}
|
||||
|
||||
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
|
||||
let start = absoluteIndex(for: rangeAnchor.start)
|
||||
let end = max(start, absoluteIndex(for: rangeAnchor.end))
|
||||
return NSRange(location: start, length: max(end - start, 0))
|
||||
}
|
||||
|
||||
public func location(
|
||||
for anchor: RDEPUBTextAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation {
|
||||
let absoluteOffset = absoluteIndex(for: anchor)
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
let progression = Double(min(max(absoluteOffset, 0), totalLength)) / Double(totalLength)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: chapter.href,
|
||||
progression: progression,
|
||||
lastProgression: progression,
|
||||
fragment: anchor.fragmentID,
|
||||
rangeAnchor: RDEPUBTextRangeAnchor(start: anchor, end: anchor)
|
||||
)
|
||||
}
|
||||
|
||||
public func location(
|
||||
for rangeAnchor: RDEPUBTextRangeAnchor,
|
||||
in chapter: RDEPUBTextChapter,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation {
|
||||
let start = absoluteIndex(for: rangeAnchor.start)
|
||||
let end = max(start, absoluteIndex(for: rangeAnchor.end))
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
let clampedStart = min(max(start, 0), totalLength)
|
||||
let clampedEnd = min(max(end, clampedStart), totalLength)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: chapter.href,
|
||||
progression: Double(clampedStart) / Double(totalLength),
|
||||
lastProgression: Double(clampedEnd) / Double(totalLength),
|
||||
fragment: rangeAnchor.start.fragmentID,
|
||||
rangeAnchor: rangeAnchor
|
||||
)
|
||||
}
|
||||
|
||||
public func row(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
||||
if let rowIndex = rows.first(where: { $0.contains(index) })?.row {
|
||||
return rowIndex
|
||||
}
|
||||
return rows.last?.row ?? 0
|
||||
}
|
||||
|
||||
public func column(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
|
||||
if let rowEntry = rows.first(where: { $0.contains(index) }) {
|
||||
return max(index - rowEntry.startOffset, 0)
|
||||
}
|
||||
guard let lastRow = rows.last else { return 0 }
|
||||
return max(index - lastRow.startOffset, 0)
|
||||
}
|
||||
|
||||
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
|
||||
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
|
||||
let normalizedRow = min(max(row, 0), rows.count - 1)
|
||||
let rowEntry = rows[normalizedRow]
|
||||
let maxColumn = max(rowEntry.endOffset - rowEntry.startOffset, 0)
|
||||
return rowEntry.startOffset + min(max(column, 0), maxColumn)
|
||||
}
|
||||
|
||||
private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
return min(max(index, 0), lastOffset)
|
||||
}
|
||||
|
||||
private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? {
|
||||
var bestID: String?
|
||||
var bestOffset = -1
|
||||
|
||||
for (id, fragOffset) in chapter.fragmentOffsets {
|
||||
if fragOffset <= offset && fragOffset > bestOffset {
|
||||
bestOffset = fragOffset
|
||||
bestID = id
|
||||
}
|
||||
}
|
||||
|
||||
return bestID
|
||||
}
|
||||
|
||||
private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
|
||||
let nsText = text as NSString
|
||||
let length = nsText.length
|
||||
guard length > 0 else {
|
||||
return [RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: 0)]
|
||||
}
|
||||
|
||||
var rows: [RDEPUBRowColumnIndex] = []
|
||||
var rowNumber = 0
|
||||
var lineStart = 0
|
||||
|
||||
nsText.enumerateSubstrings(
|
||||
in: NSRange(location: 0, length: length),
|
||||
options: [.byLines, .substringNotRequired]
|
||||
) { _, substringRange, enclosingRange, _ in
|
||||
let startOffset = enclosingRange.location
|
||||
let lineLength = max(substringRange.length, 0)
|
||||
let endOffset = max(startOffset + max(lineLength - 1, 0), startOffset)
|
||||
rows.append(
|
||||
RDEPUBRowColumnIndex(
|
||||
row: rowNumber,
|
||||
startOffset: startOffset,
|
||||
endOffset: min(endOffset, max(length - 1, 0))
|
||||
)
|
||||
)
|
||||
rowNumber += 1
|
||||
lineStart = enclosingRange.location + enclosingRange.length
|
||||
}
|
||||
|
||||
if rows.isEmpty {
|
||||
rows.append(RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: max(length - 1, 0)))
|
||||
} else if lineStart == length, text.hasSuffix("\n") {
|
||||
rows.append(RDEPUBRowColumnIndex(row: rowNumber, startOffset: length, endOffset: length))
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import UIKit
|
||||
|
||||
public final class RDEPUBChapterData {
|
||||
public let chapter: RDEPUBTextChapter
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
|
||||
public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
|
||||
self.chapter = chapter
|
||||
self.indexTable = indexTable
|
||||
}
|
||||
|
||||
public var chapterIndex: Int { chapter.chapterIndex }
|
||||
public var spineIndex: Int { chapter.spineIndex }
|
||||
public var href: String { chapter.href }
|
||||
public var title: String { chapter.title }
|
||||
public var attributedContent: NSAttributedString { chapter.attributedContent }
|
||||
public var pages: [RDEPUBTextPage] { chapter.pages }
|
||||
public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
|
||||
|
||||
public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
|
||||
chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
|
||||
}
|
||||
|
||||
public func pageNumber(containing absoluteOffset: Int) -> Int? {
|
||||
page(containing: absoluteOffset)?.absolutePageIndex
|
||||
}
|
||||
|
||||
public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
|
||||
chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
|
||||
}
|
||||
|
||||
public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
|
||||
indexTable.anchor(forAbsoluteIndex: index, in: chapter)
|
||||
}
|
||||
|
||||
public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
|
||||
let start = anchor(forAbsoluteIndex: absoluteRange.location)
|
||||
let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
|
||||
return RDEPUBTextRangeAnchor(start: start, end: end)
|
||||
}
|
||||
|
||||
public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? {
|
||||
guard page(containing: absoluteRange.location) != nil else { return nil }
|
||||
let location = self.location(for: absoluteRange, bookIdentifier: bookIdentifier)
|
||||
let text = chapter.attributedContent.attributedSubstring(from: absoluteRange).string
|
||||
let rangeInfo = RDEPUBTextOffsetRangeInfo(
|
||||
href: chapter.href,
|
||||
start: absoluteRange.location,
|
||||
end: absoluteRange.location + absoluteRange.length
|
||||
).jsonString()
|
||||
return RDEPUBSelection(
|
||||
bookIdentifier: bookIdentifier,
|
||||
location: location,
|
||||
text: text,
|
||||
rangeInfo: rangeInfo
|
||||
)
|
||||
}
|
||||
|
||||
public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
|
||||
indexTable.location(
|
||||
for: rangeAnchor(for: absoluteRange),
|
||||
in: chapter,
|
||||
bookIdentifier: bookIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
|
||||
location(for: page.contentRange, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
|
||||
if let rangeAnchor = location.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
}
|
||||
|
||||
if let fragment = location.fragment,
|
||||
let offset = fragmentOffsets[fragment] {
|
||||
return NSRange(location: offset, length: 1)
|
||||
}
|
||||
|
||||
let lastOffset = max(attributedContent.length - 1, 0)
|
||||
let offset = min(lastOffset, max(0, Int(round(Double(lastOffset) * location.navigationProgression))))
|
||||
return NSRange(location: offset, length: 1)
|
||||
}
|
||||
|
||||
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
|
||||
if let rangeAnchor = highlight.location.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
}
|
||||
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
|
||||
}
|
||||
|
||||
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
|
||||
if let rangeAnchor = searchMatch.rangeAnchor {
|
||||
return indexTable.absoluteRange(for: rangeAnchor)
|
||||
}
|
||||
if let location = searchMatch.rangeLocation {
|
||||
return NSRange(location: location, length: searchMatch.rangeLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
return allHighlights.filter { highlight in
|
||||
guard highlight.location.href == chapter.href else { return false }
|
||||
if let anchor = highlight.location.rangeAnchor?.start {
|
||||
return pageRange.contains(absoluteOffset(for: anchor))
|
||||
}
|
||||
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
||||
return false
|
||||
}
|
||||
return NSIntersectionRange(range, page.contentRange).length > 0
|
||||
}
|
||||
}
|
||||
|
||||
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
return matches.filter { match in
|
||||
guard match.href == chapter.href else { return false }
|
||||
if let range = absoluteRange(for: match) {
|
||||
return NSIntersectionRange(range, page.contentRange).length > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
if let range = absoluteRange(for: location) {
|
||||
return pageNumber(containing: range.location).map { $0 + 1 }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
|
||||
let lowerBound = page.pageStartOffset
|
||||
let upperBound = page.pageEndOffset + 1
|
||||
return lowerBound..<max(upperBound, lowerBound)
|
||||
}
|
||||
|
||||
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
|
||||
indexTable.absoluteIndex(
|
||||
fileIndex: anchor.fileIndex,
|
||||
row: anchor.row,
|
||||
column: anchor.column
|
||||
) ?? anchor.chapterOffset
|
||||
}
|
||||
}
|
||||
@@ -40,13 +40,29 @@ public struct RDEPUBTextChapter: Equatable {
|
||||
public var pages: [RDEPUBTextPage]
|
||||
}
|
||||
|
||||
public struct RDEPUBTextBook: Equatable {
|
||||
public struct RDEPUBTextBook {
|
||||
public var chapters: [RDEPUBTextChapter]
|
||||
public var pages: [RDEPUBTextPage]
|
||||
public let indexTable: RDEPUBTextIndexTable
|
||||
|
||||
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
|
||||
self.chapters = chapters
|
||||
self.pages = pages
|
||||
self.indexTable = RDEPUBTextIndexTable(chapters: chapters)
|
||||
}
|
||||
|
||||
public static func == (lhs: RDEPUBTextBook, rhs: RDEPUBTextBook) -> Bool {
|
||||
lhs.chapters == rhs.chapters && lhs.pages == rhs.pages
|
||||
}
|
||||
|
||||
public func chapterData(for href: String) -> RDEPUBChapterData? {
|
||||
guard let chapter = chapters.first(where: { $0.href == href }) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapter, indexTable: indexTable)
|
||||
}
|
||||
|
||||
public func chapterData(atChapterIndex index: Int) -> RDEPUBChapterData? {
|
||||
guard chapters.indices.contains(index) else { return nil }
|
||||
return RDEPUBChapterData(chapter: chapters[index], indexTable: indexTable)
|
||||
}
|
||||
|
||||
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
|
||||
@@ -62,6 +78,11 @@ public struct RDEPUBTextBook: Equatable {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let anchor = normalizedLocation.rangeAnchor?.start,
|
||||
let page = indexTable.pageNumber(for: anchor, in: self) {
|
||||
return page + 1
|
||||
}
|
||||
|
||||
let targetOffset: Int
|
||||
if let fragment = normalizedLocation.fragment, let fragmentOffset = chapter.fragmentOffsets[fragment] {
|
||||
targetOffset = fragmentOffset
|
||||
@@ -84,12 +105,18 @@ public struct RDEPUBTextBook: Equatable {
|
||||
}
|
||||
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
|
||||
let startAnchor = indexTable.anchor(forAbsoluteIndex: page.pageStartOffset, in: chapter)
|
||||
let endAnchor = indexTable.anchor(forAbsoluteIndex: page.pageEndOffset, in: chapter)
|
||||
let rangeAnchor = RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor)
|
||||
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: page.href,
|
||||
progression: Double(page.pageStartOffset) / Double(totalLength),
|
||||
lastProgression: Double(page.pageEndOffset) / Double(totalLength),
|
||||
fragment: nil
|
||||
fragment: nil,
|
||||
rangeAnchor: rangeAnchor
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -243,7 +270,12 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
|
||||
|
||||
let effectiveFrames = layoutFrames.isEmpty && content.length > 0
|
||||
let normalizedFrames = normalizeTrailingFrames(
|
||||
layoutFrames,
|
||||
content: content,
|
||||
href: item.href
|
||||
)
|
||||
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
|
||||
? [
|
||||
RDEPUBTextLayoutFrame(
|
||||
contentRange: NSRange(location: 0, length: content.length),
|
||||
@@ -261,7 +293,7 @@ public final class RDEPUBTextBookBuilder {
|
||||
]
|
||||
)
|
||||
]
|
||||
: layoutFrames
|
||||
: normalizedFrames
|
||||
if item.href.lowercased().contains("cover") {
|
||||
print("[EPUB][Cover] paginated href=\(item.href) pages=\(effectiveFrames.count) firstRange=\(effectiveFrames.first.map { NSStringFromRange($0.contentRange) } ?? "none")")
|
||||
}
|
||||
@@ -423,6 +455,111 @@ public final class RDEPUBTextBookBuilder {
|
||||
return attachmentCount(in: content) > 0 && trimmed.count <= 1
|
||||
}
|
||||
|
||||
private func normalizeTrailingFrames(
|
||||
_ frames: [RDEPUBTextLayoutFrame],
|
||||
content: NSAttributedString,
|
||||
href: String
|
||||
) -> [RDEPUBTextLayoutFrame] {
|
||||
guard frames.count > 1 else { return frames }
|
||||
|
||||
var normalized = frames
|
||||
|
||||
while let lastFrame = normalized.last,
|
||||
shouldDropWhitespaceOnlyTrailingFrame(lastFrame, in: content) {
|
||||
normalized.removeLast()
|
||||
let note = "normalized: dropped whitespace-only trailing page \(NSStringFromRange(lastFrame.contentRange))"
|
||||
if var previousFrame = normalized.popLast() {
|
||||
previousFrame.diagnostics.append(note)
|
||||
normalized.append(previousFrame)
|
||||
} else {
|
||||
print("[EPUB][Pagination] href=\(href) dropped trailing frame \(NSStringFromRange(lastFrame.contentRange))")
|
||||
}
|
||||
}
|
||||
|
||||
guard normalized.count > 1,
|
||||
let lastFrame = normalized.last,
|
||||
let previousFrame = normalized.dropLast().last,
|
||||
shouldMergeShortTrailingFrame(lastFrame, previousFrame: previousFrame, in: content) else {
|
||||
return normalized
|
||||
}
|
||||
|
||||
let mergedFrame = mergeTrailingFrame(previousFrame, with: lastFrame)
|
||||
normalized.removeLast(2)
|
||||
normalized.append(mergedFrame)
|
||||
return normalized
|
||||
}
|
||||
|
||||
private func shouldDropWhitespaceOnlyTrailingFrame(
|
||||
_ frame: RDEPUBTextLayoutFrame,
|
||||
in content: NSAttributedString
|
||||
) -> Bool {
|
||||
guard frame.contentRange.length > 0,
|
||||
attachmentCount(in: content, range: frame.contentRange) == 0 else {
|
||||
return false
|
||||
}
|
||||
return visibleCharacterCount(in: content, range: frame.contentRange) == 0
|
||||
}
|
||||
|
||||
private func shouldMergeShortTrailingFrame(
|
||||
_ trailingFrame: RDEPUBTextLayoutFrame,
|
||||
previousFrame: RDEPUBTextLayoutFrame,
|
||||
in content: NSAttributedString
|
||||
) -> Bool {
|
||||
guard trailingFrame.contentRange.length > 0,
|
||||
NSMaxRange(previousFrame.contentRange) == trailingFrame.contentRange.location else {
|
||||
return false
|
||||
}
|
||||
|
||||
let visibleCount = visibleCharacterCount(in: content, range: trailingFrame.contentRange)
|
||||
let trailingAttachmentCount = attachmentCount(in: content, range: trailingFrame.contentRange)
|
||||
guard visibleCount <= 2,
|
||||
trailingFrame.contentRange.length <= 2,
|
||||
visibleCount > 0 || trailingAttachmentCount > 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
let previousVisibleCount = visibleCharacterCount(in: content, range: previousFrame.contentRange)
|
||||
return previousVisibleCount >= max(visibleCount * 8, 12)
|
||||
}
|
||||
|
||||
private func mergeTrailingFrame(
|
||||
_ previousFrame: RDEPUBTextLayoutFrame,
|
||||
with trailingFrame: RDEPUBTextLayoutFrame
|
||||
) -> RDEPUBTextLayoutFrame {
|
||||
let mergedRange = NSRange(
|
||||
location: previousFrame.contentRange.location,
|
||||
length: NSMaxRange(trailingFrame.contentRange) - previousFrame.contentRange.location
|
||||
)
|
||||
|
||||
return RDEPUBTextLayoutFrame(
|
||||
contentRange: mergedRange,
|
||||
breakReason: trailingFrame.breakReason,
|
||||
blockRange: trailingFrame.blockRange ?? previousFrame.blockRange,
|
||||
attachmentRanges: uniqueRanges(from: previousFrame.attachmentRanges + trailingFrame.attachmentRanges),
|
||||
attachmentKinds: uniqueValues(from: previousFrame.attachmentKinds + trailingFrame.attachmentKinds),
|
||||
blockKinds: uniqueValues(from: previousFrame.blockKinds + trailingFrame.blockKinds),
|
||||
semanticHints: uniqueValues(from: previousFrame.semanticHints + trailingFrame.semanticHints),
|
||||
attachmentPlacements: uniqueValues(from: previousFrame.attachmentPlacements + trailingFrame.attachmentPlacements),
|
||||
trailingFragmentID: trailingFrame.trailingFragmentID ?? previousFrame.trailingFragmentID,
|
||||
diagnostics: previousFrame.diagnostics
|
||||
+ trailingFrame.diagnostics
|
||||
+ ["normalized: merged short trailing page \(NSStringFromRange(trailingFrame.contentRange)) into previous page"]
|
||||
)
|
||||
}
|
||||
|
||||
private func visibleCharacterCount(
|
||||
in content: NSAttributedString,
|
||||
range: NSRange
|
||||
) -> Int {
|
||||
guard range.length > 0 else { return 0 }
|
||||
let string = content.attributedSubstring(from: range).string
|
||||
let filteredScalars = string.unicodeScalars.filter { scalar in
|
||||
!CharacterSet.whitespacesAndNewlines.contains(scalar)
|
||||
&& !CharacterSet.controlCharacters.contains(scalar)
|
||||
}
|
||||
return filteredScalars.count
|
||||
}
|
||||
|
||||
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
@@ -431,6 +568,25 @@ public final class RDEPUBTextBookBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
|
||||
ranges.reduce(into: [NSRange]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int {
|
||||
guard content.length > 0, range.length > 0 else { return 0 }
|
||||
var count = 0
|
||||
content.enumerateAttribute(.attachment, in: range) { value, _, _ in
|
||||
if value != nil {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private func makeCacheKey(
|
||||
bookID: String,
|
||||
pageSize: CGSize,
|
||||
|
||||
@@ -50,9 +50,15 @@ struct RDEPUBTextLayouter {
|
||||
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
||||
|
||||
// Line-level avoidPageBreakInside (WXRead approach: scan CTFrame lines backward)
|
||||
let lineAdjusted = 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 adjusted = adjustedRange(from: lineAdjusted, totalLength: attributedString.length)
|
||||
let adjusted = adjustedRange(
|
||||
from: lineAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
let trailingFragmentID = nearestTrailingFragmentID(
|
||||
endingAt: adjusted.range.location + adjusted.range.length,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
@@ -106,8 +112,14 @@ struct RDEPUBTextLayouter {
|
||||
}
|
||||
|
||||
let proposedRange = NSRange(location: location, length: visibleRange.length)
|
||||
let lineAdjusted = trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
|
||||
let adjusted = adjustedRange(from: lineAdjusted, totalLength: attributedString.length)
|
||||
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
|
||||
let lineAdjusted = trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
|
||||
let lineRanges = lineRanges(from: layoutFrame)
|
||||
let adjusted = adjustedRange(
|
||||
from: lineAdjusted,
|
||||
totalLength: attributedString.length,
|
||||
lineRanges: lineRanges
|
||||
)
|
||||
let trailingFragmentID = nearestTrailingFragmentID(
|
||||
endingAt: adjusted.range.location + adjusted.range.length,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
@@ -142,7 +154,8 @@ struct RDEPUBTextLayouter {
|
||||
|
||||
private func adjustedRange(
|
||||
from proposedRange: NSRange,
|
||||
totalLength: Int
|
||||
totalLength: Int,
|
||||
lineRanges: [NSRange]
|
||||
) -> (
|
||||
range: NSRange,
|
||||
breakReason: RDEPUBTextPageBreakReason,
|
||||
@@ -217,6 +230,34 @@ struct RDEPUBTextLayouter {
|
||||
)
|
||||
}
|
||||
|
||||
if let pageRelateBoundary = preferredPageRelateBoundary(
|
||||
after: proposedRange,
|
||||
minimumEnd: minimumEnd,
|
||||
lineRanges: lineRanges
|
||||
) {
|
||||
let adjustedRange = NSRange(location: proposedRange.location, length: pageRelateBoundary - 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: RDEPUBTextSemanticHint.pageRelate.rawValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if let attachmentBoundary = preferredAttachmentBoundary(
|
||||
in: proposedRange,
|
||||
minimumEnd: minimumEnd
|
||||
@@ -300,15 +341,49 @@ struct RDEPUBTextLayouter {
|
||||
var boundary: Int?
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
|
||||
guard value != nil else { return }
|
||||
let paragraphRange = paragraphRange(containing: attributeRange.location)
|
||||
if paragraphRange.location > range.location, paragraphRange.location >= minimumEnd {
|
||||
boundary = paragraphRange.location
|
||||
|
||||
let location = attributeRange.location
|
||||
let placement = attachmentPlacement(at: location)
|
||||
let blockKind = blockKind(at: location)
|
||||
|
||||
// Mirror WXRead more closely: only block-level attachments should
|
||||
// push the entire block to the next page. Inline footnote icons and
|
||||
// other inline attachments must not cause a whole paragraph to move.
|
||||
let isBlockLevelAttachment = blockKind == .attachment || placement == .centered
|
||||
guard isBlockLevelAttachment else { return }
|
||||
|
||||
let boundaryRange = blockRange(at: location) ?? paragraphRange(containing: location)
|
||||
if boundaryRange.location > range.location, boundaryRange.location >= minimumEnd {
|
||||
boundary = boundaryRange.location
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
private func preferredPageRelateBoundary(
|
||||
after range: NSRange,
|
||||
minimumEnd: Int,
|
||||
lineRanges: [NSRange]
|
||||
) -> Int? {
|
||||
let pageStartOfNext = range.location + range.length
|
||||
guard pageStartOfNext > range.location,
|
||||
pageStartOfNext < attributedString.length,
|
||||
lineRanges.count >= 2,
|
||||
semanticHints(at: pageStartOfNext).contains(.pageRelate) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let boundaryBlockStart = blockRange(at: pageStartOfNext)?.location ?? paragraphRange(containing: pageStartOfNext).location
|
||||
guard boundaryBlockStart == pageStartOfNext else { return nil }
|
||||
|
||||
let lastLineStart = lineRanges[lineRanges.count - 1].location
|
||||
guard lastLineStart > range.location, lastLineStart >= minimumEnd else {
|
||||
return nil
|
||||
}
|
||||
return lastLineStart
|
||||
}
|
||||
|
||||
private func blockRange(at location: Int) -> NSRange? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
@@ -318,6 +393,20 @@ struct RDEPUBTextLayouter {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
guard let rawValue = attributes[.rdPageBlockKind] as? String else { return nil }
|
||||
return RDEPUBTextBlockKind(rawValue: rawValue)
|
||||
}
|
||||
|
||||
private func attachmentPlacement(at location: Int) -> RDEPUBTextAttachmentPlacement? {
|
||||
guard location >= 0, location < attributedString.length else { return nil }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
guard let rawValue = attributes[.rdPageAttachmentPlacement] as? String else { return nil }
|
||||
return RDEPUBTextAttachmentPlacement(rawValue: rawValue)
|
||||
}
|
||||
|
||||
private func paragraphRange(containing location: Int) -> NSRange {
|
||||
let source = attributedString.string as NSString
|
||||
guard source.length > 0 else { return NSRange(location: 0, length: 0) }
|
||||
@@ -334,6 +423,15 @@ struct RDEPUBTextLayouter {
|
||||
return results
|
||||
}
|
||||
|
||||
private func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
|
||||
guard location >= 0, location < attributedString.length else { return [] }
|
||||
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
|
||||
guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return [] }
|
||||
return rawValue
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
}
|
||||
|
||||
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
|
||||
var kinds: [RDEPUBTextAttachmentKind] = []
|
||||
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in
|
||||
@@ -451,6 +549,18 @@ struct RDEPUBTextLayouter {
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
from frame: CTFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
let lineRanges = lines.map {
|
||||
let range = CTLineGetStringRange($0)
|
||||
return NSRange(location: range.location, length: range.length)
|
||||
}
|
||||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func trimmedRangeForAvoidPageBreakInside(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
@@ -492,8 +602,52 @@ struct RDEPUBTextLayouter {
|
||||
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
proposed: NSRange
|
||||
) -> NSRange {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
|
||||
return proposed
|
||||
}
|
||||
let lineRanges = lines.map { $0.stringRange() }
|
||||
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func trimmedRangeForKeepWithNext(
|
||||
proposed: NSRange,
|
||||
lineRanges: [NSRange]
|
||||
) -> NSRange {
|
||||
guard !lineRanges.isEmpty else { return proposed }
|
||||
|
||||
let kMaxLinesToRemove = 3
|
||||
var linesToRemove = 0
|
||||
|
||||
for lineRange in lineRanges.reversed() {
|
||||
if lineIsInKeepWithNextBlock(lineRange) {
|
||||
linesToRemove += 1
|
||||
if linesToRemove >= kMaxLinesToRemove {
|
||||
linesToRemove = kMaxLinesToRemove
|
||||
break
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard linesToRemove > 0 else { return proposed }
|
||||
|
||||
let validLineCount = lineRanges.count - linesToRemove
|
||||
guard validLineCount > 0 else { return proposed }
|
||||
|
||||
let lastValidLine = lineRanges[validLineCount - 1]
|
||||
let endLocation = lastValidLine.location + lastValidLine.length
|
||||
let adjustedLength = endLocation - proposed.location
|
||||
guard adjustedLength > 0 else { return proposed }
|
||||
return NSRange(location: proposed.location, length: adjustedLength)
|
||||
}
|
||||
|
||||
/// Checks if a line's string range intersects with an avoidPageBreakInside block.
|
||||
private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
|
||||
var found = false
|
||||
@@ -511,6 +665,39 @@ struct RDEPUBTextLayouter {
|
||||
return found
|
||||
}
|
||||
|
||||
private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
|
||||
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
|
||||
.split(separator: ",")
|
||||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||||
if hints.contains(.keepWithNext) {
|
||||
found = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private func lineRanges(from frame: CTFrame) -> [NSRange] {
|
||||
let lines = CTFrameGetLines(frame) as! [CTLine]
|
||||
return lines.map {
|
||||
let lineRange = CTLineGetStringRange($0)
|
||||
return NSRange(location: lineRange.location, length: lineRange.length)
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
|
||||
return []
|
||||
}
|
||||
return lines.map { $0.stringRange() }
|
||||
}
|
||||
#endif
|
||||
|
||||
private func diagnostics(
|
||||
reason: RDEPUBTextPageBreakReason,
|
||||
range: NSRange,
|
||||
|
||||
@@ -22,6 +22,7 @@ public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
|
||||
|
||||
public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
|
||||
case avoidPageBreakInside
|
||||
case keepWithNext
|
||||
case pageBreakBefore
|
||||
case pageBreakAfter
|
||||
case pageRelate
|
||||
|
||||
@@ -812,6 +812,8 @@ enum RDEPUBTextRendererSupport {
|
||||
return .attachment
|
||||
}
|
||||
switch tagName {
|
||||
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
return .generic
|
||||
case "blockquote":
|
||||
return .blockquote
|
||||
case "ul", "ol", "li":
|
||||
@@ -849,6 +851,16 @@ enum RDEPUBTextRendererSupport {
|
||||
["blockquote", "pre", "code", "table", "ul", "ol", "figure", "img"].contains(tagName) {
|
||||
hints.append(.avoidPageBreakInside)
|
||||
}
|
||||
if ["h1", "h2", "h3", "h4", "h5", "h6"].contains(tagName) ||
|
||||
rawTag.contains("subhead") ||
|
||||
rawTag.contains("firsttitle") ||
|
||||
rawTag.contains("secondtitle") ||
|
||||
rawTag.contains("thirdtitle") ||
|
||||
rawTag.contains("fourthtitle") ||
|
||||
rawTag.contains("fifthtitle") ||
|
||||
rawTag.contains("sixthtitle") {
|
||||
hints.append(.keepWithNext)
|
||||
}
|
||||
if rawTag.contains("pagebreakbefore") ||
|
||||
rawTag.contains("page-break-before: always") ||
|
||||
rawTag.contains("break-before: page") {
|
||||
|
||||
@@ -17,6 +17,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
|
||||
var matches: [RDEPUBSearchMatch] = []
|
||||
for chapter in textBook.chapters {
|
||||
guard let chapterData = textBook.chapterData(for: chapter.href) else { continue }
|
||||
let source = chapter.attributedContent.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else {
|
||||
@@ -42,7 +43,8 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
previewText: previewText(in: source, matchRange: foundRange),
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: foundRange.length
|
||||
rangeLength: foundRange.length,
|
||||
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
final class RDEPUBPageInteractionController {
|
||||
|
||||
var snapshot: RDEPUBPageLayoutSnapshot?
|
||||
private var dtLayoutFrame: DTCoreTextLayoutFrame?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?) {
|
||||
dtLayoutFrame = layoutFrame
|
||||
if let layoutFrame, let page {
|
||||
snapshot = RDEPUBPageLayoutSnapshot.build(from: layoutFrame, page: page)
|
||||
} else {
|
||||
snapshot = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Hit Testing
|
||||
|
||||
func characterIndex(at point: CGPoint) -> Int? {
|
||||
guard let snapshot else { return nil }
|
||||
|
||||
// Attachment rect priority (6pt inset for easier tapping)
|
||||
for attachment in snapshot.attachments {
|
||||
if attachment.frame.insetBy(dx: -6, dy: -6).contains(point) {
|
||||
return attachment.stringRange.location
|
||||
}
|
||||
}
|
||||
|
||||
guard let line = nearestLine(to: point, in: snapshot.lines) else { return nil }
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
|
||||
let relativePoint = CGPoint(
|
||||
x: point.x - line.baselineOrigin.x,
|
||||
y: point.y - line.baselineOrigin.y
|
||||
)
|
||||
let idx = dtLine.stringIndex(forPosition: relativePoint)
|
||||
guard idx != NSNotFound, idx >= 0 else { return nil }
|
||||
return normalizedIndex(idx, lineRange: line.stringRange, pageRange: snapshot.pageContentRange)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Selection Range
|
||||
|
||||
func selectionRange(from startPoint: CGPoint, to endPoint: CGPoint) -> NSRange? {
|
||||
guard let start = characterIndex(at: startPoint),
|
||||
let end = characterIndex(at: endPoint) else { return nil }
|
||||
let lower = min(start, end)
|
||||
let upper = max(start, end)
|
||||
return NSRange(location: lower, length: max(upper - lower, 1))
|
||||
}
|
||||
|
||||
// MARK: - Selection Rects
|
||||
|
||||
func selectionRects(for absoluteRange: NSRange) -> [CGRect] {
|
||||
guard let snapshot else { return [] }
|
||||
var rects: [CGRect] = []
|
||||
|
||||
for line in snapshot.lines {
|
||||
let overlap = NSIntersectionRange(line.stringRange, absoluteRange)
|
||||
guard overlap.length > 0 else { continue }
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
guard let dtLine = dtLineContaining(range: line.stringRange) else { continue }
|
||||
let startX = dtLine.offset(forStringIndex: overlap.location)
|
||||
let endIdx = overlap.location + overlap.length
|
||||
let endX = dtLine.offset(forStringIndex: endIdx)
|
||||
#else
|
||||
let startX: CGFloat = 0
|
||||
let endX: CGFloat = line.frame.width
|
||||
#endif
|
||||
|
||||
let rect = CGRect(
|
||||
x: line.baselineOrigin.x + startX,
|
||||
y: line.baselineOrigin.y - line.ascent,
|
||||
width: max(endX - startX, 2),
|
||||
height: line.ascent + line.descent
|
||||
)
|
||||
rects.append(rect)
|
||||
}
|
||||
|
||||
return mergeAdjacentRects(rects)
|
||||
}
|
||||
|
||||
func firstRect(for absoluteRange: NSRange) -> CGRect? {
|
||||
selectionRects(for: absoluteRange).first
|
||||
}
|
||||
|
||||
func lastRect(for absoluteRange: NSRange) -> CGRect? {
|
||||
selectionRects(for: absoluteRange).last
|
||||
}
|
||||
|
||||
func boundingRect(for absoluteRange: NSRange) -> CGRect? {
|
||||
let rects = selectionRects(for: absoluteRange)
|
||||
guard var rect = rects.first else { return nil }
|
||||
for next in rects.dropFirst() {
|
||||
rect = rect.union(next)
|
||||
}
|
||||
return rect
|
||||
}
|
||||
|
||||
func menuAnchorRect(for absoluteRange: NSRange) -> CGRect? {
|
||||
guard let first = firstRect(for: absoluteRange),
|
||||
let last = lastRect(for: absoluteRange) else {
|
||||
return boundingRect(for: absoluteRange)
|
||||
}
|
||||
|
||||
let minX = min(first.minX, last.minX)
|
||||
let maxX = max(first.maxX, last.maxX)
|
||||
let minY = min(first.minY, last.minY)
|
||||
let maxY = max(first.maxY, last.maxY)
|
||||
return CGRect(x: minX, y: minY, width: max(maxX - minX, 2), height: max(maxY - minY, 2))
|
||||
}
|
||||
|
||||
// MARK: - Caret Rect
|
||||
|
||||
func caretRect(at index: Int) -> CGRect? {
|
||||
guard let snapshot else { return nil }
|
||||
guard let line = snapshot.lines.first(where: { NSLocationInRange(index, $0.stringRange) }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
|
||||
let offsetX = dtLine.offset(forStringIndex: index)
|
||||
#else
|
||||
let offsetX: CGFloat = 0
|
||||
#endif
|
||||
|
||||
return CGRect(
|
||||
x: line.baselineOrigin.x + offsetX - 1,
|
||||
y: line.baselineOrigin.y - line.ascent,
|
||||
width: 2,
|
||||
height: line.ascent + line.descent
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func nearestLine(to point: CGPoint, in lines: [RDEPUBPageLine]) -> RDEPUBPageLine? {
|
||||
var bestLine: RDEPUBPageLine?
|
||||
var bestDistance: CGFloat = .greatestFiniteMagnitude
|
||||
|
||||
for line in lines {
|
||||
let lineBottom = line.baselineOrigin.y + line.descent
|
||||
let lineTop = line.baselineOrigin.y - line.ascent
|
||||
if point.y >= lineTop && point.y <= lineBottom {
|
||||
return line
|
||||
}
|
||||
let lineMidY = (lineTop + lineBottom) / 2
|
||||
let distance = abs(point.y - lineMidY)
|
||||
|
||||
if distance < bestDistance {
|
||||
bestDistance = distance
|
||||
bestLine = line
|
||||
}
|
||||
}
|
||||
|
||||
return bestLine
|
||||
}
|
||||
|
||||
private func normalizedIndex(_ idx: Int, lineRange: NSRange, pageRange: NSRange) -> Int {
|
||||
var result = idx
|
||||
if result < lineRange.location {
|
||||
result = lineRange.location
|
||||
}
|
||||
let lineEnd = lineRange.location + lineRange.length
|
||||
if result >= lineEnd {
|
||||
result = max(lineEnd - 1, lineRange.location)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
|
||||
guard let dtLayoutFrame else { return nil }
|
||||
return dtLayoutFrame.lineContaining(UInt(range.location))
|
||||
}
|
||||
#endif
|
||||
|
||||
private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] {
|
||||
guard rects.count > 1 else { return rects }
|
||||
|
||||
let sorted = rects.sorted { a, b in
|
||||
if abs(a.origin.y - b.origin.y) < 1 {
|
||||
return a.origin.x < b.origin.x
|
||||
}
|
||||
return a.origin.y < b.origin.y
|
||||
}
|
||||
|
||||
var merged: [CGRect] = [sorted[0]]
|
||||
for rect in sorted.dropFirst() {
|
||||
let last = merged[merged.count - 1]
|
||||
if abs(rect.origin.y - last.origin.y) < 1,
|
||||
rect.origin.x <= last.maxX + 2 {
|
||||
merged[merged.count - 1] = last.union(rect)
|
||||
} else {
|
||||
merged.append(rect)
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
struct RDEPUBPageLine {
|
||||
let stringRange: NSRange
|
||||
let frame: CGRect
|
||||
let baselineOrigin: CGPoint
|
||||
let ascent: CGFloat
|
||||
let descent: CGFloat
|
||||
let leading: CGFloat
|
||||
}
|
||||
|
||||
struct RDEPUBPageRun {
|
||||
let stringRange: NSRange
|
||||
let frame: CGRect
|
||||
let isAttachment: Bool
|
||||
}
|
||||
|
||||
struct RDEPUBPageAttachment {
|
||||
let stringRange: NSRange
|
||||
let frame: CGRect
|
||||
let displaySize: CGSize
|
||||
let placement: RDEPUBTextAttachmentPlacement?
|
||||
let kind: RDEPUBTextAttachmentKind?
|
||||
}
|
||||
|
||||
struct RDEPUBPageLayoutSnapshot {
|
||||
let page: RDEPUBTextPage
|
||||
let lines: [RDEPUBPageLine]
|
||||
let runs: [RDEPUBPageRun]
|
||||
let attachments: [RDEPUBPageAttachment]
|
||||
let pageContentRange: NSRange
|
||||
#if canImport(DTCoreText)
|
||||
let layoutFrame: DTCoreTextLayoutFrame
|
||||
#endif
|
||||
|
||||
func line(containing absoluteIndex: Int) -> RDEPUBPageLine? {
|
||||
lines.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
|
||||
}
|
||||
|
||||
func run(containing absoluteIndex: Int) -> RDEPUBPageRun? {
|
||||
runs.first { NSLocationInRange(absoluteIndex, $0.stringRange) }
|
||||
}
|
||||
|
||||
func runs(intersecting range: NSRange) -> [RDEPUBPageRun] {
|
||||
runs.filter { NSIntersectionRange($0.stringRange, range).length > 0 }
|
||||
}
|
||||
|
||||
func attachment(at point: CGPoint, hitSlop: CGFloat = 6) -> RDEPUBPageAttachment? {
|
||||
attachments.first { $0.frame.insetBy(dx: -hitSlop, dy: -hitSlop).contains(point) }
|
||||
}
|
||||
|
||||
func rects(containing point: CGPoint, in decorations: [RDEPUBTextOverlayDecoration]) -> [RDEPUBTextOverlayDecoration] {
|
||||
decorations.filter { decoration in
|
||||
decoration.rects.contains { $0.insetBy(dx: -4, dy: -4).contains(point) }
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
static func build(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
page: RDEPUBTextPage
|
||||
) -> RDEPUBPageLayoutSnapshot? {
|
||||
guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var lines: [RDEPUBPageLine] = []
|
||||
var runs: [RDEPUBPageRun] = []
|
||||
var attachments: [RDEPUBPageAttachment] = []
|
||||
|
||||
for dtLine in dtLines {
|
||||
let lineRange = dtLine.stringRange()
|
||||
let line = RDEPUBPageLine(
|
||||
stringRange: lineRange,
|
||||
frame: dtLine.frame,
|
||||
baselineOrigin: dtLine.baselineOrigin,
|
||||
ascent: dtLine.ascent,
|
||||
descent: dtLine.descent,
|
||||
leading: dtLine.leading
|
||||
)
|
||||
lines.append(line)
|
||||
|
||||
if let glyphRuns = dtLine.glyphRuns as? [DTCoreTextGlyphRun] {
|
||||
for run in glyphRuns {
|
||||
let runRange = run.stringRange()
|
||||
let isAttachment = run.attachment != nil
|
||||
runs.append(
|
||||
RDEPUBPageRun(
|
||||
stringRange: runRange,
|
||||
frame: run.frame,
|
||||
isAttachment: isAttachment
|
||||
)
|
||||
)
|
||||
|
||||
guard isAttachment else { continue }
|
||||
let metadata = attachmentMetadata(
|
||||
for: runRange,
|
||||
on: page
|
||||
)
|
||||
attachments.append(
|
||||
RDEPUBPageAttachment(
|
||||
stringRange: runRange,
|
||||
frame: run.frame,
|
||||
displaySize: run.frame.size,
|
||||
placement: metadata.placement,
|
||||
kind: metadata.kind
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let visibleRange = layoutFrame.visibleStringRange()
|
||||
|
||||
return RDEPUBPageLayoutSnapshot(
|
||||
page: page,
|
||||
lines: lines,
|
||||
runs: runs,
|
||||
attachments: attachments,
|
||||
pageContentRange: visibleRange,
|
||||
layoutFrame: layoutFrame
|
||||
)
|
||||
}
|
||||
|
||||
private static func attachmentMetadata(
|
||||
for range: NSRange,
|
||||
on page: RDEPUBTextPage
|
||||
) -> (placement: RDEPUBTextAttachmentPlacement?, kind: RDEPUBTextAttachmentKind?) {
|
||||
guard let attachmentIndex = page.metadata.attachmentRanges.firstIndex(where: { NSIntersectionRange($0, range).length > 0 }) else {
|
||||
return (nil, nil)
|
||||
}
|
||||
|
||||
let placement = page.metadata.attachmentPlacements.indices.contains(attachmentIndex)
|
||||
? page.metadata.attachmentPlacements[attachmentIndex]
|
||||
: nil
|
||||
let kind = page.metadata.attachmentKinds.indices.contains(attachmentIndex)
|
||||
? page.metadata.attachmentKinds[attachmentIndex]
|
||||
: nil
|
||||
return (placement, kind)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -58,6 +58,11 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
currentVisibleLocation()
|
||||
}
|
||||
|
||||
public var currentPageNumber: Int? {
|
||||
guard readerView.currentPage >= 0 else { return nil }
|
||||
return readerView.currentPage + 1
|
||||
}
|
||||
|
||||
public private(set) var currentSelection: RDEPUBSelection?
|
||||
|
||||
public var highlights: [RDEPUBHighlight] {
|
||||
@@ -263,6 +268,27 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
restoreReadingLocation(location)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
guard pageNumber > 0 else { return false }
|
||||
|
||||
if textBook != nil {
|
||||
guard let location = resolvedTextLocation(forPageNumber: pageNumber) else {
|
||||
return false
|
||||
}
|
||||
return restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
guard activePages.indices.contains(pageNumber - 1) else {
|
||||
return false
|
||||
}
|
||||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
if let location = currentVisibleLocation() {
|
||||
persist(location: location)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public func clearSelection() {
|
||||
updateCurrentSelection(nil)
|
||||
}
|
||||
@@ -489,12 +515,17 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
}
|
||||
|
||||
private func applyReaderViewConfiguration() {
|
||||
let displayTypeDidChange = readerView.currentDisplayType != configuration.displayType
|
||||
let preservedLocation = displayTypeDidChange ? currentVisibleLocation() : nil
|
||||
view.backgroundColor = configuration.theme.contentBackgroundColor
|
||||
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
|
||||
readerView.pageDirection = resolvedPageDirection()
|
||||
updateReaderChrome()
|
||||
if readerView.currentDisplayType != configuration.displayType {
|
||||
if displayTypeDidChange {
|
||||
readerView.switchReaderDisplayType(configuration.displayType)
|
||||
if let preservedLocation {
|
||||
_ = restoreReadingLocation(preservedLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,7 +781,8 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
href: selection.location.href,
|
||||
progression: selection.location.progression,
|
||||
lastProgression: selection.location.lastProgression,
|
||||
fragment: selection.location.fragment
|
||||
fragment: selection.location.fragment,
|
||||
rangeAnchor: selection.location.rangeAnchor
|
||||
)
|
||||
return RDEPUBSelection(
|
||||
bookIdentifier: currentBookIdentifier,
|
||||
@@ -772,7 +804,8 @@ public final class RDEPUBReaderController: UIViewController {
|
||||
href: highlight.location.href,
|
||||
progression: highlight.location.progression,
|
||||
lastProgression: highlight.location.lastProgression,
|
||||
fragment: highlight.location.fragment
|
||||
fragment: highlight.location.fragment,
|
||||
rangeAnchor: highlight.location.rangeAnchor
|
||||
)
|
||||
return RDEPUBHighlight(
|
||||
id: highlight.id,
|
||||
@@ -1367,6 +1400,11 @@ extension RDEPUBReaderController {
|
||||
return false
|
||||
}
|
||||
|
||||
if let bookmarkAnchor = bookmark.location.rangeAnchor,
|
||||
let locationAnchor = location.rangeAnchor {
|
||||
return bookmarkAnchor == locationAnchor
|
||||
}
|
||||
|
||||
if let bookmarkFragment = bookmark.location.fragment,
|
||||
let locationFragment = location.fragment {
|
||||
return bookmarkFragment == locationFragment
|
||||
@@ -1394,7 +1432,8 @@ extension RDEPUBReaderController {
|
||||
href: location.href,
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: location.fragment
|
||||
fragment: location.fragment,
|
||||
rangeAnchor: location.rangeAnchor
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1407,7 +1446,8 @@ extension RDEPUBReaderController {
|
||||
href: location.href,
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: location.fragment
|
||||
fragment: location.fragment,
|
||||
rangeAnchor: location.rangeAnchor
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1499,21 +1539,32 @@ extension RDEPUBReaderController {
|
||||
href: searchMatch.href,
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor
|
||||
)
|
||||
return restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
|
||||
if let textBook,
|
||||
let publication,
|
||||
let rangeLocation = searchMatch.rangeLocation,
|
||||
let chapter = textBook.chapters.first(where: {
|
||||
(publication.resourceResolver.normalizedHref($0.href) ?? $0.href) ==
|
||||
(publication.resourceResolver.normalizedHref(searchMatch.href) ?? searchMatch.href)
|
||||
}),
|
||||
let page = chapter.pages.first(where: { rangeLocation >= $0.pageStartOffset && rangeLocation <= $0.pageEndOffset }) {
|
||||
return page.absolutePageIndex + 1
|
||||
if let chapterData = textChapterData(forNormalizedHref: searchMatch.href) {
|
||||
let location = RDEPUBLocation(
|
||||
bookIdentifier: currentBookIdentifier,
|
||||
href: chapterData.href,
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor
|
||||
)
|
||||
if let pageNumber = chapterData.pageNumber(for: location) {
|
||||
return pageNumber
|
||||
}
|
||||
|
||||
if let rangeLocation = searchMatch.rangeLocation,
|
||||
let page = chapterData.pages.first(where: {
|
||||
rangeLocation >= $0.pageStartOffset && rangeLocation <= $0.pageEndOffset
|
||||
}) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
}
|
||||
|
||||
let location = RDEPUBLocation(
|
||||
@@ -1521,7 +1572,8 @@ extension RDEPUBReaderController {
|
||||
href: searchMatch.href,
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor
|
||||
)
|
||||
|
||||
if let textBook, let publication {
|
||||
@@ -1554,9 +1606,12 @@ extension RDEPUBReaderController {
|
||||
}
|
||||
|
||||
let currentMatch = searchState.currentMatch
|
||||
let normalizedCurrentHref = currentMatch.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
|
||||
let resources = pageHrefs.map { href in
|
||||
let matchCount = searchState.matches.filter { $0.href == href }.count
|
||||
let activeLocalMatchIndex = currentMatch?.href == href ? currentMatch?.localMatchIndex : nil
|
||||
let matchCount = searchState.matches.filter {
|
||||
(publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == href
|
||||
}.count
|
||||
let activeLocalMatchIndex = normalizedCurrentHref == href ? currentMatch?.localMatchIndex : nil
|
||||
return RDEPUBSearchPresentationResource(
|
||||
href: href,
|
||||
matchCount: matchCount,
|
||||
@@ -1611,6 +1666,11 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
}
|
||||
|
||||
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(for: page.href) {
|
||||
return chapterData.highlights(on: page, from: activeHighlights)
|
||||
}
|
||||
|
||||
guard let publication else {
|
||||
return activeHighlights.filter { $0.location.href == page.href }
|
||||
}
|
||||
@@ -1620,6 +1680,14 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||
guard let textBook, let publication else { return nil }
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href
|
||||
return textBook.chapters.lazy
|
||||
.first(where: { (publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref })
|
||||
.flatMap { textBook.chapterData(for: $0.href) }
|
||||
}
|
||||
|
||||
public func topToolView(readerView: RDReaderView) -> UIView? {
|
||||
topToolView
|
||||
}
|
||||
@@ -1728,27 +1796,22 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
|
||||
private func normalizedTextSelection(_ selection: RDEPUBSelection) -> RDEPUBSelection? {
|
||||
guard let textBook,
|
||||
let chapter = textBook.chapters.first(where: { $0.href == selection.location.href }) else {
|
||||
let chapterData = textBook.chapterData(for: selection.location.href) else {
|
||||
return scopedSelection(selection, relativeToSpineIndex: nil)
|
||||
}
|
||||
guard let payload = RDEPUBTextOffsetRangeInfo.decode(from: selection.rangeInfo) else {
|
||||
return scopedSelection(selection, relativeToSpineIndex: nil)
|
||||
}
|
||||
|
||||
let contentLength = max(chapter.attributedContent.length, 1)
|
||||
let contentLength = max(chapterData.attributedContent.length, 1)
|
||||
let lastInclusiveOffset = max(contentLength - 1, 1)
|
||||
let start = max(0, min(payload.start, lastInclusiveOffset))
|
||||
let endExclusive = max(start + 1, min(payload.end, contentLength))
|
||||
let lastSelectedOffset = max(start, min(endExclusive - 1, lastInclusiveOffset))
|
||||
let absoluteRange = NSRange(location: start, length: endExclusive - start)
|
||||
let location = chapterData.location(for: absoluteRange, bookIdentifier: currentBookIdentifier)
|
||||
return RDEPUBSelection(
|
||||
bookIdentifier: currentBookIdentifier,
|
||||
location: RDEPUBLocation(
|
||||
bookIdentifier: currentBookIdentifier,
|
||||
href: selection.location.href,
|
||||
progression: Double(start) / Double(lastInclusiveOffset),
|
||||
lastProgression: Double(lastSelectedOffset) / Double(lastInclusiveOffset),
|
||||
fragment: nil
|
||||
),
|
||||
location: location,
|
||||
text: selection.text,
|
||||
rangeInfo: selection.rangeInfo,
|
||||
createdAt: selection.createdAt
|
||||
@@ -1757,6 +1820,13 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
|
||||
private func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
if let textBook, let publication {
|
||||
// Anchor-based lookup (character-level precision)
|
||||
if let anchor = location.rangeAnchor?.start {
|
||||
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
|
||||
return page + 1
|
||||
}
|
||||
}
|
||||
|
||||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
@@ -1779,13 +1849,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
private func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
|
||||
guard let textBook,
|
||||
let publication,
|
||||
let location = textBook.location(
|
||||
forPageNumber: pageNumber,
|
||||
bookIdentifier: currentBookIdentifier
|
||||
) else {
|
||||
let page = textBook.page(at: pageNumber) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let location = textBook.chapterData(for: page.href)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
|
||||
?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
|
||||
guard let location else { return nil }
|
||||
|
||||
return publication.resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: nil,
|
||||
|
||||
@@ -17,8 +17,10 @@ struct RDEPUBTextOverlayDecoration {
|
||||
}
|
||||
|
||||
final class RDEPUBSelectionOverlayView: UIView {
|
||||
private var page: RDEPUBTextPage?
|
||||
private var selectionRange: NSRange?
|
||||
private(set) var page: RDEPUBTextPage?
|
||||
private var snapshot: RDEPUBPageLayoutSnapshot?
|
||||
private(set) var selectionRange: NSRange?
|
||||
private var selectionRects: [CGRect] = []
|
||||
private var decorations: [RDEPUBTextOverlayDecoration] = []
|
||||
private let selectionVerticalAdjustment: CGFloat = -1
|
||||
|
||||
@@ -39,16 +41,28 @@ final class RDEPUBSelectionOverlayView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(page: RDEPUBTextPage, selectionColor: UIColor) {
|
||||
func configure(page: RDEPUBTextPage, selectionColor: UIColor, snapshot: RDEPUBPageLayoutSnapshot? = nil) {
|
||||
self.page = page
|
||||
self.snapshot = snapshot
|
||||
self.selectionColor = selectionColor
|
||||
selectionRange = nil
|
||||
decorations = []
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
func updateSnapshot(_ snapshot: RDEPUBPageLayoutSnapshot?) {
|
||||
self.snapshot = snapshot
|
||||
}
|
||||
|
||||
func updateSelection(absoluteRange: NSRange?) {
|
||||
selectionRange = absoluteRange
|
||||
selectionRects = []
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
func updateSelection(absoluteRange: NSRange?, rects: [CGRect]) {
|
||||
selectionRange = absoluteRange
|
||||
selectionRects = rects
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
@@ -62,6 +76,32 @@ final class RDEPUBSelectionOverlayView: UIView {
|
||||
}
|
||||
|
||||
func absoluteRange(at point: CGPoint) -> NSRange? {
|
||||
if let selectionRange,
|
||||
selectionRects.contains(where: { $0.insetBy(dx: -4, dy: -4).contains(point) }) {
|
||||
return selectionRange
|
||||
}
|
||||
|
||||
if let snapshot,
|
||||
let attachment = snapshot.attachment(at: point) {
|
||||
return attachment.stringRange
|
||||
}
|
||||
|
||||
if let snapshot {
|
||||
let hitDecorations = snapshot.rects(containing: point, in: resolvedDecorations)
|
||||
if let mostSpecific = hitDecorations.min(by: { lhs, rhs in
|
||||
lhs.absoluteRange.length < rhs.absoluteRange.length
|
||||
}) {
|
||||
return mostSpecific.absoluteRange
|
||||
}
|
||||
}
|
||||
|
||||
for decoration in resolvedDecorations {
|
||||
for rect in decoration.rects {
|
||||
if rect.insetBy(dx: -4, dy: -4).contains(point) {
|
||||
return decoration.absoluteRange
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,21 +150,21 @@ final class RDEPUBSelectionOverlayView: UIView {
|
||||
}
|
||||
|
||||
private var resolvedDecorations: [RDEPUBTextOverlayDecoration] {
|
||||
guard let page else { return [] }
|
||||
guard page != nil else { return [] }
|
||||
|
||||
let nonSelection = decorations.filter { !$0.rects.isEmpty }
|
||||
guard let selectionRange else { return nonSelection }
|
||||
var result = decorations.filter { !$0.rects.isEmpty }
|
||||
|
||||
let selectionRects:[CGRect] = []
|
||||
guard !selectionRects.isEmpty else { return nonSelection }
|
||||
|
||||
return [
|
||||
RDEPUBTextOverlayDecoration(
|
||||
kind: .selection,
|
||||
absoluteRange: selectionRange,
|
||||
rects: selectionRects,
|
||||
color: selectionColor
|
||||
if let selectionRange, !selectionRects.isEmpty {
|
||||
result.append(
|
||||
RDEPUBTextOverlayDecoration(
|
||||
kind: .selection,
|
||||
absoluteRange: selectionRange,
|
||||
rects: selectionRects,
|
||||
color: selectionColor
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,9 @@ final class RDEPUBTextContentView: UIView {
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
private var highlightedRanges: [RDEPUBHighlight] = []
|
||||
private var currentSearchState: RDEPUBSearchState?
|
||||
private var isSelectionFromInteraction = false
|
||||
private var selectionMenuAnchorRect: CGRect?
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
@@ -91,6 +94,20 @@ final class RDEPUBTextContentView: UIView {
|
||||
private var coreTextDisplayRange: NSRange?
|
||||
#endif
|
||||
|
||||
private let interactionController = RDEPUBPageInteractionController()
|
||||
|
||||
private let backgroundOverlayView: RDEPUBSelectionOverlayView = {
|
||||
let view = RDEPUBSelectionOverlayView()
|
||||
return view
|
||||
}()
|
||||
|
||||
private let overlayView: RDEPUBSelectionOverlayView = {
|
||||
let view = RDEPUBSelectionOverlayView()
|
||||
return view
|
||||
}()
|
||||
|
||||
private var selectionAnchorPoint: CGPoint?
|
||||
|
||||
private let textView: RDEPUBSelectableTextView = {
|
||||
let view = RDEPUBSelectableTextView()
|
||||
view.isEditable = false
|
||||
@@ -119,8 +136,10 @@ final class RDEPUBTextContentView: UIView {
|
||||
super.init(frame: frame)
|
||||
addSubview(coverImageView)
|
||||
#if canImport(DTCoreText)
|
||||
addSubview(backgroundOverlayView)
|
||||
addSubview(coreTextContentView)
|
||||
#endif
|
||||
addSubview(overlayView)
|
||||
addSubview(textView)
|
||||
addSubview(pageNumberLabel)
|
||||
textView.delegate = self
|
||||
@@ -128,24 +147,46 @@ final class RDEPUBTextContentView: UIView {
|
||||
guard let self else { return }
|
||||
self.delegate?.textContentView(self, didRequestSelectionAction: action)
|
||||
}
|
||||
UIMenuController.shared.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBSelectableTextView.rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBSelectableTextView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(RDEPUBSelectableTextView.rd_annotate(_:)))
|
||||
]
|
||||
|
||||
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||
longPress.minimumPressDuration = 0.4
|
||||
addGestureRecognizer(longPress)
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
tap.numberOfTapsRequired = 1
|
||||
addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
#if canImport(DTCoreText)
|
||||
switch action {
|
||||
case #selector(rd_copy(_:)),
|
||||
#selector(rd_highlight(_:)),
|
||||
#selector(rd_annotate(_:)):
|
||||
return overlayView.selectionRange?.length ?? 0 > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
#else
|
||||
return super.canPerformAction(action, withSender: sender)
|
||||
#endif
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
backgroundOverlayView.frame = bounds.inset(by: contentInsets)
|
||||
coreTextContentView.frame = bounds.inset(by: contentInsets)
|
||||
updateCoreTextLayoutFrameIfNeeded()
|
||||
#endif
|
||||
overlayView.frame = bounds.inset(by: contentInsets)
|
||||
textView.frame = bounds.inset(by: contentInsets)
|
||||
coverImageView.frame = bounds.inset(by: contentInsets)
|
||||
|
||||
@@ -168,6 +209,7 @@ final class RDEPUBTextContentView: UIView {
|
||||
) {
|
||||
currentPage = page
|
||||
highlightedRanges = highlights
|
||||
currentSearchState = searchState
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
@@ -179,6 +221,8 @@ final class RDEPUBTextContentView: UIView {
|
||||
coreTextContentView.layoutFrame = nil
|
||||
coreTextDisplayContent = nil
|
||||
coreTextDisplayRange = nil
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
#endif
|
||||
textView.attributedText = nil
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
@@ -197,7 +241,6 @@ final class RDEPUBTextContentView: UIView {
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: selectionRange
|
||||
)
|
||||
normalizeInlineAttachments(in: selectionContent, basePointSize: configuration.fontSize)
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
let displayContent = NSMutableAttributedString(attributedString: page.chapterContent)
|
||||
@@ -207,31 +250,152 @@ final class RDEPUBTextContentView: UIView {
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: fullRange
|
||||
)
|
||||
normalizeInlineAttachments(in: displayContent, basePointSize: configuration.fontSize)
|
||||
applyHighlights(to: displayContent, page: page, contentBaseOffset: 0)
|
||||
applySearchHighlights(to: displayContent, page: page, searchState: searchState, contentBaseOffset: 0)
|
||||
coreTextContentView.isHidden = false
|
||||
coreTextContentView.backgroundColor = .clear
|
||||
coreTextDisplayContent = displayContent
|
||||
coreTextDisplayRange = page.contentRange
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
textView.attributedText = nil
|
||||
updateCoreTextLayoutFrameIfNeeded()
|
||||
#else
|
||||
applyHighlights(to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
|
||||
applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
|
||||
textView.isHidden = false
|
||||
textView.isUserInteractionEnabled = true
|
||||
#endif
|
||||
|
||||
#if !canImport(DTCoreText)
|
||||
textView.tintColor = configuration.theme.toolControlTextColor
|
||||
textView.attributedText = selectionProxyContent(from: selectionContent)
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
#endif
|
||||
|
||||
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||
#if canImport(DTCoreText)
|
||||
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
|
||||
let (bgDecorations, fgDecorations) = buildOverlayDecorations(page: page)
|
||||
backgroundOverlayView.applyDecorations(bgDecorations)
|
||||
overlayView.applyDecorations(fgDecorations)
|
||||
#endif
|
||||
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
overlayView.clearSelection()
|
||||
backgroundOverlayView.clearSelection()
|
||||
selectionAnchorPoint = nil
|
||||
selectionMenuAnchorRect = nil
|
||||
isSelectionFromInteraction = false
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
}
|
||||
|
||||
// MARK: - Gesture Handling
|
||||
|
||||
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
let point = gesture.location(in: overlayView)
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
selectionAnchorPoint = point
|
||||
isSelectionFromInteraction = true
|
||||
handleSelectionFromInteraction(point: point, anchorPoint: nil)
|
||||
|
||||
case .changed:
|
||||
guard let anchor = selectionAnchorPoint else { return }
|
||||
handleSelectionFromInteraction(point: point, anchorPoint: anchor)
|
||||
|
||||
case .ended:
|
||||
isSelectionFromInteraction = false
|
||||
showSelectionMenuIfNeeded()
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSelectionFromInteraction(point: CGPoint, anchorPoint: CGPoint?) {
|
||||
guard let page = currentPage else { return }
|
||||
|
||||
let range: NSRange?
|
||||
if let anchor = anchorPoint {
|
||||
range = interactionController.selectionRange(from: anchor, to: point)
|
||||
} else if let idx = interactionController.characterIndex(at: point) {
|
||||
range = NSRange(location: idx, length: 1)
|
||||
} else {
|
||||
range = nil
|
||||
}
|
||||
|
||||
guard let range else { return }
|
||||
let rects = interactionController.selectionRects(for: range)
|
||||
overlayView.updateSelection(absoluteRange: range, rects: rects)
|
||||
selectionMenuAnchorRect = interactionController.menuAnchorRect(for: range)
|
||||
notifySelectionChange(range: range, page: page)
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
@objc private func rd_copy(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .copy)
|
||||
}
|
||||
|
||||
@objc private func rd_highlight(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .highlight)
|
||||
}
|
||||
|
||||
@objc private func rd_annotate(_ sender: Any?) {
|
||||
delegate?.textContentView(self, didRequestSelectionAction: .annotate)
|
||||
}
|
||||
|
||||
private func notifySelectionChange(range: NSRange, page: RDEPUBTextPage) {
|
||||
let source = page.chapterContent.string as NSString
|
||||
let selectedText = source.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedText.isEmpty else {
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let totalLength = max(page.content.length - 1, 1)
|
||||
let relativeLocation = range.location - page.pageStartOffset
|
||||
let selection = RDEPUBSelection(
|
||||
location: RDEPUBLocation(
|
||||
href: page.href,
|
||||
progression: Double(max(relativeLocation, 0)) / Double(totalLength),
|
||||
lastProgression: Double(max(relativeLocation + range.length - 1, 0)) / Double(totalLength),
|
||||
fragment: nil
|
||||
),
|
||||
text: selectedText,
|
||||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: range.location, end: range.location + range.length).jsonString()
|
||||
)
|
||||
delegate?.textContentView(self, didChangeSelection: selection)
|
||||
}
|
||||
|
||||
private func showSelectionMenuIfNeeded() {
|
||||
#if canImport(DTCoreText)
|
||||
guard overlayView.selectionRange?.length ?? 0 > 0,
|
||||
let anchorRect = selectionMenuAnchorRect ?? overlayView.selectionRange.flatMap({ interactionController.menuAnchorRect(for: $0) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
becomeFirstResponder()
|
||||
let menuRect = overlayView.convert(anchorRect, to: self)
|
||||
let menuController = UIMenuController.shared
|
||||
menuController.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBTextContentView.rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBTextContentView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(RDEPUBTextContentView.rd_annotate(_:)))
|
||||
]
|
||||
menuController.setTargetRect(menuRect, in: self)
|
||||
menuController.setMenuVisible(true, animated: true)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func applyHighlights(to content: NSMutableAttributedString, page: RDEPUBTextPage) {
|
||||
applyHighlights(to: content, page: page, contentBaseOffset: page.pageStartOffset)
|
||||
}
|
||||
@@ -317,6 +481,68 @@ final class RDEPUBTextContentView: UIView {
|
||||
return lowerBound..<max(upperBound, lowerBound)
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func buildOverlayDecorations(page: RDEPUBTextPage) -> (background: [RDEPUBTextOverlayDecoration], foreground: [RDEPUBTextOverlayDecoration]) {
|
||||
var background: [RDEPUBTextOverlayDecoration] = []
|
||||
var foreground: [RDEPUBTextOverlayDecoration] = []
|
||||
let pageRange = absoluteOffsetRange(for: page)
|
||||
let pageStart = pageRange.lowerBound
|
||||
let pageEndExclusive = pageRange.upperBound
|
||||
|
||||
// Search results → background (behind text)
|
||||
if let searchState = currentSearchState {
|
||||
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||
|
||||
for match in searchState.matches where match.href == page.href {
|
||||
guard let matchStart = match.rangeLocation else { continue }
|
||||
let matchEnd = matchStart + match.rangeLength
|
||||
let overlapStart = max(matchStart, pageStart)
|
||||
let overlapEnd = min(matchEnd, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
|
||||
let rects = interactionController.selectionRects(for: absoluteRange)
|
||||
guard !rects.isEmpty else { continue }
|
||||
|
||||
let isActive = match == searchState.currentMatch
|
||||
let kind: RDEPUBTextOverlayDecoration.Kind = isActive ? .activeSearch : .search
|
||||
let color = isActive ? activeColor : normalColor
|
||||
background.append(RDEPUBTextOverlayDecoration(kind: kind, absoluteRange: absoluteRange, rects: rects, color: color))
|
||||
}
|
||||
}
|
||||
|
||||
// Highlights → background (filled) or foreground (underline)
|
||||
for highlight in highlightedRanges where highlight.location.href == page.href {
|
||||
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
|
||||
let overlapStart = max(range.location, pageStart)
|
||||
let overlapEnd = min(range.location + range.length, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let absoluteRange = NSRange(location: overlapStart, length: overlapEnd - overlapStart)
|
||||
let rects = interactionController.selectionRects(for: absoluteRange)
|
||||
guard !rects.isEmpty else { continue }
|
||||
|
||||
let color = UIColor(hexString: highlight.color, alpha: 0.45)
|
||||
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45)
|
||||
let decoration = RDEPUBTextOverlayDecoration(
|
||||
kind: highlight.style == .underline ? .underline : .highlight,
|
||||
absoluteRange: absoluteRange,
|
||||
rects: rects,
|
||||
color: color
|
||||
)
|
||||
|
||||
if decoration.kind == .underline {
|
||||
foreground.append(decoration)
|
||||
} else {
|
||||
background.append(decoration)
|
||||
}
|
||||
}
|
||||
|
||||
return (background, foreground)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
|
||||
guard page.pageIndexInChapter == 0,
|
||||
page.href.lowercased().contains("cover"),
|
||||
@@ -331,6 +557,8 @@ final class RDEPUBTextContentView: UIView {
|
||||
coreTextContentView.layoutFrame = nil
|
||||
coreTextDisplayContent = nil
|
||||
coreTextDisplayRange = nil
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
#endif
|
||||
textView.attributedText = nil
|
||||
return true
|
||||
@@ -369,21 +597,6 @@ final class RDEPUBTextContentView: UIView {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func normalizeInlineAttachments(in content: NSMutableAttributedString, basePointSize: CGFloat) {
|
||||
guard content.length > 0 else { return }
|
||||
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in
|
||||
#if canImport(DTCoreText)
|
||||
if let attachment = value as? DTTextAttachment {
|
||||
RDEPUBTextRendererSupport.normalizeAttachmentLayoutForWXRead(
|
||||
attachment,
|
||||
fontPointSize: basePointSize
|
||||
)
|
||||
content.addAttribute(.attachment, value: attachment, range: range)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
|
||||
let proxy = NSMutableAttributedString(attributedString: content)
|
||||
let fullRange = NSRange(location: 0, length: proxy.length)
|
||||
@@ -415,17 +628,24 @@ final class RDEPUBTextContentView: UIView {
|
||||
guard !coreTextContentView.isHidden,
|
||||
let displayContent = coreTextDisplayContent,
|
||||
let displayRange = coreTextDisplayRange,
|
||||
let page = currentPage,
|
||||
coreTextContentView.bounds.width > 0,
|
||||
coreTextContentView.bounds.height > 0 else {
|
||||
interactionController.configure(layoutFrame: nil, page: currentPage)
|
||||
return
|
||||
}
|
||||
|
||||
guard let layouter = DTCoreTextLayouter(attributedString: displayContent) else {
|
||||
coreTextContentView.layoutFrame = nil
|
||||
interactionController.configure(layoutFrame: nil, page: page)
|
||||
return
|
||||
}
|
||||
layouter.shouldCacheLayoutFrames = false
|
||||
coreTextContentView.layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
|
||||
let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
|
||||
coreTextContentView.layoutFrame = layoutFrame
|
||||
interactionController.configure(layoutFrame: layoutFrame, page: page)
|
||||
overlayView.updateSnapshot(interactionController.snapshot)
|
||||
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -433,6 +653,7 @@ final class RDEPUBTextContentView: UIView {
|
||||
|
||||
extension RDEPUBTextContentView: UITextViewDelegate {
|
||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||
guard !isSelectionFromInteraction else { return }
|
||||
guard let page = currentPage else {
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
return
|
||||
|
||||
@@ -211,12 +211,25 @@ public class RDReaderView: UIView {
|
||||
private var isShowToolView: Bool = false
|
||||
private var isTransitioning: Bool = false
|
||||
private var didBuildUI = false
|
||||
private let preloadHostView = UIView()
|
||||
private var preloadedPageViews: [Int: UIView] = [:]
|
||||
private var pageCurlCachedViews: [Int: UIView] = [:]
|
||||
private var predictedPageDirection: Bool?
|
||||
public var preloadRadius: Int = 1
|
||||
private var cacheSignature: CacheSignature?
|
||||
|
||||
/// 用于 pageCurl 双页模式下封面页旁边的空白页
|
||||
static let blankPageNum = Int.max
|
||||
/// 用于 pageCurl 双页模式下末尾不成对页旁边的空白页
|
||||
static let blankEndPageNum = Int.max - 1
|
||||
|
||||
private struct CacheSignature: Equatable {
|
||||
let displayType: DisplayType
|
||||
let isLandscape: Bool
|
||||
let pagesPerScreen: Int
|
||||
let boundsSize: CGSize
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
}
|
||||
@@ -224,6 +237,7 @@ public class RDReaderView: UIView {
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||
preloadHostView.frame = bounds
|
||||
let nowLandscape = isLandscape
|
||||
if let prev = previousIsLandscape, prev != nowLandscape {
|
||||
previousIsLandscape = nowLandscape
|
||||
@@ -254,6 +268,7 @@ public class RDReaderView: UIView {
|
||||
// 仿真翻页:通过 spineLocation 原生支持双页,需要重建 PageViewController
|
||||
rebuildPageViewController()
|
||||
transitionToPage(pageNum: savedPage)
|
||||
primePageCache(around: savedPage, preferredForward: predictedPageDirection)
|
||||
default:
|
||||
// 滚动模式:禁用动画防止旋转过渡中出现尺寸抖动
|
||||
UIView.performWithoutAnimation {
|
||||
@@ -266,6 +281,7 @@ public class RDReaderView: UIView {
|
||||
let safePage = min(savedPage, max(0, totalPages - 1))
|
||||
let targetOffset = layout.currentContentOffset(count: safePage)
|
||||
collectionView.setContentOffset(targetOffset, animated: false)
|
||||
primePageCache(around: safePage, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,12 +305,222 @@ public class RDReaderView: UIView {
|
||||
/// 重建 UIPageViewController。横竖屏切换时需要重建,因为 spineLocation 只能在初始化时设置
|
||||
private func rebuildPageViewController() {
|
||||
detachPageViewControllerIfNeeded()
|
||||
|
||||
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape
|
||||
let pageVC = createPageViewController(isDualPage: isDualPage)
|
||||
pageViewController = pageVC
|
||||
attachPageViewControllerIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - UIPageViewController Fault Detection
|
||||
|
||||
private func detectPageViewControllerFault(_ pageVC: UIPageViewController) -> Bool {
|
||||
guard currentDisplayType == .pageCurl else { return false }
|
||||
let expectedCount = (landscapeDualPageEnabled && isLandscape) ? 2 : 1
|
||||
guard let viewControllers = pageVC.viewControllers,
|
||||
viewControllers.count == expectedCount else {
|
||||
return true
|
||||
}
|
||||
|
||||
let childViewControllers = viewControllers.compactMap { $0 as? RDReaderPageChildViewController }
|
||||
guard childViewControllers.count == expectedCount else {
|
||||
return true
|
||||
}
|
||||
|
||||
if expectedCount == 1 {
|
||||
return childViewControllers.first?.pageNum != currentPage
|
||||
}
|
||||
|
||||
let expectedPair = dualPagePair(for: currentPage)
|
||||
let expectedRightPage = expectedPair.right
|
||||
?? (isFullScreenPage(expectedPair.left) ? RDReaderView.blankPageNum : RDReaderView.blankEndPageNum)
|
||||
return childViewControllers[0].pageNum != expectedPair.left
|
||||
|| childViewControllers[1].pageNum != expectedRightPage
|
||||
}
|
||||
|
||||
private func patchPageViewControllerFault() {
|
||||
guard currentPage >= 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.invalidatePageCaches()
|
||||
self.rebuildPageViewController()
|
||||
self.transitionToPage(pageNum: self.currentPage, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preload / Forecast
|
||||
|
||||
private func ensurePreloadHostView() {
|
||||
guard preloadHostView.superview == nil else { return }
|
||||
preloadHostView.isHidden = true
|
||||
preloadHostView.isUserInteractionEnabled = false
|
||||
preloadHostView.clipsToBounds = true
|
||||
preloadHostView.frame = bounds
|
||||
preloadHostView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
insertSubview(preloadHostView, at: 0)
|
||||
}
|
||||
|
||||
private func shouldCachePage(_ pageNum: Int) -> Bool {
|
||||
pageNum >= 0
|
||||
&& pageNum != RDReaderView.blankPageNum
|
||||
&& pageNum != RDReaderView.blankEndPageNum
|
||||
&& pageNum < (dataSource?.pageCountOfReaderView(readerView: self) ?? 0)
|
||||
}
|
||||
|
||||
private func currentCacheSignature() -> CacheSignature {
|
||||
CacheSignature(
|
||||
displayType: currentDisplayType,
|
||||
isLandscape: isLandscape,
|
||||
pagesPerScreen: pagesPerScreen,
|
||||
boundsSize: bounds.size
|
||||
)
|
||||
}
|
||||
|
||||
private func invalidatePageCaches() {
|
||||
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
|
||||
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
|
||||
pageCurlCachedViews.removeAll()
|
||||
preloadedPageViews.removeAll()
|
||||
cacheSignature = currentCacheSignature()
|
||||
}
|
||||
|
||||
private func refreshCacheSignatureIfNeeded() {
|
||||
let signature = currentCacheSignature()
|
||||
if cacheSignature != signature {
|
||||
invalidatePageCaches()
|
||||
} else if cacheSignature == nil {
|
||||
cacheSignature = signature
|
||||
}
|
||||
}
|
||||
|
||||
private func spreadStart(for pageNum: Int) -> Int? {
|
||||
guard shouldCachePage(pageNum) else { return nil }
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape && currentDisplayType != .verticalScroll
|
||||
guard isDualPage else { return pageNum }
|
||||
return dualPagePair(for: pageNum).left
|
||||
}
|
||||
|
||||
private func spreadPageNumbers(startingAt pageNum: Int) -> Set<Int> {
|
||||
guard shouldCachePage(pageNum) else { return [] }
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape && currentDisplayType != .verticalScroll
|
||||
guard isDualPage else { return [pageNum] }
|
||||
let pair = dualPagePair(for: pageNum)
|
||||
var pages: Set<Int> = [pair.left]
|
||||
if let right = pair.right, shouldCachePage(right) {
|
||||
pages.insert(right)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
private func adjacentSpreadStart(from pageNum: Int, forward: Bool) -> Int? {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
guard totalPages > 0,
|
||||
let spreadStart = spreadStart(for: pageNum) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape && currentDisplayType != .verticalScroll
|
||||
guard isDualPage else {
|
||||
let target = forward ? spreadStart + 1 : spreadStart - 1
|
||||
return shouldCachePage(target) ? target : nil
|
||||
}
|
||||
|
||||
if forward {
|
||||
return adjacentDualPage(from: spreadStart, forward: true)
|
||||
}
|
||||
|
||||
let pair = dualPagePair(for: spreadStart)
|
||||
let prevEnd = pair.left - 1
|
||||
guard prevEnd >= 0 else { return nil }
|
||||
return dualPagePair(for: prevEnd).left
|
||||
}
|
||||
|
||||
private func visiblePageNumbers(for anchorPage: Int) -> Set<Int> {
|
||||
spreadPageNumbers(startingAt: anchorPage)
|
||||
}
|
||||
|
||||
private func pageViewForDisplay(pageNum: Int) -> UIView {
|
||||
if let cached = pageCurlCachedViews[pageNum] {
|
||||
return cached
|
||||
}
|
||||
if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) {
|
||||
preloaded.removeFromSuperview()
|
||||
pageCurlCachedViews[pageNum] = preloaded
|
||||
return preloaded
|
||||
}
|
||||
let view = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: nil) ?? UIView()
|
||||
pageCurlCachedViews[pageNum] = view
|
||||
return view
|
||||
}
|
||||
|
||||
private func trimCachedPageViews(keeping pageNumbers: Set<Int>) {
|
||||
pageCurlCachedViews = pageCurlCachedViews.filter { key, value in
|
||||
let keep = pageNumbers.contains(key)
|
||||
if !keep {
|
||||
value.removeFromSuperview()
|
||||
}
|
||||
return keep
|
||||
}
|
||||
|
||||
preloadedPageViews = preloadedPageViews.filter { key, value in
|
||||
let keep = pageNumbers.contains(key)
|
||||
if !keep {
|
||||
value.removeFromSuperview()
|
||||
}
|
||||
return keep
|
||||
}
|
||||
}
|
||||
|
||||
private func forecastTargets(around pageNum: Int, preferredForward: Bool?) -> [Int] {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
guard totalPages > 0, shouldCachePage(pageNum) else { return [] }
|
||||
|
||||
var targets = Set<Int>()
|
||||
|
||||
var previousAnchor = pageNum
|
||||
for _ in 0..<max(preloadRadius, 0) {
|
||||
guard let prev = adjacentSpreadStart(from: previousAnchor, forward: false) else { break }
|
||||
targets.formUnion(spreadPageNumbers(startingAt: prev))
|
||||
previousAnchor = prev
|
||||
}
|
||||
|
||||
var nextAnchor = pageNum
|
||||
for _ in 0..<max(preloadRadius, 0) {
|
||||
guard let next = adjacentSpreadStart(from: nextAnchor, forward: true) else { break }
|
||||
targets.formUnion(spreadPageNumbers(startingAt: next))
|
||||
nextAnchor = next
|
||||
}
|
||||
|
||||
if let preferredForward,
|
||||
let edgeAnchor = preferredForward ? adjacentSpreadStart(from: nextAnchor, forward: true)
|
||||
: adjacentSpreadStart(from: previousAnchor, forward: false) {
|
||||
targets.formUnion(spreadPageNumbers(startingAt: edgeAnchor))
|
||||
}
|
||||
|
||||
return targets.sorted()
|
||||
}
|
||||
|
||||
private func primePageCache(around pageNum: Int, preferredForward: Bool? = nil) {
|
||||
refreshCacheSignatureIfNeeded()
|
||||
let targets = forecastTargets(around: pageNum, preferredForward: preferredForward)
|
||||
let keepSet = Set(targets).union(visiblePageNumbers(for: pageNum))
|
||||
trimCachedPageViews(keeping: keepSet)
|
||||
guard !targets.isEmpty else { return }
|
||||
|
||||
ensurePreloadHostView()
|
||||
preloadHostView.frame = bounds
|
||||
|
||||
for targetPage in targets {
|
||||
let existing = preloadedPageViews[targetPage] ?? pageCurlCachedViews.removeValue(forKey: targetPage)
|
||||
let contentView = dataSource?.pageContentView(readerView: self, pageNum: targetPage, containerView: existing) ?? existing ?? UIView()
|
||||
preloadedPageViews[targetPage] = contentView
|
||||
if contentView.superview !== preloadHostView {
|
||||
contentView.removeFromSuperview()
|
||||
preloadHostView.addSubview(contentView)
|
||||
}
|
||||
contentView.frame = preloadHostView.bounds
|
||||
}
|
||||
}
|
||||
|
||||
public override func didMoveToSuperview() {
|
||||
super.didMoveToSuperview()
|
||||
@@ -356,6 +582,8 @@ public class RDReaderView: UIView {
|
||||
if currentDisplayType == .pageCurl {
|
||||
attachPageViewControllerIfNeeded()
|
||||
}
|
||||
ensurePreloadHostView()
|
||||
cacheSignature = currentCacheSignature()
|
||||
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
// 不取消底层触摸事件,确保工具栏按钮(返回等)的 touchUpInside 能正常触发
|
||||
@@ -464,10 +692,14 @@ public class RDReaderView: UIView {
|
||||
/// 切换翻页模式(仿真/水平滚动/上下滚动)
|
||||
/// 会重建底层视图(PageViewController 或 CollectionView),并恢复到当前页
|
||||
public func switchReaderDisplayType(_ displayType: RDReaderView.DisplayType) {
|
||||
let previousDisplayType = currentDisplayType
|
||||
self.currentDisplayType = displayType
|
||||
if currentPage == -1 {
|
||||
currentPage = 0
|
||||
}
|
||||
if previousDisplayType != displayType {
|
||||
invalidatePageCaches()
|
||||
}
|
||||
// 同步横屏双页标记到布局
|
||||
layout.isLandscapeDualPage = landscapeDualPageEnabled && isLandscape
|
||||
layout.coverPageIndex = coverPageIndex
|
||||
@@ -478,6 +710,7 @@ public class RDReaderView: UIView {
|
||||
attachPageViewControllerIfNeeded()
|
||||
rebuildPageViewController()
|
||||
transitionToPage(pageNum: currentPage)
|
||||
primePageCache(around: currentPage, preferredForward: predictedPageDirection)
|
||||
default:
|
||||
detachPageViewControllerIfNeeded()
|
||||
// RTL 水平模式翻转 collectionView
|
||||
@@ -491,6 +724,7 @@ public class RDReaderView: UIView {
|
||||
insertSubview(self.collectionView, at: 0)
|
||||
layout.displayType = displayType
|
||||
transitionToPage(pageNum: currentPage)
|
||||
primePageCache(around: currentPage, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,10 +745,10 @@ public class RDReaderView: UIView {
|
||||
}
|
||||
if isDualPage {
|
||||
let pair = dualPagePair(for: pageNum)
|
||||
let leftContent = dataSource?.pageContentView(readerView: self, pageNum: pair.left, containerView: nil)
|
||||
let leftContent = pageViewForDisplay(pageNum: pair.left)
|
||||
let leftVC = RDReaderPageChildViewController(contentView: leftContent, pageNum: pair.left)
|
||||
if let rightPage = pair.right {
|
||||
let rightContent = dataSource?.pageContentView(readerView: self, pageNum: rightPage, containerView: nil)
|
||||
let rightContent = pageViewForDisplay(pageNum: rightPage)
|
||||
let rightVC = RDReaderPageChildViewController(contentView: rightContent, pageNum: rightPage)
|
||||
pageViewController.setViewControllers([leftVC, rightVC], direction: animated ? direction : .forward, animated: animated, completion: nil)
|
||||
} else {
|
||||
@@ -524,16 +758,20 @@ public class RDReaderView: UIView {
|
||||
pageViewController.setViewControllers([leftVC, emptyVC], direction: animated ? direction : .forward, animated: animated, completion: nil)
|
||||
}
|
||||
currentPage = pair.left
|
||||
primePageCache(around: pair.left, preferredForward: predictedPageDirection)
|
||||
} else {
|
||||
let contentView = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: nil)
|
||||
let contentView = pageViewForDisplay(pageNum: pageNum)
|
||||
let vc = RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum)
|
||||
pageViewController.setViewControllers([vc], direction: animated ? direction : .forward, animated: animated, completion: nil)
|
||||
currentPage = pageNum
|
||||
primePageCache(around: pageNum, preferredForward: predictedPageDirection)
|
||||
}
|
||||
default:
|
||||
collectionView.reloadData()
|
||||
collectionView.layoutIfNeeded()
|
||||
collectionView.setContentOffset(layout.currentContentOffset(count: pageNum), animated: animated)
|
||||
currentPage = pageNum
|
||||
primePageCache(around: pageNum, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,7 +796,7 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
|
||||
if pageNum == RDReaderView.blankPageNum || pageNum == RDReaderView.blankEndPageNum {
|
||||
return RDReaderPageChildViewController(contentView: UIView(), pageNum: pageNum)
|
||||
}
|
||||
let contentView = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: nil)
|
||||
let contentView = pageViewForDisplay(pageNum: pageNum)
|
||||
return RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum)
|
||||
}
|
||||
|
||||
@@ -659,12 +897,25 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
|
||||
let pn = firstVC.pageNum
|
||||
if pn != RDReaderView.blankPageNum && pn != RDReaderView.blankEndPageNum {
|
||||
currentPage = pn
|
||||
primePageCache(around: pn, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
|
||||
predictedPageDirection = nil
|
||||
|
||||
if detectPageViewControllerFault(pageViewController) {
|
||||
patchPageViewControllerFault()
|
||||
}
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
||||
willTransitionToViewController = pendingViewControllers.first
|
||||
if let target = pendingViewControllers.first as? RDReaderPageChildViewController,
|
||||
target.pageNum != RDReaderView.blankPageNum,
|
||||
target.pageNum != RDReaderView.blankEndPageNum {
|
||||
predictedPageDirection = target.pageNum >= currentPage
|
||||
primePageCache(around: target.pageNum, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -676,7 +927,10 @@ extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate,
|
||||
if let identifer = self.dataSource?.pageIdentifier(readerView: self, pageNum: indexPath.row) {
|
||||
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifer, for: IndexPath(item: indexPath.row, section: 0)) as! RDReaderContentCell
|
||||
let conttainerView = self.dataSource?.pageContentView(readerView: self, pageNum: indexPath.row, containerView: cell.containerView)
|
||||
let preloadedView = preloadedPageViews.removeValue(forKey: indexPath.row)
|
||||
preloadedView?.removeFromSuperview()
|
||||
let reusableView = cell.containerView ?? preloadedView
|
||||
let conttainerView = self.dataSource?.pageContentView(readerView: self, pageNum: indexPath.row, containerView: reusableView)
|
||||
if let conttainerView = conttainerView {
|
||||
cell.containerView = conttainerView
|
||||
}
|
||||
@@ -700,7 +954,7 @@ extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate,
|
||||
|
||||
public func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) {
|
||||
currentPage = pageIndex
|
||||
|
||||
primePageCache(around: pageIndex, preferredForward: predictedPageDirection)
|
||||
}
|
||||
|
||||
public func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? {
|
||||
|
||||
@@ -26,6 +26,40 @@ public final class RDURLReaderController: UIViewController {
|
||||
embedReaderController()
|
||||
}
|
||||
|
||||
public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) {
|
||||
readerController?.configuration.displayType = displayType
|
||||
logDemoState(prefix: "display=\(displayType.demoArgumentValue)")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
let moved = readerController?.go(toPageNumber: pageNumber, animated: animated) ?? false
|
||||
if moved {
|
||||
logDemoState(prefix: "page=\(pageNumber)")
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
public func runDemoDisplaySequence(
|
||||
_ displayTypes: [RDReaderView.DisplayType],
|
||||
initialPageNumber: Int? = nil,
|
||||
stepDelay: TimeInterval = 1.0
|
||||
) {
|
||||
let normalizedDelay = max(stepDelay, 0.1)
|
||||
if let initialPageNumber {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + normalizedDelay) { [weak self] in
|
||||
_ = self?.goToDemoPage(initialPageNumber)
|
||||
}
|
||||
}
|
||||
|
||||
for (index, displayType) in displayTypes.enumerated() {
|
||||
let delay = normalizedDelay * Double(index + 1 + (initialPageNumber == nil ? 0 : 1))
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.applyDemoDisplayType(displayType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func embedReaderController() {
|
||||
let controller: UIViewController
|
||||
if bookURL.pathExtension.lowercased() == "epub" {
|
||||
@@ -70,6 +104,19 @@ public final class RDURLReaderController: UIViewController {
|
||||
controller.didMove(toParent: self)
|
||||
}
|
||||
|
||||
private var readerController: RDEPUBReaderController? {
|
||||
embeddedController as? RDEPUBReaderController
|
||||
}
|
||||
|
||||
private func logDemoState(prefix: String) {
|
||||
guard let readerController else { return }
|
||||
let location = readerController.currentLocation
|
||||
let href = location?.href ?? "nil"
|
||||
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
|
||||
let page = readerController.currentPageNumber.map(String.init) ?? "nil"
|
||||
print("[ReadViewDemo] automation \(prefix) -> page \(page) href \(href) progression \(progression)")
|
||||
}
|
||||
|
||||
private func currentTextPageSize() -> CGSize {
|
||||
let viewportSize = UIScreen.main.bounds.size
|
||||
let insets = epubConfiguration.reflowableContentInsets
|
||||
@@ -103,3 +150,16 @@ public final class RDURLReaderController: UIViewController {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
private extension RDReaderView.DisplayType {
|
||||
var demoArgumentValue: String {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
return "pageCurl"
|
||||
case .horizontalScroll:
|
||||
return "horizontalScroll"
|
||||
case .verticalScroll:
|
||||
return "verticalScroll"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,12 +177,20 @@
|
||||
mark.style.color = 'inherit';
|
||||
if (style === 'underline') {
|
||||
mark.style.background = 'transparent';
|
||||
mark.style.textDecorationLine = 'underline';
|
||||
mark.style.textDecorationStyle = 'solid';
|
||||
mark.style.textDecorationThickness = '2px';
|
||||
mark.style.textDecorationColor = item.color || '#F8E16C';
|
||||
} else {
|
||||
mark.style.background = item.color || '#F8E16C';
|
||||
}
|
||||
mark.dataset.highlightId = item.id || '';
|
||||
range.surroundContents(mark);
|
||||
var fragment = range.extractContents();
|
||||
mark.appendChild(fragment);
|
||||
range.insertNode(mark);
|
||||
if (mark.parentNode && mark.parentNode.normalize) {
|
||||
mark.parentNode.normalize();
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user