refactor: split reader architecture and chrome handling

This commit is contained in:
shen 2026-05-31 21:47:54 +08:00
parent ea21c6a831
commit 44202357c0
80 changed files with 10635 additions and 8522 deletions

File diff suppressed because it is too large Load Diff

View File

@ -185,14 +185,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";

View File

@ -0,0 +1,350 @@
import Foundation
public struct RDEPUBSelection: Codable, Equatable {
///
public var bookIdentifier: String?
///
public var location: RDEPUBLocation
///
public var text: String
/// DOM Range JSON
public var rangeInfo: String?
///
public var createdAt: Date
public init(
bookIdentifier: String? = nil,
location: RDEPUBLocation,
text: String,
rangeInfo: String? = nil,
createdAt: Date = Date()
) {
self.bookIdentifier = bookIdentifier
self.location = location
self.text = text
self.rangeInfo = rangeInfo?.nilIfEmpty
self.createdAt = createdAt
}
///
public var isEmpty: Bool {
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rangeInfo?.isEmpty != false
}
}
///
/// - highlight:
/// - underline: 线
public enum RDEPUBHighlightStyle: String, Codable {
case highlight
case underline
}
/// WXRead WRBookmark
public enum RDEPUBAnnotationKind: String, Codable, Equatable {
case bookmark
case highlight
case underline
}
///
public enum RDEPUBAnnotationMenuAction: Equatable {
case copy
case highlight
case annotate
}
/// EPUB
/// 使 DOM Range DTCoreText
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
/// "text-offset"
public var kind: String
///
public var href: String
///
public var start: Int
///
public var end: Int
///
/// - Parameters:
/// - href:
/// - start:
/// - end:
public init(href: String, start: Int, end: Int) {
self.kind = "text-offset"
self.href = href
self.start = start
self.end = end
}
/// NSRange NSAttributedString
public var nsRange: NSRange? {
guard end > start else { return nil }
return NSRange(location: start, length: end - start)
}
/// JSON
public func jsonString() -> String? {
guard let data = try? JSONEncoder().encode(self) else { return nil }
return String(data: data, encoding: .utf8)
}
/// JSON kind
public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? {
guard let string,
let data = string.data(using: .utf8),
let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data),
rangeInfo.kind == "text-offset",
rangeInfo.end > rangeInfo.start else {
return nil
}
return rangeInfo
}
}
///
public struct RDEPUBHighlight: Codable, Equatable {
///
public var id: String
///
public var bookIdentifier: String?
///
public var location: RDEPUBLocation
///
public var text: String
/// Web EPUB DOM Range JSON EPUB JSON
public var rangeInfo: String?
/// 线
public var style: RDEPUBHighlightStyle
/// CSS "#F8E16C"
public var color: String
///
public var note: String?
///
public var createdAt: Date
public init(
id: String = UUID().uuidString,
bookIdentifier: String? = nil,
location: RDEPUBLocation,
text: String,
rangeInfo: String? = nil,
style: RDEPUBHighlightStyle = .highlight,
color: String = "#F8E16C",
note: String? = nil,
createdAt: Date = Date()
) {
self.id = id
self.bookIdentifier = bookIdentifier
self.location = location
self.text = text
self.rangeInfo = rangeInfo?.nilIfEmpty
self.style = style
self.color = color
self.note = note?.nilIfEmpty
self.createdAt = createdAt
}
///
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
/// Codable /
private enum CodingKeys: String, CodingKey {
case id
case bookIdentifier
case location
case text
case rangeInfo
case style
case color
case note
case createdAt
}
/// 使
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier)
location = try container.decode(RDEPUBLocation.self, forKey: .location)
text = try container.decode(String.self, forKey: .text)
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
style = decodedStyle
} else {
style = .highlight
}
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
}
///
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier)
try container.encode(location, forKey: .location)
try container.encode(text, forKey: .text)
try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo)
try container.encode(style, forKey: .style)
try container.encode(color, forKey: .color)
try container.encodeIfPresent(note, forKey: .note)
try container.encode(createdAt, forKey: .createdAt)
}
public var annotation: RDEPUBAnnotation {
RDEPUBAnnotation(highlight: self)
}
}
///
public struct RDEPUBBookmark: Codable, Equatable {
///
public var id: String
///
public var bookIdentifier: String?
///
public var location: RDEPUBLocation
///
public var chapterTitle: String?
///
public var note: String?
///
public var createdAt: Date
public init(
id: String = UUID().uuidString,
bookIdentifier: String? = nil,
location: RDEPUBLocation,
chapterTitle: String? = nil,
note: String? = nil,
createdAt: Date = Date()
) {
self.id = id
self.bookIdentifier = bookIdentifier
self.location = location
self.chapterTitle = chapterTitle?.nilIfEmpty
self.note = note?.nilIfEmpty
self.createdAt = createdAt
}
///
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
public var annotation: RDEPUBAnnotation {
RDEPUBAnnotation(bookmark: self)
}
}
/// WXRead WRBookmark
public struct RDEPUBAnnotation: Codable, Equatable {
public var id: String
public var bookIdentifier: String?
public var kind: RDEPUBAnnotationKind
public var location: RDEPUBLocation
public var text: String?
public var rangeInfo: String?
public var color: String?
public var chapterTitle: String?
public var note: String?
public var createdAt: Date
public init(
id: String = UUID().uuidString,
bookIdentifier: String? = nil,
kind: RDEPUBAnnotationKind,
location: RDEPUBLocation,
text: String? = nil,
rangeInfo: String? = nil,
color: String? = nil,
chapterTitle: String? = nil,
note: String? = nil,
createdAt: Date = Date()
) {
self.id = id
self.bookIdentifier = bookIdentifier
self.kind = kind
self.location = location
self.text = text?.nilIfEmpty
self.rangeInfo = rangeInfo?.nilIfEmpty
self.color = color?.nilIfEmpty
self.chapterTitle = chapterTitle?.nilIfEmpty
self.note = note?.nilIfEmpty
self.createdAt = createdAt
}
public init(highlight: RDEPUBHighlight) {
self.init(
id: highlight.id,
bookIdentifier: highlight.bookIdentifier,
kind: highlight.style == .underline ? .underline : .highlight,
location: highlight.location,
text: highlight.text,
rangeInfo: highlight.rangeInfo,
color: highlight.color,
chapterTitle: nil,
note: highlight.note,
createdAt: highlight.createdAt
)
}
public init(bookmark: RDEPUBBookmark) {
self.init(
id: bookmark.id,
bookIdentifier: bookmark.bookIdentifier,
kind: .bookmark,
location: bookmark.location,
text: nil,
rangeInfo: nil,
color: nil,
chapterTitle: bookmark.chapterTitle,
note: bookmark.note,
createdAt: bookmark.createdAt
)
}
public var bookmark: RDEPUBBookmark? {
guard kind == .bookmark else { return nil }
return RDEPUBBookmark(
id: id,
bookIdentifier: bookIdentifier,
location: location,
chapterTitle: chapterTitle,
note: note,
createdAt: createdAt
)
}
public var highlight: RDEPUBHighlight? {
guard kind == .highlight || kind == .underline,
let text else {
return nil
}
return RDEPUBHighlight(
id: id,
bookIdentifier: bookIdentifier,
location: location,
text: text,
rangeInfo: rangeInfo,
style: kind == .underline ? .underline : .highlight,
color: color ?? "#F8E16C",
note: note,
createdAt: createdAt
)
}
}
private extension String {
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@ -0,0 +1,229 @@
import Foundation
public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
///
case chapterEnd
///
case frameLimit
///
case blockBoundary
///
case attachmentBoundary
///
case semanticBoundary
}
///
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
///
case image
///
case generic
}
///
///
public struct RDEPUBTextPageMetadata: Codable, Equatable {
///
public var breakReason: RDEPUBTextPageBreakReason
///
public var blockRange: NSRange?
///
public var attachmentRanges: [NSRange]
///
public var attachmentKinds: [RDEPUBTextAttachmentKind]
///
public var blockKinds: [RDEPUBTextBlockKind]
///
public var semanticHints: [RDEPUBTextSemanticHint]
///
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// fragment ID
public var trailingFragmentID: String?
///
public var diagnostics: [String]
public init(
breakReason: RDEPUBTextPageBreakReason,
blockRange: NSRange? = nil,
attachmentRanges: [NSRange] = [],
attachmentKinds: [RDEPUBTextAttachmentKind] = [],
blockKinds: [RDEPUBTextBlockKind] = [],
semanticHints: [RDEPUBTextSemanticHint] = [],
attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [],
trailingFragmentID: String? = nil,
diagnostics: [String] = []
) {
self.breakReason = breakReason
self.blockRange = blockRange
self.attachmentRanges = attachmentRanges
self.attachmentKinds = attachmentKinds
self.blockKinds = blockKinds
self.semanticHints = semanticHints
self.attachmentPlacements = attachmentPlacements
self.trailingFragmentID = trailingFragmentID
self.diagnostics = diagnostics
}
}
///
public struct EPUBChapterInfo: Codable, Equatable {
/// spine
public var spineIndex: Int
///
public var title: String
///
public var pageCount: Int
public init(spineIndex: Int, title: String, pageCount: Int) {
self.spineIndex = spineIndex
self.title = title
self.pageCount = pageCount
}
}
///
/// RDEPUBReadingSession
public struct EPUBPage: Codable, Equatable {
/// spine
public var spineIndex: Int
/// 0
public var chapterIndex: Int
/// 0
public var pageIndexInChapter: Int
///
public var totalPagesInChapter: Int
///
public var chapterTitle: String
/// Spread fixed layout
public var fixedSpread: EPUBFixedSpread?
public init(
spineIndex: Int,
chapterIndex: Int,
pageIndexInChapter: Int,
totalPagesInChapter: Int,
chapterTitle: String,
fixedSpread: EPUBFixedSpread?
) {
self.spineIndex = spineIndex
self.chapterIndex = chapterIndex
self.pageIndexInChapter = pageIndexInChapter
self.totalPagesInChapter = totalPagesInChapter
self.chapterTitle = chapterTitle
self.fixedSpread = fixedSpread
}
}
/// Spread
public struct EPUBFixedSpreadResource: Codable, Equatable {
/// spine
public var spineIndex: Int
///
public var href: String
///
public var title: String
/// spread
public var pageSpread: RDEPUBPageSpread?
public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) {
self.spineIndex = spineIndex
self.href = href
self.title = title
self.pageSpread = pageSpread
}
}
/// Spread
/// 1-2 spine
public struct EPUBFixedSpread: Codable, Equatable {
/// spread 1-2
public var resources: [EPUBFixedSpreadResource]
public init(resources: [EPUBFixedSpreadResource]) {
self.resources = resources
}
///
public var primaryResource: EPUBFixedSpreadResource {
resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "")
}
/// href spread
/// - Parameters:
/// - normalizedHref: href
/// - normalizer: href
/// - Returns:
public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool {
resources.contains { resource in
normalizer(resource.href) == normalizedHref
}
}
/// spine spread Spread
/// pageSpread left/right/center
/// - Parameters:
/// - spine: spine
/// - spreadEnabled: spread
/// - Returns: spread
public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] {
let resources = spine.enumerated().map { index, item in
EPUBFixedSpreadResource(
spineIndex: index,
href: item.href,
title: item.title,
pageSpread: item.pageSpread
)
}
guard spreadEnabled, resources.count > 1 else {
return resources.map { EPUBFixedSpread(resources: [$0]) }
}
var spreads: [EPUBFixedSpread] = []
var cursor = 0
while cursor < resources.count {
let current = resources[cursor]
guard cursor + 1 < resources.count else {
spreads.append(EPUBFixedSpread(resources: [current]))
break
}
let next = resources[cursor + 1]
if shouldPair(current: current, next: next) {
spreads.append(EPUBFixedSpread(resources: [current, next]))
cursor += 2
} else {
spreads.append(EPUBFixedSpread(resources: [current]))
cursor += 1
}
}
return spreads
}
/// 便 parser spread
public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] {
makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled)
}
/// spread
/// center left+right right+left
private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool {
if current.pageSpread == .center || next.pageSpread == .center {
return false
}
switch (current.pageSpread, next.pageSpread) {
case (.left, .right), (.right, .left):
return true
case (.left, .left), (.right, .right):
return false
default:
return true
}
}
}

View File

@ -0,0 +1,135 @@
import Foundation
public struct RDEPUBLocation: Codable, Equatable {
///
public var bookIdentifier: String?
/// OPF spine
public var href: String
/// [0, 1]
public var progression: Double
/// [0, 1]
public var lastProgression: Double?
/// #section1
public var fragment: String?
/// EPUB
public var rangeAnchor: RDEPUBTextRangeAnchor?
public init(
bookIdentifier: String? = nil,
href: String,
progression: Double,
lastProgression: Double? = 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
}
/// progression lastProgression
/// /
public var navigationProgression: Double {
let end = lastProgression ?? progression
if end.isNaN || end.isInfinite {
return progression
}
return Self.clamp((progression + end) / 2.0)
}
/// [0, 1]
private static func clamp(_ value: Double) -> Double {
guard value.isFinite else { return 0 }
return min(1, max(0, value))
}
}
/// spine
public struct RDEPUBViewportResource: Codable, Equatable {
/// OPF
public var href: String
/// spine
public var spineIndex: Int
/// [0, 1]
public var progression: Double
/// [0, 1]
public var lastProgression: Double?
///
public var fragment: String?
public init(
href: String,
spineIndex: Int,
progression: Double,
lastProgression: Double? = nil,
fragment: String? = nil
) {
self.href = href
self.spineIndex = spineIndex
self.progression = progression
self.lastProgression = lastProgression
self.fragment = fragment
}
}
///
/// spine
public struct RDEPUBViewport: Codable, Equatable {
///
public var resources: [RDEPUBViewportResource]
///
public var visiblePageNumber: Int
///
public var chapterIndex: Int?
///
public var isFixedLayout: Bool
public init(
resources: [RDEPUBViewportResource],
visiblePageNumber: Int,
chapterIndex: Int? = nil,
isFixedLayout: Bool
) {
self.resources = resources
self.visiblePageNumber = visiblePageNumber
self.chapterIndex = chapterIndex
self.isFixedLayout = isFixedLayout
}
}
///
/// RDEPUBReadingSession
public struct RDEPUBReadingContext: Codable, Equatable {
/// href + progression
public var location: RDEPUBLocation
///
public var viewport: RDEPUBViewport
///
public var pageNumber: Int
///
public var chapterIndex: Int?
public init(
location: RDEPUBLocation,
viewport: RDEPUBViewport,
pageNumber: Int,
chapterIndex: Int? = nil
) {
self.location = location
self.viewport = viewport
self.pageNumber = pageNumber
self.chapterIndex = chapterIndex
}
}
/// JS ssReaderSelectionChanged
private extension String {
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@ -1,719 +0,0 @@
//
// RDEPUBReadingModels.swift
// RDReaderView
//
// EPUBCore
// LocationViewportReadingContext
// SelectionHighlightBookmark
// ChapterInfoPage Spread
// RDEPUBReadingSession 穿
//
import Foundation
/// EPUB 使 href + progression
/// progression [0, 1]
public struct RDEPUBLocation: Codable, Equatable {
///
public var bookIdentifier: String?
/// OPF spine
public var href: String
/// [0, 1]
public var progression: Double
/// [0, 1]
public var lastProgression: Double?
/// #section1
public var fragment: String?
/// EPUB
public var rangeAnchor: RDEPUBTextRangeAnchor?
public init(
bookIdentifier: String? = nil,
href: String,
progression: Double,
lastProgression: Double? = 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
}
/// progression lastProgression
/// /
public var navigationProgression: Double {
let end = lastProgression ?? progression
if end.isNaN || end.isInfinite {
return progression
}
return Self.clamp((progression + end) / 2.0)
}
/// [0, 1]
private static func clamp(_ value: Double) -> Double {
guard value.isFinite else { return 0 }
return min(1, max(0, value))
}
}
/// spine
public struct RDEPUBViewportResource: Codable, Equatable {
/// OPF
public var href: String
/// spine
public var spineIndex: Int
/// [0, 1]
public var progression: Double
/// [0, 1]
public var lastProgression: Double?
///
public var fragment: String?
public init(
href: String,
spineIndex: Int,
progression: Double,
lastProgression: Double? = nil,
fragment: String? = nil
) {
self.href = href
self.spineIndex = spineIndex
self.progression = progression
self.lastProgression = lastProgression
self.fragment = fragment
}
}
///
/// spine
public struct RDEPUBViewport: Codable, Equatable {
///
public var resources: [RDEPUBViewportResource]
///
public var visiblePageNumber: Int
///
public var chapterIndex: Int?
///
public var isFixedLayout: Bool
public init(
resources: [RDEPUBViewportResource],
visiblePageNumber: Int,
chapterIndex: Int? = nil,
isFixedLayout: Bool
) {
self.resources = resources
self.visiblePageNumber = visiblePageNumber
self.chapterIndex = chapterIndex
self.isFixedLayout = isFixedLayout
}
}
///
/// RDEPUBReadingSession
public struct RDEPUBReadingContext: Codable, Equatable {
/// href + progression
public var location: RDEPUBLocation
///
public var viewport: RDEPUBViewport
///
public var pageNumber: Int
///
public var chapterIndex: Int?
public init(
location: RDEPUBLocation,
viewport: RDEPUBViewport,
pageNumber: Int,
chapterIndex: Int? = nil
) {
self.location = location
self.viewport = viewport
self.pageNumber = pageNumber
self.chapterIndex = chapterIndex
}
}
/// JS ssReaderSelectionChanged
public struct RDEPUBSelection: Codable, Equatable {
///
public var bookIdentifier: String?
///
public var location: RDEPUBLocation
///
public var text: String
/// DOM Range JSON
public var rangeInfo: String?
///
public var createdAt: Date
public init(
bookIdentifier: String? = nil,
location: RDEPUBLocation,
text: String,
rangeInfo: String? = nil,
createdAt: Date = Date()
) {
self.bookIdentifier = bookIdentifier
self.location = location
self.text = text
self.rangeInfo = rangeInfo?.nilIfEmpty
self.createdAt = createdAt
}
///
public var isEmpty: Bool {
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rangeInfo?.isEmpty != false
}
}
///
/// - highlight:
/// - underline: 线
public enum RDEPUBHighlightStyle: String, Codable {
case highlight
case underline
}
/// WXRead WRBookmark
public enum RDEPUBAnnotationKind: String, Codable, Equatable {
case bookmark
case highlight
case underline
}
///
public enum RDEPUBAnnotationMenuAction: Equatable {
case copy
case highlight
case annotate
}
/// EPUB
/// 使 DOM Range DTCoreText
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
/// "text-offset"
public var kind: String
///
public var href: String
///
public var start: Int
///
public var end: Int
///
/// - Parameters:
/// - href:
/// - start:
/// - end:
public init(href: String, start: Int, end: Int) {
self.kind = "text-offset"
self.href = href
self.start = start
self.end = end
}
/// NSRange NSAttributedString
public var nsRange: NSRange? {
guard end > start else { return nil }
return NSRange(location: start, length: end - start)
}
/// JSON
public func jsonString() -> String? {
guard let data = try? JSONEncoder().encode(self) else { return nil }
return String(data: data, encoding: .utf8)
}
/// JSON kind
public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? {
guard let string,
let data = string.data(using: .utf8),
let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data),
rangeInfo.kind == "text-offset",
rangeInfo.end > rangeInfo.start else {
return nil
}
return rangeInfo
}
}
///
public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
///
case chapterEnd
///
case frameLimit
///
case blockBoundary
///
case attachmentBoundary
///
case semanticBoundary
}
///
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
///
case image
///
case generic
}
///
///
public struct RDEPUBTextPageMetadata: Codable, Equatable {
///
public var breakReason: RDEPUBTextPageBreakReason
///
public var blockRange: NSRange?
///
public var attachmentRanges: [NSRange]
///
public var attachmentKinds: [RDEPUBTextAttachmentKind]
///
public var blockKinds: [RDEPUBTextBlockKind]
///
public var semanticHints: [RDEPUBTextSemanticHint]
///
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// fragment ID
public var trailingFragmentID: String?
///
public var diagnostics: [String]
public init(
breakReason: RDEPUBTextPageBreakReason,
blockRange: NSRange? = nil,
attachmentRanges: [NSRange] = [],
attachmentKinds: [RDEPUBTextAttachmentKind] = [],
blockKinds: [RDEPUBTextBlockKind] = [],
semanticHints: [RDEPUBTextSemanticHint] = [],
attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [],
trailingFragmentID: String? = nil,
diagnostics: [String] = []
) {
self.breakReason = breakReason
self.blockRange = blockRange
self.attachmentRanges = attachmentRanges
self.attachmentKinds = attachmentKinds
self.blockKinds = blockKinds
self.semanticHints = semanticHints
self.attachmentPlacements = attachmentPlacements
self.trailingFragmentID = trailingFragmentID
self.diagnostics = diagnostics
}
}
///
/// Web EPUBDOM Range EPUB
public struct RDEPUBHighlight: Codable, Equatable {
///
public var id: String
///
public var bookIdentifier: String?
///
public var location: RDEPUBLocation
///
public var text: String
/// Web EPUB DOM Range JSON EPUB JSON
public var rangeInfo: String?
/// 线
public var style: RDEPUBHighlightStyle
/// CSS "#F8E16C"
public var color: String
///
public var note: String?
///
public var createdAt: Date
public init(
id: String = UUID().uuidString,
bookIdentifier: String? = nil,
location: RDEPUBLocation,
text: String,
rangeInfo: String? = nil,
style: RDEPUBHighlightStyle = .highlight,
color: String = "#F8E16C",
note: String? = nil,
createdAt: Date = Date()
) {
self.id = id
self.bookIdentifier = bookIdentifier
self.location = location
self.text = text
self.rangeInfo = rangeInfo?.nilIfEmpty
self.style = style
self.color = color
self.note = note?.nilIfEmpty
self.createdAt = createdAt
}
///
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
/// Codable /
private enum CodingKeys: String, CodingKey {
case id
case bookIdentifier
case location
case text
case rangeInfo
case style
case color
case note
case createdAt
}
/// 使
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier)
location = try container.decode(RDEPUBLocation.self, forKey: .location)
text = try container.decode(String.self, forKey: .text)
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
style = decodedStyle
} else {
style = .highlight
}
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
}
///
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier)
try container.encode(location, forKey: .location)
try container.encode(text, forKey: .text)
try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo)
try container.encode(style, forKey: .style)
try container.encode(color, forKey: .color)
try container.encodeIfPresent(note, forKey: .note)
try container.encode(createdAt, forKey: .createdAt)
}
public var annotation: RDEPUBAnnotation {
RDEPUBAnnotation(highlight: self)
}
}
///
public struct RDEPUBBookmark: Codable, Equatable {
///
public var id: String
///
public var bookIdentifier: String?
///
public var location: RDEPUBLocation
///
public var chapterTitle: String?
///
public var note: String?
///
public var createdAt: Date
public init(
id: String = UUID().uuidString,
bookIdentifier: String? = nil,
location: RDEPUBLocation,
chapterTitle: String? = nil,
note: String? = nil,
createdAt: Date = Date()
) {
self.id = id
self.bookIdentifier = bookIdentifier
self.location = location
self.chapterTitle = chapterTitle?.nilIfEmpty
self.note = note?.nilIfEmpty
self.createdAt = createdAt
}
///
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
public var annotation: RDEPUBAnnotation {
RDEPUBAnnotation(bookmark: self)
}
}
/// WXRead WRBookmark
public struct RDEPUBAnnotation: Codable, Equatable {
public var id: String
public var bookIdentifier: String?
public var kind: RDEPUBAnnotationKind
public var location: RDEPUBLocation
public var text: String?
public var rangeInfo: String?
public var color: String?
public var chapterTitle: String?
public var note: String?
public var createdAt: Date
public init(
id: String = UUID().uuidString,
bookIdentifier: String? = nil,
kind: RDEPUBAnnotationKind,
location: RDEPUBLocation,
text: String? = nil,
rangeInfo: String? = nil,
color: String? = nil,
chapterTitle: String? = nil,
note: String? = nil,
createdAt: Date = Date()
) {
self.id = id
self.bookIdentifier = bookIdentifier
self.kind = kind
self.location = location
self.text = text?.nilIfEmpty
self.rangeInfo = rangeInfo?.nilIfEmpty
self.color = color?.nilIfEmpty
self.chapterTitle = chapterTitle?.nilIfEmpty
self.note = note?.nilIfEmpty
self.createdAt = createdAt
}
public init(highlight: RDEPUBHighlight) {
self.init(
id: highlight.id,
bookIdentifier: highlight.bookIdentifier,
kind: highlight.style == .underline ? .underline : .highlight,
location: highlight.location,
text: highlight.text,
rangeInfo: highlight.rangeInfo,
color: highlight.color,
chapterTitle: nil,
note: highlight.note,
createdAt: highlight.createdAt
)
}
public init(bookmark: RDEPUBBookmark) {
self.init(
id: bookmark.id,
bookIdentifier: bookmark.bookIdentifier,
kind: .bookmark,
location: bookmark.location,
text: nil,
rangeInfo: nil,
color: nil,
chapterTitle: bookmark.chapterTitle,
note: bookmark.note,
createdAt: bookmark.createdAt
)
}
public var bookmark: RDEPUBBookmark? {
guard kind == .bookmark else { return nil }
return RDEPUBBookmark(
id: id,
bookIdentifier: bookIdentifier,
location: location,
chapterTitle: chapterTitle,
note: note,
createdAt: createdAt
)
}
public var highlight: RDEPUBHighlight? {
guard kind == .highlight || kind == .underline,
let text else {
return nil
}
return RDEPUBHighlight(
id: id,
bookIdentifier: bookIdentifier,
location: location,
text: text,
rangeInfo: rangeInfo,
style: kind == .underline ? .underline : .highlight,
color: color ?? "#F8E16C",
note: note,
createdAt: createdAt
)
}
}
///
public struct EPUBChapterInfo: Codable, Equatable {
/// spine
public var spineIndex: Int
///
public var title: String
///
public var pageCount: Int
public init(spineIndex: Int, title: String, pageCount: Int) {
self.spineIndex = spineIndex
self.title = title
self.pageCount = pageCount
}
}
///
/// RDEPUBReadingSession
public struct EPUBPage: Codable, Equatable {
/// spine
public var spineIndex: Int
/// 0
public var chapterIndex: Int
/// 0
public var pageIndexInChapter: Int
///
public var totalPagesInChapter: Int
///
public var chapterTitle: String
/// Spread fixed layout
public var fixedSpread: EPUBFixedSpread?
public init(
spineIndex: Int,
chapterIndex: Int,
pageIndexInChapter: Int,
totalPagesInChapter: Int,
chapterTitle: String,
fixedSpread: EPUBFixedSpread?
) {
self.spineIndex = spineIndex
self.chapterIndex = chapterIndex
self.pageIndexInChapter = pageIndexInChapter
self.totalPagesInChapter = totalPagesInChapter
self.chapterTitle = chapterTitle
self.fixedSpread = fixedSpread
}
}
/// Spread
public struct EPUBFixedSpreadResource: Codable, Equatable {
/// spine
public var spineIndex: Int
///
public var href: String
///
public var title: String
/// spread
public var pageSpread: RDEPUBPageSpread?
public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) {
self.spineIndex = spineIndex
self.href = href
self.title = title
self.pageSpread = pageSpread
}
}
/// Spread
/// 1-2 spine
public struct EPUBFixedSpread: Codable, Equatable {
/// spread 1-2
public var resources: [EPUBFixedSpreadResource]
public init(resources: [EPUBFixedSpreadResource]) {
self.resources = resources
}
///
public var primaryResource: EPUBFixedSpreadResource {
resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "")
}
/// href spread
/// - Parameters:
/// - normalizedHref: href
/// - normalizer: href
/// - Returns:
public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool {
resources.contains { resource in
normalizer(resource.href) == normalizedHref
}
}
/// spine spread Spread
/// pageSpread left/right/center
/// - Parameters:
/// - spine: spine
/// - spreadEnabled: spread
/// - Returns: spread
public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] {
let resources = spine.enumerated().map { index, item in
EPUBFixedSpreadResource(
spineIndex: index,
href: item.href,
title: item.title,
pageSpread: item.pageSpread
)
}
guard spreadEnabled, resources.count > 1 else {
return resources.map { EPUBFixedSpread(resources: [$0]) }
}
var spreads: [EPUBFixedSpread] = []
var cursor = 0
while cursor < resources.count {
let current = resources[cursor]
guard cursor + 1 < resources.count else {
spreads.append(EPUBFixedSpread(resources: [current]))
break
}
let next = resources[cursor + 1]
if shouldPair(current: current, next: next) {
spreads.append(EPUBFixedSpread(resources: [current, next]))
cursor += 2
} else {
spreads.append(EPUBFixedSpread(resources: [current]))
cursor += 1
}
}
return spreads
}
/// 便 parser spread
public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] {
makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled)
}
/// spread
/// center left+right right+left
private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool {
if current.pageSpread == .center || next.pageSpread == .center {
return false
}
switch (current.pageSpread, next.pageSpread) {
case (.left, .right), (.right, .left):
return true
case (.left, .left), (.right, .right):
return false
default:
return true
}
}
}
/// String nil Codable
private extension String {
/// nil
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@ -0,0 +1,57 @@
import Foundation
/// Builds diagnostics and human-readable summaries for a text book build.
struct RDEPUBBuildDiagnosticsReporter {
func phase7SemanticSummary(
title: String?,
diagnostics: [RDEPUBTextChapterPaginationDiagnostic]
) -> String? {
guard !diagnostics.isEmpty else { return nil }
let blockKinds = uniqueValues(from: diagnostics.flatMap(\.blockKinds))
let semanticHints = uniqueValues(from: diagnostics.flatMap(\.semanticHints))
let attachmentPlacements = uniqueValues(from: diagnostics.flatMap(\.attachmentPlacements))
let note = diagnostics
.flatMap(\.sampleNotes)
.first(where: { $0.contains("semantic") || $0.contains("attachment") || $0.contains("block kinds") })
var parts = [
title,
"章节 \(diagnostics.count)",
blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]",
semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
].compactMap { $0 }
if let note {
parts.append(note)
}
return parts.joined(separator: " · ")
}
func chapterDiagnostic(
href: String,
title: String,
pages: [RDEPUBTextPage]
) -> RDEPUBTextChapterPaginationDiagnostic {
RDEPUBTextChapterPaginationDiagnostic(
href: href,
title: title,
pageCount: pages.count,
breakReasons: pages.map(\.metadata.breakReason),
attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count,
blockAdjustedPageCount: pages.filter { $0.metadata.breakReason == .blockBoundary || $0.metadata.breakReason == .attachmentBoundary }.count,
blockKinds: uniqueValues(from: pages.flatMap(\.metadata.blockKinds)),
semanticHints: uniqueValues(from: pages.flatMap(\.metadata.semanticHints)),
attachmentPlacements: uniqueValues(from: pages.flatMap(\.metadata.attachmentPlacements)),
sampleNotes: Array(pages.flatMap(\.metadata.diagnostics).prefix(4))
)
}
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
values.reduce(into: [T]()) { result, value in
if !result.contains(value) {
result.append(value)
}
}
}
}

View File

@ -0,0 +1,153 @@
import Foundation
/// Normalizes suspicious trailing or whitespace-only page frames after pagination.
struct RDEPUBChapterTailNormalizer {
func normalize(
_ frames: [RDEPUBTextLayoutFrame],
content: NSAttributedString,
href: String
) -> [RDEPUBTextLayoutFrame] {
guard frames.count > 1 else { return frames }
var normalized = frames
var compacted: [RDEPUBTextLayoutFrame] = []
compacted.reserveCapacity(normalized.count)
for frame in normalized {
if shouldDropWhitespaceOnlyFrame(frame, in: content) {
let note = "normalized: dropped whitespace-only intermediate page \(NSStringFromRange(frame.contentRange))"
if var previous = compacted.popLast() {
previous.diagnostics.append(note)
compacted.append(previous)
} else {
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
}
continue
}
compacted.append(frame)
}
normalized = compacted
while let lastFrame = normalized.last,
shouldDropWhitespaceOnlyFrame(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 shouldDropWhitespaceOnlyFrame(
_ 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 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 uniqueValues<T: Equatable>(from values: [T]) -> [T] {
values.reduce(into: [T]()) { result, value in
if !result.contains(value) {
result.append(value)
}
}
}
private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
ranges.reduce(into: [NSRange]()) { result, value in
if !result.contains(value) {
result.append(value)
}
}
}
}

View File

@ -0,0 +1,45 @@
import UIKit
/// Keeps pagination cache key generation and cache IO in one BuildPipeline role.
struct RDEPUBPaginationCacheCoordinator {
private let cache: RDEPUBTextBookCache?
private let layoutConfig: RDEPUBTextLayoutConfig
init(cache: RDEPUBTextBookCache?, layoutConfig: RDEPUBTextLayoutConfig) {
self.cache = cache
self.layoutConfig = layoutConfig
}
func cacheKey(
bookID: String,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) -> String? {
guard let cache else { return nil }
return cache.cacheKey(
bookID: bookID,
fontSize: style.font.pointSize,
lineHeightMultiple: style.lineSpacing,
contentInsets: layoutConfig.edgeInsets,
pageSize: pageSize,
layoutConfigSignature: layoutConfig.cacheSignature
)
}
func load(key: String?) -> [String: RDEPUBTextChapterPaginationCache]? {
key.flatMap { cache?.load(key: $0) }
}
func save(chapters: [RDEPUBTextChapter], key: String?) {
guard let key else { return }
let paginationCache = chapters.map { chapter in
RDEPUBTextChapterPaginationCache(
href: chapter.href,
pageRanges: chapter.pages.map(\.contentRange),
breakReasons: chapter.pages.map(\.metadata.breakReason),
semanticHints: Array(Set(chapter.pages.flatMap(\.metadata.semanticHints)))
)
}
cache?.save(paginationCache, key: key)
}
}

View File

@ -0,0 +1,395 @@
import UIKit
public final class RDEPUBTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let cache: RDEPUBTextBookCache?
private let layoutConfig: RDEPUBTextLayoutConfig
private let sampler: RDEPUBTextPerformanceSampler
private let renderPipeline: RDEPUBChapterRenderPipeline
private let paginationPipeline: RDEPUBChapterPaginationPipeline
private let tailNormalizer: RDEPUBChapterTailNormalizer
private let cacheCoordinator: RDEPUBPaginationCacheCoordinator
private let diagnosticsReporter: RDEPUBBuildDiagnosticsReporter
///
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
///
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
///
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
/// /
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
public init(
renderer: RDEPUBTextRenderer,
cache: RDEPUBTextBookCache? = nil,
layoutConfig: RDEPUBTextLayoutConfig = .default
) {
self.renderer = renderer
self.cache = cache
self.layoutConfig = layoutConfig
self.sampler = RDEPUBTextPerformanceSampler()
self.renderPipeline = RDEPUBChapterRenderPipeline(renderer: renderer)
self.paginationPipeline = RDEPUBChapterPaginationPipeline()
self.tailNormalizer = RDEPUBChapterTailNormalizer()
self.cacheCoordinator = RDEPUBPaginationCacheCoordinator(cache: cache, layoutConfig: layoutConfig)
self.diagnosticsReporter = RDEPUBBuildDiagnosticsReporter()
}
/// 使 DTCoreText
public convenience init() {
self.init(renderer: RDEPUBDTCoreTextRenderer())
}
private var isPaginationDebugEnabled: Bool {
ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
}
/// Phase 7
public func phase7SemanticSummary(title: String? = nil) -> String? {
diagnosticsReporter.phase7SemanticSummary(
title: title,
diagnostics: lastBuildPaginationDiagnostics
)
}
/// EPUB publication
///
///
/// 1.
/// 2. spine 线 HTML
/// 3. HTML NSAttributedString
/// 4. /
/// 5. 使 CoreText
/// 6.
/// 7. RDEPUBTextBook
public func build(
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) throws -> RDEPUBTextBook {
if isPaginationDebugEnabled {
print("[PaginationDebug] build pageSize=\(NSCoder.string(for: pageSize)) layoutInsets=\(NSCoder.string(for: layoutConfig.edgeInsets))")
}
var chapters: [RDEPUBTextChapter] = []
var flatPages: [RDEPUBTextPage] = []
lastBuildResourceDiagnostics = []
lastBuildPaginationDiagnostics = []
lastBuildPerformanceSamples = []
lastBuildCacheStats = (0, 0)
sampler.reset()
let buildStart = CFAbsoluteTimeGetCurrent()
// WXRead
let bookID = publication.metadata.identifier ?? publication.metadata.title
let cacheKey = cacheCoordinator.cacheKey(bookID: bookID, pageSize: pageSize, style: style)
let cachedPagination = cacheCoordinator.load(key: cacheKey)
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
continue
}
//
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
let request = RDEPUBTextTypesetterPipeline().makeRequest(
from: RDEPUBTypesettingInput(
href: item.href,
title: chapterTitle,
rawHTML: rawHTML,
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
style: style,
resourceResolver: publication.resourceResolver,
contentLanguageCode: publication.metadata.language,
pageSize: pageSize,
layoutConfig: layoutConfig
)
).request
// HTML NSAttributedString
let renderStart = CFAbsoluteTimeGetCurrent()
let rendered = try renderPipeline.render(request)
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
}
// /
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] skipped href=\(item.href)")
}
continue
}
let chapterIndex = chapters.count
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
//
let paginateStart = CFAbsoluteTimeGetCurrent()
let layoutFrames: [RDEPUBTextLayoutFrame]
let isCacheHit: Bool
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
//
layoutFrames = [
RDEPUBTextLayoutFrame(
contentRange: NSRange(location: 0, length: content.length),
breakReason: .chapterEnd,
blockRange: nil,
attachmentRanges: attachmentRanges(in: content),
attachmentKinds: [],
blockKinds: [],
semanticHints: [],
attachmentPlacements: [],
trailingFragmentID: nil,
diagnostics: [
"page break: chapterEnd",
"cover fallback: single attachment page",
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
]
)
]
isCacheHit = false
} else if let cached = cachedPagination?[item.href] {
// 使 CoreText
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
return RDEPUBTextLayoutFrame(
contentRange: range,
breakReason: breakReason,
blockRange: nil,
attachmentRanges: [],
attachmentKinds: [],
blockKinds: [],
semanticHints: cached.semanticHints,
attachmentPlacements: [],
trailingFragmentID: nil,
diagnostics: ["page break: \(breakReason.rawValue)", "page range: \(NSStringFromRange(range))", "source: cache hit"]
)
}
isCacheHit = true
} else {
// CoreText
layoutFrames = content.length > 0
? paginationPipeline.frames(
for: content,
pageSize: pageSize,
config: layoutConfig,
fragmentOffsets: rendered.fragmentOffsets
)
: []
isCacheHit = false
}
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
//
let normalizedFrames = tailNormalizer.normalize(
layoutFrames,
content: content,
href: item.href
)
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
? [
RDEPUBTextLayoutFrame(
contentRange: NSRange(location: 0, length: content.length),
breakReason: .chapterEnd,
blockRange: nil,
attachmentRanges: [],
attachmentKinds: [],
blockKinds: [],
semanticHints: [],
attachmentPlacements: [],
trailingFragmentID: nil,
diagnostics: [
"page break: chapterEnd",
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
]
)
]
: 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")")
}
//
sampler.record(RDEPUBTextPerformanceSample(
chapterHref: item.href,
renderDuration: renderDuration,
paginateDuration: paginateDuration,
pageCount: effectiveFrames.count,
attributedStringLength: content.length,
cacheHit: isCacheHit
))
if isCacheHit {
lastBuildCacheStats.hits += 1
} else {
lastBuildCacheStats.misses += 1
}
//
let chapterAttributedContent = content.copy() as! NSAttributedString
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
let range = frame.contentRange
return RDEPUBTextPage(
absolutePageIndex: flatPages.count + localPageIndex,
chapterIndex: chapterIndex,
spineIndex: spineIndex,
href: item.href,
chapterTitle: chapterTitle,
pageIndexInChapter: localPageIndex,
totalPagesInChapter: effectiveFrames.count,
chapterContent: chapterAttributedContent,
content: content.attributedSubstring(from: range),
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + max(range.length - 1, 0),
metadata: frame.metadata
)
}
if isPaginationDebugEnabled,
item.href.contains("Chapter_3.xhtml") {
print("[PaginationDebug] href=\(item.href) pages=\(pages.count)")
for page in pages {
let preview = debugPreview(for: page.content, limit: 36)
print("[PaginationDebug] absPage=\(page.absolutePageIndex + 1) localPage=\(page.pageIndexInChapter + 1) range=\(NSStringFromRange(page.contentRange)) break=\(page.metadata.breakReason.rawValue) preview=\(preview)")
for note in page.metadata.diagnostics.prefix(4) {
print("[PaginationDebug] note=\(note)")
}
}
}
chapters.append(
RDEPUBTextChapter(
chapterIndex: chapterIndex,
spineIndex: spineIndex,
href: item.href,
title: chapterTitle,
attributedContent: chapterAttributedContent,
fragmentOffsets: rendered.fragmentOffsets,
pageBreakReasons: pages.map(\.metadata.breakReason),
pages: pages
)
)
lastBuildPaginationDiagnostics.append(
diagnosticsReporter.chapterDiagnostic(
href: item.href,
title: chapterTitle,
pages: pages
)
)
flatPages.append(contentsOf: pages)
}
let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
//
cacheCoordinator.save(chapters: chapters, key: cacheKey)
print(sampler.summary())
lastBuildPerformanceSamples = sampler.samples
return book
}
// MARK: -
/// 退 spine item title href
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
tocItem.href.components(separatedBy: "#").first == item.href
})?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
return title
}
let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedTitle.isEmpty ? item.href : trimmedTitle
}
///
private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
items.flatMap { item in
[item] + flattenedTOCItems(from: item.children)
}
}
// MARK: -
/// /
private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
let lowercasedHref = item.href.lowercased()
var hasAttachment = false
if content.length > 0 {
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
guard value != nil else { return }
hasAttachment = true
stop.pointee = true
}
}
if text.isEmpty && !hasAttachment && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
return true
}
return false
}
// MARK: -
///
private func attachmentCount(in content: NSAttributedString) -> Int {
guard content.length > 0 else { return 0 }
var count = 0
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, _ in
if value != nil {
count += 1
}
}
return count
}
/// NSRange
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
guard content.length > 0 else { return [] }
var ranges: [NSRange] = []
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in
if value != nil {
ranges.append(range)
}
}
return ranges
}
// MARK: -
/// href cover
private func isAttachmentOnlyCoverChapter(
item: RDEPUBSpineItem,
content: NSAttributedString,
plainText: String
) -> Bool {
let lowercasedHref = item.href.lowercased()
guard lowercasedHref.contains("cover") else { return false }
let trimmed = plainText.trimmingCharacters(in: .whitespacesAndNewlines)
return attachmentCount(in: content) > 0 && trimmed.count <= 1
}
private func debugPreview(for content: NSAttributedString, limit: Int) -> String {
let collapsed = content.string
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\t", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !collapsed.isEmpty else { return "<empty>" }
if collapsed.count <= limit {
return collapsed
}
let head = collapsed.prefix(limit)
return "\(head)"
}
}

View File

@ -0,0 +1,193 @@
import UIKit
// MARK: -
///
/// /
public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
public var href: String
public var title: String
public var pageCount: Int
///
public var breakReasons: [RDEPUBTextPageBreakReason]
///
public var attachmentPageCount: Int
/// /
public var blockAdjustedPageCount: Int
public var blockKinds: [RDEPUBTextBlockKind]
public var semanticHints: [RDEPUBTextSemanticHint]
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// 4
public var sampleNotes: [String]
}
// MARK: -
///
///
/// `RDEPUBTextPage` `RDEPUBTextLayoutFrame`
/// `contentRange`
public struct RDEPUBTextPage: Equatable {
/// 0
public var absolutePageIndex: Int
public var chapterIndex: Int
public var spineIndex: Int
public var href: String
public var chapterTitle: String
/// 0
public var pageIndexInChapter: Int
public var totalPagesInChapter: Int
///
public var chapterContent: NSAttributedString
///
public var content: NSAttributedString
///
public var contentRange: NSRange
public var pageStartOffset: Int
public var pageEndOffset: Int
///
public var metadata: RDEPUBTextPageMetadata
}
// MARK: -
///
public struct RDEPUBTextChapter: Equatable {
public var chapterIndex: Int
public var spineIndex: Int
public var href: String
public var title: String
///
public var attributedContent: NSAttributedString
/// fragment ID
public var fragmentOffsets: [String: Int]
public var pageBreakReasons: [RDEPUBTextPageBreakReason]
public var pages: [RDEPUBTextPage]
}
// MARK: -
/// EPUB
///
/// EPUBTextRendering `RDEPUBTextBookBuilder.build()`
/// `chapterData(for:)` `chapterData(atChapterIndex:)` `RDEPUBChapterData`
public struct RDEPUBTextBook {
public var chapters: [RDEPUBTextChapter]
public var pages: [RDEPUBTextPage]
/// fileIndex/row/column
public let indexTable: RDEPUBTextIndexTable
/// WXRead <-> <->
public var positionConverter: RDEPUBTextPositionConverter {
RDEPUBTextPositionConverter(book: self)
}
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
}
/// href 访
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)
}
/// spine 访
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData? {
guard let chapter = chapters.first(where: { $0.spineIndex == spineIndex }) 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)
}
/// 1 访
public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData? {
guard let page = page(at: pageNumber) else { return nil }
return chapterData(forSpineIndex: page.spineIndex)
}
/// 访
public func chapterData(
for location: RDEPUBLocation,
resolver: RDEPUBResourceResolver,
bookIdentifier: String?
) -> RDEPUBChapterData? {
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier) else {
return nil
}
return chapterData(for: normalizedLocation.href)
}
/// WXRead
public var chapterInfos: [EPUBChapterInfo] {
chapters.map { chapter in
EPUBChapterInfo(
spineIndex: chapter.spineIndex,
title: chapter.title,
pageCount: chapter.pages.count
)
}
}
/// 1
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
return nil
}
return pages[pageNumber - 1]
}
/// 1
///
///
/// 1. rangeAnchor
/// 2. fragment ID
/// 3. navigationProgression 退
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
let chapterData = chapterData(for: normalizedLocation.href) else {
return nil
}
if let anchor = normalizedLocation.rangeAnchor?.start,
let page = positionConverter.pageNumber(for: anchor) {
return page
}
if let anchor = indexTable.anchor(for: normalizedLocation),
let page = positionConverter.pageNumber(for: anchor) {
return page
}
return chapterData.pageNumber(for: normalizedLocation)
}
/// RDEPUBLocation
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard let chapterData = chapterData(forPageNumber: pageNumber),
let page = page(at: pageNumber) else {
return nil
}
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
}
}
// MARK: -
/// EPUB publication
///
/// spine HTML / RDEPUBTextBook
///
///
/// - WXRead
/// - /
/// -

View File

@ -0,0 +1,46 @@
import UIKit
protocol RDEPUBTextBookBuilding {
func build(
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) throws -> RDEPUBTextBook
}
struct RDEPUBChapterRenderPipeline {
private let renderer: RDEPUBTextRenderer
init(renderer: RDEPUBTextRenderer) {
self.renderer = renderer
}
func render(_ request: RDEPUBTextChapterRenderRequest) throws -> RDEPUBRenderedChapterContent {
try renderer.renderChapter(request: request)
}
}
struct RDEPUBChapterPaginationPipeline {
private let frameFactory: RDEPUBPageFrameBuilding
init(frameFactory: RDEPUBPageFrameBuilding = RDEPUBCoreTextPageFrameFactory()) {
self.frameFactory = frameFactory
}
func frames(
for content: NSAttributedString,
pageSize: CGSize,
config: RDEPUBTextLayoutConfig,
fragmentOffsets: [String: Int]
) -> [RDEPUBTextLayoutFrame] {
frameFactory.makeFrames(
attributedString: content,
pageSize: pageSize,
config: config,
fragmentOffsets: fragmentOffsets
)
}
}
extension RDEPUBTextBookBuilder: RDEPUBTextBookBuilding {}

View File

@ -0,0 +1,270 @@
import CoreText
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
///
///
///
/// 1. avoidPageBreakInside WXRead 退
/// 2. keepWithNext
/// 3. pageBreakBefore/After
/// 4. pageRelate
/// 5.
/// 6. CoreText
struct RDEPUBChapterPageCounter {
private let factory: RDEPUBCoreTextPageFrameFactory
private let attributedString: NSAttributedString
private let pageSize: CGSize
private let config: RDEPUBTextLayoutConfig
private let pageBreakPolicy: RDEPUBPageBreakPolicy
/// CoreText
private let framesetter: CTFramesetter
/// DTCoreText
private let dtLayoutRect: CGRect
init(factory: RDEPUBCoreTextPageFrameFactory) {
self.factory = factory
self.attributedString = factory.attributedString
self.pageSize = factory.pageSize
self.config = factory.config
self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: factory.attributedString)
self.framesetter = CTFramesetterCreateWithAttributedString(factory.attributedString)
self.dtLayoutRect = factory.config.contentRect(fallback: factory.pageSize)
}
///
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
}
#if canImport(DTCoreText)
return layoutFramesUsingDTCoreText(fragmentOffsets: fragmentOffsets)
#else
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
#endif
}
// MARK: - CoreText 退
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
}
var frames: [RDEPUBTextLayoutFrame] = []
var location = 0
let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
let usableWidth = resolvedSize.width - config.edgeInsets.left - config.edgeInsets.right
let usableHeight = resolvedSize.height - config.edgeInsets.top - config.edgeInsets.bottom
guard usableWidth > 0, usableHeight > 0 else {
return []
}
while location < attributedString.length {
let framePath = CGMutablePath()
let pageRect = CGRect(
x: config.edgeInsets.left,
y: config.edgeInsets.bottom,
width: usableWidth,
height: usableHeight
)
framePath.addRect(pageRect)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), framePath, nil)
let proposedRange = proposedRangeUsingWXReadPageCount(
from: frame,
start: location,
usableHeight: usableHeight,
totalLength: attributedString.length
)
guard proposedRange.length > 0 else {
break
}
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: frame)
let adjusted = pageBreakPolicy.adjustedRange(
from: avoidAdjusted,
totalLength: attributedString.length,
lineRanges: lineRanges,
factory: factory
)
let trailingFragmentID = factory.nearestTrailingFragmentID(
endingAt: adjusted.range.location + adjusted.range.length,
fragmentOffsets: fragmentOffsets
)
frames.append(
RDEPUBTextLayoutFrame(
contentRange: adjusted.range,
breakReason: adjusted.breakReason,
blockRange: adjusted.blockRange,
attachmentRanges: adjusted.attachmentRanges,
attachmentKinds: adjusted.attachmentKinds,
blockKinds: adjusted.blockKinds,
semanticHints: adjusted.semanticHints,
attachmentPlacements: adjusted.attachmentPlacements,
trailingFragmentID: trailingFragmentID,
diagnostics: adjusted.diagnostics
)
)
let nextLocation = adjusted.range.location + adjusted.range.length
guard nextLocation > location else {
location += max(proposedRange.length, 1)
continue
}
location = nextLocation
}
return frames
}
// MARK: - DTCoreText
#if canImport(DTCoreText)
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard config.numberOfColumns == 1 else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
}
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
}
layouter.shouldCacheLayoutFrames = false
var frames: [RDEPUBTextLayoutFrame] = []
var location = 0
let pageRect = dtLayoutRect
while location < attributedString.length {
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
break
}
let visibleRange = layoutFrame.visibleStringRange()
guard visibleRange.length > 0 else {
break
}
let proposedRange = NSRange(location: location, length: visibleRange.length)
let avoidAdjusted = factory.trimmedRangeForAvoidPageBreakInside(from: layoutFrame, proposed: proposedRange)
let lineAdjusted = factory.trimmedRangeForKeepWithNext(from: layoutFrame, proposed: avoidAdjusted)
let lineRanges = RDEPUBCoreTextPageFrameFactory.lineRanges(from: layoutFrame)
let adjusted = pageBreakPolicy.adjustedRange(
from: lineAdjusted,
totalLength: attributedString.length,
lineRanges: lineRanges,
factory: factory
)
let verifiedRange: NSRange
if adjusted.breakReason == .attachmentBoundary {
verifiedRange = verifiedDisplayRange(for: adjusted.range)
} else {
verifiedRange = adjusted.range
}
let trailingFragmentID = factory.nearestTrailingFragmentID(
endingAt: verifiedRange.location + verifiedRange.length,
fragmentOffsets: fragmentOffsets
)
let diagnostics = verifiedRange == adjusted.range
? adjusted.diagnostics
: adjusted.diagnostics + ["verified-display-range \(NSStringFromRange(adjusted.range)) -> \(NSStringFromRange(verifiedRange))"]
frames.append(
RDEPUBTextLayoutFrame(
contentRange: verifiedRange,
breakReason: adjusted.breakReason,
blockRange: factory.blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)),
attachmentRanges: factory.attachmentRanges(in: verifiedRange),
attachmentKinds: factory.attachmentKinds(in: verifiedRange),
blockKinds: factory.blockKinds(in: verifiedRange),
semanticHints: factory.semanticHints(in: verifiedRange),
attachmentPlacements: factory.attachmentPlacements(in: verifiedRange),
trailingFragmentID: trailingFragmentID,
diagnostics: diagnostics
)
)
let nextLocation = verifiedRange.location + verifiedRange.length
guard nextLocation > location else {
location += max(visibleRange.length, 1)
continue
}
location = nextLocation
}
return frames
}
private func verifiedDisplayRange(for range: NSRange) -> NSRange {
guard let clampedRange = factory.clampedRange(range),
clampedRange.length > 0,
!factory.attachmentRanges(in: clampedRange).isEmpty else {
return range
}
let pageContent = attributedString.attributedSubstring(from: clampedRange)
guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else {
return clampedRange
}
layouter.shouldCacheLayoutFrames = false
guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else {
return clampedRange
}
let visibleRange = layoutFrame.visibleStringRange()
guard visibleRange.length > 0, visibleRange.length < clampedRange.length else {
return clampedRange
}
return NSRange(location: clampedRange.location, length: visibleRange.length)
}
#endif
// MARK: - WXRead
/// WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString`
private func proposedRangeUsingWXReadPageCount(
from frame: CTFrame,
start location: Int,
usableHeight: CGFloat,
totalLength: Int
) -> NSRange {
let lines = CTFrameGetLines(frame) as! [CTLine]
guard !lines.isEmpty else {
return NSRange(location: location, length: 0)
}
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
var pageCharCount = 0
for (index, line) in lines.enumerated() {
let lineRange = CTLineGetStringRange(line)
let lineY = origins[index].y
var ascent: CGFloat = 0
var descent: CGFloat = 0
CTLineGetTypographicBounds(line, &ascent, &descent, nil)
if lineY - ascent > usableHeight {
break
}
pageCharCount += lineRange.length
}
if pageCharCount == 0 {
pageCharCount = 1
}
return NSRange(location: location, length: min(pageCharCount, totalLength - location))
}
}

View File

@ -0,0 +1,389 @@
import CoreText
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
/// CoreText
///
/// RDEPUBChapterPageCounter RDEPUBPageBreakPolicy
struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
let attributedString: NSAttributedString
let pageSize: CGSize
let config: RDEPUBTextLayoutConfig
private let pageBreakPolicy: RDEPUBPageBreakPolicy
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
self.attributedString = attributedString
self.pageSize = pageSize
self.config = config
self.pageBreakPolicy = RDEPUBPageBreakPolicy(attributedString: attributedString)
}
/// 便使
init() {
self.init(attributedString: NSAttributedString(), pageSize: .zero, config: .default)
}
// MARK: - RDEPUBPageFrameBuilding
func makeFrames(
attributedString: NSAttributedString,
pageSize: CGSize,
config: RDEPUBTextLayoutConfig,
fragmentOffsets: [String: Int]
) -> [RDEPUBTextLayoutFrame] {
let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config)
let counter = RDEPUBChapterPageCounter(factory: factory)
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
}
// MARK: -
/// CGPath CTFramesetterCreateFrame
static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
let columnRects = config.columnRects(fallback: pageSize)
guard columnRects.count > 1 else {
return CGPath(rect: columnRects.first ?? CGRect(origin: .zero, size: pageSize), transform: nil)
}
let path = CGMutablePath()
for rect in columnRects {
path.addRect(rect)
}
return path
}
// MARK: -
/// CTFrame avoidPageBreakInside
func trimmedRangeForAvoidPageBreakInside(
from frame: CTFrame,
proposed: NSRange
) -> NSRange {
guard config.avoidPageBreakInsideEnabled else { return proposed }
let lines = CTFrameGetLines(frame) as! [CTLine]
guard !lines.isEmpty else { return proposed }
let lineRanges = lines.map {
let range = CTLineGetStringRange($0)
return NSRange(location: range.location, length: range.length)
}
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
}
/// CoreText keepWithNext
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)
/// DTCoreText avoidPageBreakInside
func trimmedRangeForAvoidPageBreakInside(
from layoutFrame: DTCoreTextLayoutFrame,
proposed: NSRange
) -> NSRange {
guard config.avoidPageBreakInsideEnabled else { return proposed }
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
return proposed
}
let lineRanges = lines.map { $0.stringRange() }
return trimmedRangeForAvoidPageBreakInside(proposed: proposed, lineRanges: lineRanges)
}
/// DTCoreText keepWithNext
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
/// avoidPageBreakInside
func trimmedRangeForAvoidPageBreakInside(
proposed: NSRange,
lineRanges: [NSRange]
) -> NSRange {
guard config.avoidPageBreakInsideEnabled, !lineRanges.isEmpty else { return proposed }
let kMaxLinesToRemove = 3
var linesToRemove = 0
for lineRange in lineRanges.reversed() {
if pageBreakPolicy.lineIsInAvoidPageBreakInsideBlock(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)
}
/// keepWithNext
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 pageBreakPolicy.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)
}
// MARK: -
/// CTFrame
static 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)
/// DTCoreTextLayoutFrame
static func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
return []
}
return lines.map { $0.stringRange() }
}
#endif
// MARK: -
///
func blockRange(at location: Int) -> NSRange? {
guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
if let encodedRange = attributes[.rdPageBlockRange] as? String {
return NSRangeFromString(encodedRange)
}
return nil
}
///
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)
}
///
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)
}
///
func paragraphRange(containing location: Int) -> NSRange {
let source = attributedString.string as NSString
guard source.length > 0 else { return NSRange(location: 0, length: 0) }
let safeLocation = min(max(location, 0), max(source.length - 1, 0))
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
}
///
func attachmentRanges(in range: NSRange) -> [NSRange] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var results: [NSRange] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, _ in
guard value != nil else { return }
results.append(attributeRange)
}
return results
}
///
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)) }
}
///
func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var kinds: [RDEPUBTextAttachmentKind] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, _, _ in
guard let rawValue = value as? String,
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
!kinds.contains(kind) else {
return
}
kinds.append(kind)
}
return kinds
}
///
func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var kinds: [RDEPUBTextBlockKind] = []
attributedString.enumerateAttribute(.rdPageBlockKind, in: safeRange) { value, _, _ in
guard let rawValue = value as? String,
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
!kinds.contains(kind) else {
return
}
kinds.append(kind)
}
return kinds
}
///
func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var hints: [RDEPUBTextSemanticHint] = []
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, _ in
guard let rawValue = value as? String else { return }
for hint in rawValue.split(separator: ",").compactMap({ RDEPUBTextSemanticHint(rawValue: String($0)) }) where !hints.contains(hint) {
hints.append(hint)
}
}
return hints
}
///
func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var placements: [RDEPUBTextAttachmentPlacement] = []
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: safeRange) { value, _, _ in
guard let rawValue = value as? String,
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
!placements.contains(placement) else {
return
}
placements.append(placement)
}
return placements
}
/// attributedString
func clampedRange(_ range: NSRange) -> NSRange? {
guard range.location >= 0, range.length >= 0 else { return nil }
guard attributedString.length > 0 else {
return range.location == 0 ? NSRange(location: 0, length: 0) : nil
}
guard range.location < attributedString.length else { return nil }
let maxLength = attributedString.length - range.location
return NSRange(location: range.location, length: min(range.length, maxLength))
}
/// fragment ID
func nearestTrailingFragmentID(
endingAt location: Int,
fragmentOffsets: [String: Int]
) -> String? {
fragmentOffsets
.filter { $0.value <= location }
.max { lhs, rhs in lhs.value < rhs.value }?
.key
}
// MARK: -
///
func diagnostics(
reason: RDEPUBTextPageBreakReason,
range: NSRange,
attachmentRanges: [NSRange],
blockRange: NSRange?,
blockKinds: [RDEPUBTextBlockKind],
semanticHints: [RDEPUBTextSemanticHint],
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
trigger: String? = nil
) -> [String] {
var items = ["page break: \(reason.rawValue)", "page range: \(NSStringFromRange(range))"]
if let blockRange {
items.append("block range: \(NSStringFromRange(blockRange))")
}
if !attachmentRanges.isEmpty {
items.append("attachment ranges: \(attachmentRanges.map(NSStringFromRange).joined(separator: ","))")
}
if !blockKinds.isEmpty {
items.append("block kinds: \(blockKinds.map(\.rawValue).joined(separator: ","))")
}
if !semanticHints.isEmpty {
items.append("semantic hints: \(semanticHints.map(\.rawValue).joined(separator: ","))")
}
if !attachmentPlacements.isEmpty {
items.append("attachment placements: \(attachmentPlacements.map(\.rawValue).joined(separator: ","))")
}
if let trigger, !trigger.isEmpty {
items.append("semantic trigger: \(trigger)")
}
return items
}
}

View File

@ -0,0 +1,308 @@
import Foundation
import UIKit
///
///
/// CoreText frame attributed string
struct RDEPUBPageBreakPolicy {
private let attributedString: NSAttributedString
init(attributedString: NSAttributedString) {
self.attributedString = attributedString
}
// MARK: -
/// avoidPageBreakInside
func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
guard let probeRange = clampedProbeRange(for: lineRange) else {
return false
}
var found = false
attributedString.enumerateAttributes(in: probeRange) { attributes, _, stop in
guard shouldTreatAvoidHintAsBlockProtection(attributes) else {
return
}
guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return }
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
if hints.contains(.avoidPageBreakInside) {
found = true
stop.pointee = true
}
}
return found
}
/// keepWithNext
func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
guard let probeRange = clampedProbeRange(for: lineRange) else {
return false
}
var found = false
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
}
// MARK: -
/// CoreText/DTCoreText
///
///
/// 1. chapterEnd
/// 2. pageRelate
/// 3. 使
func adjustedRange(
from proposedRange: NSRange,
totalLength: Int,
lineRanges: [NSRange],
factory: RDEPUBCoreTextPageFrameFactory
) -> (
range: NSRange,
breakReason: RDEPUBTextPageBreakReason,
blockRange: NSRange?,
attachmentRanges: [NSRange],
attachmentKinds: [RDEPUBTextAttachmentKind],
blockKinds: [RDEPUBTextBlockKind],
semanticHints: [RDEPUBTextSemanticHint],
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
diagnostics: [String]
) {
let pageEnd = proposedRange.location + proposedRange.length
let proposedBlockKinds = factory.blockKinds(in: proposedRange)
let proposedSemanticHints = factory.semanticHints(in: proposedRange)
let proposedAttachmentPlacements = factory.attachmentPlacements(in: proposedRange)
guard pageEnd < totalLength else {
return (
range: proposedRange,
breakReason: .chapterEnd,
blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
attachmentRanges: factory.attachmentRanges(in: proposedRange),
attachmentKinds: factory.attachmentKinds(in: proposedRange),
blockKinds: proposedBlockKinds,
semanticHints: proposedSemanticHints,
attachmentPlacements: proposedAttachmentPlacements,
diagnostics: factory.diagnostics(
reason: .chapterEnd,
range: proposedRange,
attachmentRanges: factory.attachmentRanges(in: proposedRange),
blockRange: factory.blockRange(at: max(proposedRange.location, pageEnd - 1)),
blockKinds: proposedBlockKinds,
semanticHints: proposedSemanticHints,
attachmentPlacements: proposedAttachmentPlacements
)
)
}
let currentBlockRange = factory.blockRange(at: max(proposedRange.location, pageEnd - 1))
let currentAttachmentRanges = factory.attachmentRanges(in: proposedRange)
let currentAttachmentKinds = factory.attachmentKinds(in: proposedRange)
let currentBlockKinds = proposedBlockKinds
let currentSemanticHints = proposedSemanticHints
let currentAttachmentPlacements = proposedAttachmentPlacements
// WXRead CTFrame pageRelate
//
if let pageRelateBoundary = preferredPageRelateBoundary(
after: proposedRange,
minimumEnd: proposedRange.location + 1,
lineRanges: lineRanges,
factory: factory
) {
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: factory.diagnostics(
reason: .semanticBoundary,
range: adjustedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements,
trigger: RDEPUBTextSemanticHint.pageRelate.rawValue
)
)
}
//
return (
range: proposedRange,
breakReason: .frameLimit,
blockRange: currentBlockRange,
attachmentRanges: currentAttachmentRanges,
attachmentKinds: currentAttachmentKinds,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements,
diagnostics: factory.diagnostics(
reason: .frameLimit,
range: proposedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements
)
)
}
// MARK: -
/// pageBreakBefore / pageBreakAfter
func preferredSemanticBoundary(
in range: NSRange,
minimumEnd: Int,
factory: RDEPUBCoreTextPageFrameFactory
) -> (location: Int, trigger: String)? {
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
return nil
}
var boundary: (location: Int, trigger: String)?
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, attributeRange, stop in
guard let rawValue = value as? String else { return }
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
guard !hints.isEmpty else { return }
if hints.contains(.pageBreakBefore),
attributeRange.location > safeRange.location,
attributeRange.location >= minimumEnd {
boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue)
stop.pointee = true
return
}
let attributeEnd = attributeRange.location + attributeRange.length
if hints.contains(.pageBreakAfter),
attributeEnd > minimumEnd,
attributeEnd < safeRange.location + safeRange.length {
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
stop.pointee = true
return
}
}
return boundary
}
///
func preferredAttachmentBoundary(
in range: NSRange,
minimumEnd: Int,
factory: RDEPUBCoreTextPageFrameFactory
) -> Int? {
guard let safeRange = factory.clampedRange(range), safeRange.length > 0 else {
return nil
}
var boundary: Int?
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, stop in
guard value != nil else { return }
let location = attributeRange.location
let placement = factory.attachmentPlacement(at: location)
let blockKind = factory.blockKind(at: location)
let isBlockLevelAttachment: Bool
switch placement {
case .centered:
isBlockLevelAttachment = true
case .inline, .baseline:
isBlockLevelAttachment = false
case nil:
isBlockLevelAttachment = blockKind == .attachment
}
guard isBlockLevelAttachment else { return }
let boundaryRange = factory.blockRange(at: location) ?? factory.paragraphRange(containing: location)
if boundaryRange.location > safeRange.location, boundaryRange.location >= minimumEnd {
boundary = boundaryRange.location
stop.pointee = true
}
}
return boundary
}
/// pageRelate
func preferredPageRelateBoundary(
after range: NSRange,
minimumEnd: Int,
lineRanges: [NSRange],
factory: RDEPUBCoreTextPageFrameFactory
) -> Int? {
let pageStartOfNext = range.location + range.length
guard pageStartOfNext > range.location,
pageStartOfNext < attributedString.length,
lineRanges.count >= 2,
factory.semanticHints(at: pageStartOfNext).contains(.pageRelate) else {
return nil
}
let boundaryBlockStart = factory.blockRange(at: pageStartOfNext)?.location
?? factory.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
}
// MARK: -
private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
guard let rawValue = attributes[.rdPageSemanticHints] as? String else {
return false
}
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
guard hints.contains(.avoidPageBreakInside) else {
return false
}
let placement = (attributes[.rdPageAttachmentPlacement] as? String)
.flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
let blockKind = (attributes[.rdPageBlockKind] as? String)
.flatMap(RDEPUBTextBlockKind.init(rawValue:))
if blockKind == .attachment, placement != .centered {
return false
}
return true
}
private func clampedProbeRange(for lineRange: NSRange) -> NSRange? {
clampedRange(NSRange(location: lineRange.location, length: max(lineRange.length, 1)))
}
private func clampedRange(_ range: NSRange) -> NSRange? {
guard range.location >= 0, range.length >= 0 else { return nil }
guard attributedString.length > 0 else {
return range.location == 0 ? NSRange(location: 0, length: 0) : nil
}
guard range.location < attributedString.length else { return nil }
let maxLength = attributedString.length - range.location
return NSRange(location: range.location, length: min(range.length, maxLength))
}
}

View File

@ -0,0 +1,26 @@
import CoreText
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
/// CoreText Facade
///
///
/// - RDEPUBCoreTextPageFrameFactory
/// - RDEPUBPageBreakPolicy keepWithNext
/// - RDEPUBChapterPageCounter
struct RDEPUBTextLayouter {
private let counter: RDEPUBChapterPageCounter
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
let factory = RDEPUBCoreTextPageFrameFactory(attributedString: attributedString, pageSize: pageSize, config: config)
self.counter = RDEPUBChapterPageCounter(factory: factory)
}
///
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
counter.layoutFrames(fragmentOffsets: fragmentOffsets)
}
}

View File

@ -0,0 +1,28 @@
import Foundation
import UIKit
// MARK: -
struct RDEPUBPageBreakDecision {
var range: NSRange
var reason: RDEPUBTextPageBreakReason
var diagnostics: [String]
}
protocol RDEPUBChapterPageCounting {
func pageRanges(
for attributedString: NSAttributedString,
pageSize: CGSize,
config: RDEPUBTextLayoutConfig,
fragmentOffsets: [String: Int]
) -> [RDEPUBPageBreakDecision]
}
protocol RDEPUBPageFrameBuilding {
func makeFrames(
attributedString: NSAttributedString,
pageSize: CGSize,
config: RDEPUBTextLayoutConfig,
fragmentOffsets: [String: Int]
) -> [RDEPUBTextLayoutFrame]
}

View File

@ -18,8 +18,9 @@ extension NSAttributedString {
fragmentOffsets: [String: Int] = [:],
config: RDEPUBTextLayoutConfig = .default
) -> [RDEPUBTextLayoutFrame] {
RDEPUBTextLayouter(attributedString: self, pageSize: size, config: config)
.layoutFrames(fragmentOffsets: fragmentOffsets)
let factory = RDEPUBCoreTextPageFrameFactory(attributedString: self, pageSize: size, config: config)
let counter = RDEPUBChapterPageCounter(factory: factory)
return counter.layoutFrames(fragmentOffsets: fragmentOffsets)
}
/// NSRange

View File

@ -25,13 +25,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
}
/// HTML NSAttributedString fragment
///
///
/// 1. HTML Data
/// 2. DTCoreText 退
/// 3. ${rd-sem-start/end}
/// 4. fragment
/// 5.
public func renderChapter(
request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent {
@ -46,8 +39,8 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
}
let attributedString = NSMutableAttributedString(attributedString: rendered)
RDEPUBTextRendererSupport.applyPaginationSemantics(in: attributedString)
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
return RDEPUBRenderedChapterContent(
attributedString: attributedString,
@ -65,7 +58,8 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
baseURL: URL?,
style: RDEPUBTextRenderStyle
) throws -> RDEPUBRenderedChapterContent {
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
let request = RDEPUBTextTypesetterPipeline().makeRequest(
from: RDEPUBTypesettingInput(
href: "",
title: "",
rawHTML: html,
@ -73,14 +67,15 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
style: style,
resourceResolver: nil
)
).request
return try renderChapter(request: request)
}
/// 退 DTCoreText HTML
private func fallbackRenderedContent(request: RDEPUBTextChapterRenderRequest) -> RDEPUBRenderedChapterContent {
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: request.context.html, style: request.style)
RDEPUBTextRendererSupport.applyPaginationSemantics(in: attributedString)
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
RDEPUBSemanticMarkerInjector.applyPaginationSemantics(in: attributedString)
let fragmentOffsets = RDEPUBFragmentMarkerInjector.extractFragmentOffsets(from: attributedString)
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: request.style)
return RDEPUBRenderedChapterContent(
attributedString: attributedString,
@ -91,9 +86,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
#if canImport(DTCoreText)
/// 使 DTCoreText HTML Data
///
/// `willFlushCallback` DOM
/// /
private func makeAttributedString(from data: Data, request: RDEPUBTextChapterRenderRequest) -> NSAttributedString? {
let builder = DTHTMLAttributedStringBuilder(
html: data,
@ -102,7 +94,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
)
builder?.willFlushCallback = { element in
guard let element else { return }
RDEPUBTextRendererSupport.prepareHTMLElementForReaderRendering(
RDEPUBAttachmentNormalizer.prepareHTMLElementForReaderRendering(
element,
style: request.style,
maxImageSize: resolvedMaxImageSize(for: request)
@ -112,9 +104,6 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
}
/// DTCoreText
///
/// - `( + ) / `
/// style.lineSpacing
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
let style = request.style
let maxImageSize = resolvedMaxImageSize(for: request)
@ -139,16 +128,12 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
}
private func resolvedMaxImageSize(for request: RDEPUBTextChapterRenderRequest) -> CGSize {
if let pageSize = request.pageSize {
let layoutConfig = request.layoutConfig ?? .default
let contentRect = layoutConfig.contentRect(fallback: pageSize)
let fallbackPageSize = request.pageSize ?? layoutConfig.fallbackViewportSize
let contentRect = layoutConfig.contentRect(fallback: fallbackPageSize)
let maxWidth = max(round(contentRect.width), 1)
let maxHeight = max(round(contentRect.height * layoutConfig.imageMaxHeightRatio), 1)
return CGSize(width: maxWidth, height: maxHeight)
}
let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28)
return CGSize(width: max(round(screenBounds.width), 1), height: max(round(screenBounds.height * 0.85), 1))
}
#endif
}

View File

@ -1,798 +0,0 @@
import UIKit
// MARK: -
///
/// /
public struct RDEPUBTextChapterPaginationDiagnostic: Equatable {
public var href: String
public var title: String
public var pageCount: Int
///
public var breakReasons: [RDEPUBTextPageBreakReason]
///
public var attachmentPageCount: Int
/// /
public var blockAdjustedPageCount: Int
public var blockKinds: [RDEPUBTextBlockKind]
public var semanticHints: [RDEPUBTextSemanticHint]
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// 4
public var sampleNotes: [String]
}
// MARK: -
///
///
/// `RDEPUBTextPage` `RDEPUBTextLayoutFrame`
/// `contentRange`
public struct RDEPUBTextPage: Equatable {
/// 0
public var absolutePageIndex: Int
public var chapterIndex: Int
public var spineIndex: Int
public var href: String
public var chapterTitle: String
/// 0
public var pageIndexInChapter: Int
public var totalPagesInChapter: Int
///
public var chapterContent: NSAttributedString
///
public var content: NSAttributedString
///
public var contentRange: NSRange
public var pageStartOffset: Int
public var pageEndOffset: Int
///
public var metadata: RDEPUBTextPageMetadata
}
// MARK: -
///
public struct RDEPUBTextChapter: Equatable {
public var chapterIndex: Int
public var spineIndex: Int
public var href: String
public var title: String
///
public var attributedContent: NSAttributedString
/// fragment ID
public var fragmentOffsets: [String: Int]
public var pageBreakReasons: [RDEPUBTextPageBreakReason]
public var pages: [RDEPUBTextPage]
}
// MARK: -
/// EPUB
///
/// EPUBTextRendering `RDEPUBTextBookBuilder.build()`
/// `chapterData(for:)` `chapterData(atChapterIndex:)` `RDEPUBChapterData`
public struct RDEPUBTextBook {
public var chapters: [RDEPUBTextChapter]
public var pages: [RDEPUBTextPage]
/// fileIndex/row/column
public let indexTable: RDEPUBTextIndexTable
/// WXRead <-> <->
public var positionConverter: RDEPUBTextPositionConverter {
RDEPUBTextPositionConverter(book: self)
}
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
}
/// href 访
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)
}
/// spine 访
public func chapterData(forSpineIndex spineIndex: Int) -> RDEPUBChapterData? {
guard let chapter = chapters.first(where: { $0.spineIndex == spineIndex }) 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)
}
/// 1 访
public func chapterData(forPageNumber pageNumber: Int) -> RDEPUBChapterData? {
guard let page = page(at: pageNumber) else { return nil }
return chapterData(forSpineIndex: page.spineIndex)
}
/// 访
public func chapterData(
for location: RDEPUBLocation,
resolver: RDEPUBResourceResolver,
bookIdentifier: String?
) -> RDEPUBChapterData? {
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier) else {
return nil
}
return chapterData(for: normalizedLocation.href)
}
/// WXRead
public var chapterInfos: [EPUBChapterInfo] {
chapters.map { chapter in
EPUBChapterInfo(
spineIndex: chapter.spineIndex,
title: chapter.title,
pageCount: chapter.pages.count
)
}
}
/// 1
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
return nil
}
return pages[pageNumber - 1]
}
/// 1
///
///
/// 1. rangeAnchor
/// 2. fragment ID
/// 3. navigationProgression 退
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
let chapterData = chapterData(for: normalizedLocation.href) else {
return nil
}
if let anchor = normalizedLocation.rangeAnchor?.start,
let page = positionConverter.pageNumber(for: anchor) {
return page
}
if let anchor = indexTable.anchor(for: normalizedLocation),
let page = positionConverter.pageNumber(for: anchor) {
return page
}
return chapterData.pageNumber(for: normalizedLocation)
}
/// RDEPUBLocation
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard let chapterData = chapterData(forPageNumber: pageNumber),
let page = page(at: pageNumber) else {
return nil
}
return chapterData.location(forPage: page, bookIdentifier: bookIdentifier)
}
}
// MARK: -
/// EPUB publication
///
/// spine HTML / RDEPUBTextBook
///
///
/// - WXRead
/// - /
/// -
/// -
public final class RDEPUBTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let cache: RDEPUBTextBookCache?
private let layoutConfig: RDEPUBTextLayoutConfig
private let sampler: RDEPUBTextPerformanceSampler
///
public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
///
public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = []
///
public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = []
/// /
public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0)
public init(
renderer: RDEPUBTextRenderer,
cache: RDEPUBTextBookCache? = nil,
layoutConfig: RDEPUBTextLayoutConfig = .default
) {
self.renderer = renderer
self.cache = cache
self.layoutConfig = layoutConfig
self.sampler = RDEPUBTextPerformanceSampler()
}
/// 使 DTCoreText
public convenience init() {
self.init(renderer: RDEPUBDTCoreTextRenderer())
}
private var isPaginationDebugEnabled: Bool {
ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
}
/// Phase 7
public func phase7SemanticSummary(title: String? = nil) -> String? {
guard !lastBuildPaginationDiagnostics.isEmpty else { return nil }
let blockKinds = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.blockKinds))
let semanticHints = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.semanticHints))
let attachmentPlacements = uniqueValues(from: lastBuildPaginationDiagnostics.flatMap(\.attachmentPlacements))
let note = lastBuildPaginationDiagnostics
.flatMap(\.sampleNotes)
.first(where: { $0.contains("semantic") || $0.contains("attachment") || $0.contains("block kinds") })
var parts = [
title,
"章节 \(lastBuildPaginationDiagnostics.count)",
blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]",
semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
].compactMap { $0 }
if let note {
parts.append(note)
}
return parts.joined(separator: " · ")
}
/// EPUB publication
///
///
/// 1.
/// 2. spine 线 HTML
/// 3. HTML NSAttributedString
/// 4. /
/// 5. 使 CoreText
/// 6.
/// 7. RDEPUBTextBook
public func build(
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) throws -> RDEPUBTextBook {
if isPaginationDebugEnabled {
print("[PaginationDebug] build pageSize=\(NSCoder.string(for: pageSize)) layoutInsets=\(NSCoder.string(for: layoutConfig.edgeInsets))")
}
var chapters: [RDEPUBTextChapter] = []
var flatPages: [RDEPUBTextPage] = []
lastBuildResourceDiagnostics = []
lastBuildPaginationDiagnostics = []
lastBuildPerformanceSamples = []
lastBuildCacheStats = (0, 0)
sampler.reset()
let buildStart = CFAbsoluteTimeGetCurrent()
// WXRead
let bookID = publication.metadata.identifier ?? publication.metadata.title
let cacheKey = makeCacheKey(bookID: bookID, pageSize: pageSize, style: style)
let cachedPagination = cacheKey.flatMap { cache?.load(key: $0) }
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
continue
}
//
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: item.href,
title: chapterTitle,
rawHTML: rawHTML,
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
style: style,
resourceResolver: publication.resourceResolver,
contentLanguageCode: publication.metadata.language,
pageSize: pageSize,
layoutConfig: layoutConfig
)
// HTML NSAttributedString
let renderStart = CFAbsoluteTimeGetCurrent()
let rendered = try renderer.renderChapter(request: request)
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
lastBuildResourceDiagnostics.append(contentsOf: rendered.resourceDiagnostics)
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] rendered href=\(item.href) textLength=\(plainText.count) attrLength=\(rendered.attributedString.length) attachments=\(attachmentCount(in: rendered.attributedString))")
}
// /
if shouldSkipChapter(item: item, content: rendered.attributedString, text: plainText) {
if item.href.lowercased().contains("cover") {
print("[EPUB][Cover] skipped href=\(item.href)")
}
continue
}
let chapterIndex = chapters.count
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
//
let paginateStart = CFAbsoluteTimeGetCurrent()
let layoutFrames: [RDEPUBTextLayoutFrame]
let isCacheHit: Bool
if isAttachmentOnlyCoverChapter(item: item, content: content, plainText: plainText) {
//
layoutFrames = [
RDEPUBTextLayoutFrame(
contentRange: NSRange(location: 0, length: content.length),
breakReason: .chapterEnd,
blockRange: nil,
attachmentRanges: attachmentRanges(in: content),
attachmentKinds: [],
blockKinds: [],
semanticHints: [],
attachmentPlacements: [],
trailingFragmentID: nil,
diagnostics: [
"page break: chapterEnd",
"cover fallback: single attachment page",
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
]
)
]
isCacheHit = false
} else if let cached = cachedPagination?[item.href] {
// 使 CoreText
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
return RDEPUBTextLayoutFrame(
contentRange: range,
breakReason: breakReason,
blockRange: nil,
attachmentRanges: [],
attachmentKinds: [],
blockKinds: [],
semanticHints: cached.semanticHints,
attachmentPlacements: [],
trailingFragmentID: nil,
diagnostics: ["page break: \(breakReason.rawValue)", "page range: \(NSStringFromRange(range))", "source: cache hit"]
)
}
isCacheHit = true
} else {
// CoreText
layoutFrames = content.length > 0
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets, config: layoutConfig)
: []
isCacheHit = false
}
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
//
let normalizedFrames = normalizeTrailingFrames(
layoutFrames,
content: content,
href: item.href
)
let effectiveFrames = normalizedFrames.isEmpty && content.length > 0
? [
RDEPUBTextLayoutFrame(
contentRange: NSRange(location: 0, length: content.length),
breakReason: .chapterEnd,
blockRange: nil,
attachmentRanges: [],
attachmentKinds: [],
blockKinds: [],
semanticHints: [],
attachmentPlacements: [],
trailingFragmentID: nil,
diagnostics: [
"page break: chapterEnd",
"page range: \(NSStringFromRange(NSRange(location: 0, length: content.length)))"
]
)
]
: 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")")
}
//
sampler.record(RDEPUBTextPerformanceSample(
chapterHref: item.href,
renderDuration: renderDuration,
paginateDuration: paginateDuration,
pageCount: effectiveFrames.count,
attributedStringLength: content.length,
cacheHit: isCacheHit
))
if isCacheHit {
lastBuildCacheStats.hits += 1
} else {
lastBuildCacheStats.misses += 1
}
//
let chapterAttributedContent = content.copy() as! NSAttributedString
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
let range = frame.contentRange
return RDEPUBTextPage(
absolutePageIndex: flatPages.count + localPageIndex,
chapterIndex: chapterIndex,
spineIndex: spineIndex,
href: item.href,
chapterTitle: chapterTitle,
pageIndexInChapter: localPageIndex,
totalPagesInChapter: effectiveFrames.count,
chapterContent: chapterAttributedContent,
content: content.attributedSubstring(from: range),
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + max(range.length - 1, 0),
metadata: frame.metadata
)
}
if isPaginationDebugEnabled,
item.href.contains("Chapter_3.xhtml") {
print("[PaginationDebug] href=\(item.href) pages=\(pages.count)")
for page in pages {
let preview = debugPreview(for: page.content, limit: 36)
print("[PaginationDebug] absPage=\(page.absolutePageIndex + 1) localPage=\(page.pageIndexInChapter + 1) range=\(NSStringFromRange(page.contentRange)) break=\(page.metadata.breakReason.rawValue) preview=\(preview)")
for note in page.metadata.diagnostics.prefix(4) {
print("[PaginationDebug] note=\(note)")
}
}
}
chapters.append(
RDEPUBTextChapter(
chapterIndex: chapterIndex,
spineIndex: spineIndex,
href: item.href,
title: chapterTitle,
attributedContent: chapterAttributedContent,
fragmentOffsets: rendered.fragmentOffsets,
pageBreakReasons: pages.map(\.metadata.breakReason),
pages: pages
)
)
lastBuildPaginationDiagnostics.append(
RDEPUBTextChapterPaginationDiagnostic(
href: item.href,
title: chapterTitle,
pageCount: pages.count,
breakReasons: pages.map(\.metadata.breakReason),
attachmentPageCount: pages.filter { !$0.metadata.attachmentKinds.isEmpty }.count,
blockAdjustedPageCount: pages.filter { $0.metadata.breakReason == .blockBoundary || $0.metadata.breakReason == .attachmentBoundary }.count,
blockKinds: uniqueValues(from: pages.flatMap(\.metadata.blockKinds)),
semanticHints: uniqueValues(from: pages.flatMap(\.metadata.semanticHints)),
attachmentPlacements: uniqueValues(from: pages.flatMap(\.metadata.attachmentPlacements)),
sampleNotes: Array(
pages
.flatMap(\.metadata.diagnostics)
.prefix(4)
)
)
)
flatPages.append(contentsOf: pages)
}
let book = RDEPUBTextBook(chapters: chapters, pages: flatPages)
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
//
if let cacheKey {
let paginationCache = chapters.map { chapter in
let pageRanges = chapter.pages.map(\.contentRange)
let breakReasons = chapter.pages.map(\.metadata.breakReason)
let semanticHints = Array(Set(chapter.pages.flatMap(\.metadata.semanticHints)))
return RDEPUBTextChapterPaginationCache(
href: chapter.href,
pageRanges: pageRanges,
breakReasons: breakReasons,
semanticHints: semanticHints
)
}
cache?.save(paginationCache, key: cacheKey)
}
print(sampler.summary())
lastBuildPerformanceSamples = sampler.samples
return book
}
// MARK: -
/// 退 spine item title href
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
tocItem.href.components(separatedBy: "#").first == item.href
})?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
return title
}
let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedTitle.isEmpty ? item.href : trimmedTitle
}
///
private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
items.flatMap { item in
[item] + flattenedTOCItems(from: item.children)
}
}
// MARK: -
/// /
private func shouldSkipChapter(item: RDEPUBSpineItem, content: NSAttributedString, text: String) -> Bool {
let lowercasedHref = item.href.lowercased()
var hasAttachment = false
if content.length > 0 {
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
guard value != nil else { return }
hasAttachment = true
stop.pointee = true
}
}
if text.isEmpty && !hasAttachment && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
return true
}
return false
}
// MARK: -
///
private func attachmentCount(in content: NSAttributedString) -> Int {
guard content.length > 0 else { return 0 }
var count = 0
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, _ in
if value != nil {
count += 1
}
}
return count
}
/// NSRange
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
guard content.length > 0 else { return [] }
var ranges: [NSRange] = []
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, range, _ in
if value != nil {
ranges.append(range)
}
}
return ranges
}
// MARK: -
/// href cover
private func isAttachmentOnlyCoverChapter(
item: RDEPUBSpineItem,
content: NSAttributedString,
plainText: String
) -> Bool {
let lowercasedHref = item.href.lowercased()
guard lowercasedHref.contains("cover") else { return false }
let trimmed = plainText.trimmingCharacters(in: .whitespacesAndNewlines)
return attachmentCount(in: content) > 0 && trimmed.count <= 1
}
private func debugPreview(for content: NSAttributedString, limit: Int) -> String {
let collapsed = content.string
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\t", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !collapsed.isEmpty else { return "<empty>" }
if collapsed.count <= limit {
return collapsed
}
let head = collapsed.prefix(limit)
return "\(head)"
}
// MARK: -
///
/// 1.
/// 2.
/// 3. 2
private func normalizeTrailingFrames(
_ frames: [RDEPUBTextLayoutFrame],
content: NSAttributedString,
href: String
) -> [RDEPUBTextLayoutFrame] {
guard frames.count > 1 else { return frames }
var normalized = frames
// /
//
var compacted: [RDEPUBTextLayoutFrame] = []
compacted.reserveCapacity(normalized.count)
for frame in normalized {
if shouldDropWhitespaceOnlyFrame(frame, in: content) {
let note = "normalized: dropped whitespace-only intermediate page \(NSStringFromRange(frame.contentRange))"
if var previous = compacted.popLast() {
previous.diagnostics.append(note)
compacted.append(previous)
} else {
print("[EPUB][Pagination] href=\(href) dropped leading/intermediate whitespace frame \(NSStringFromRange(frame.contentRange))")
}
continue
}
compacted.append(frame)
}
normalized = compacted
//
while let lastFrame = normalized.last,
shouldDropWhitespaceOnlyFrame(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 shouldDropWhitespaceOnlyFrame(
_ 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
}
///
/// 2 8 12
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
}
// MARK: -
///
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
values.reduce(into: [T]()) { result, value in
if !result.contains(value) {
result.append(value)
}
}
}
/// NSRange
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
}
// MARK: -
/// ID SHA256
private func makeCacheKey(
bookID: String,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) -> String? {
guard let cache else { return nil }
return cache.cacheKey(
bookID: bookID,
fontSize: style.font.pointSize,
lineHeightMultiple: style.lineSpacing,
contentInsets: layoutConfig.edgeInsets,
pageSize: pageSize,
layoutConfigSignature: layoutConfig.cacheSignature
)
}
}

View File

@ -1,946 +0,0 @@
import CoreText
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
/// CoreText
///
///
/// 1. avoidPageBreakInside WXRead 退
/// 2. keepWithNext
/// 3. pageBreakBefore/After
/// 4. pageRelate
/// 5.
/// 6. CoreText
///
///
/// - DTCoreText DTCoreTextLayouter DTCoreTextLayoutFrame
/// - CoreText 退CTFramesetterCreateFrame
struct RDEPUBTextLayouter {
///
private let attributedString: NSAttributedString
///
private let pageSize: CGSize
/// CoreText
private let framesetter: CTFramesetter
/// CTFrame
private let path: CGPath
/// DTCoreText
private let dtLayoutRect: CGRect
/// avoidPageBreakInside
private let config: RDEPUBTextLayoutConfig
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
self.attributedString = attributedString
self.pageSize = pageSize
self.config = config
self.framesetter = CTFramesetterCreateWithAttributedString(attributedString)
self.dtLayoutRect = config.contentRect(fallback: pageSize)
self.path = Self.makeLayoutPath(pageSize: pageSize, config: config)
}
///
/// DTCoreText CoreText
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
}
#if canImport(DTCoreText)
return layoutFramesUsingDTCoreText(fragmentOffsets: fragmentOffsets)
#else
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
#endif
}
// MARK: - CoreText 退
/// 使 CoreText API
///
/// CTFramesetterCreateFrame
private func layoutFramesUsingCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
}
var frames: [RDEPUBTextLayoutFrame] = []
var location = 0
let resolvedSize = config.resolvedFrameSize(fallback: pageSize)
let usableWidth = resolvedSize.width - config.edgeInsets.left - config.edgeInsets.right
let usableHeight = resolvedSize.height - config.edgeInsets.top - config.edgeInsets.bottom
guard usableWidth > 0, usableHeight > 0 else {
return []
}
while location < attributedString.length {
let framePath = CGMutablePath()
// WXRead WRChapterPageCount
// CoreText 使 bottom inset y UIKit top inset
let pageRect = CGRect(
x: config.edgeInsets.left,
y: config.edgeInsets.bottom,
width: usableWidth,
height: usableHeight
)
framePath.addRect(pageRect)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), framePath, nil)
let proposedRange = proposedRangeUsingWXReadPageCount(
from: frame,
start: location,
usableHeight: usableHeight,
totalLength: attributedString.length
)
guard proposedRange.length > 0 else {
break
}
// avoidPageBreakInside WXRead
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
let lineRanges = lineRanges(from: frame)
let adjusted = adjustedRange(
from: avoidAdjusted,
totalLength: attributedString.length,
lineRanges: lineRanges
)
let trailingFragmentID = nearestTrailingFragmentID(
endingAt: adjusted.range.location + adjusted.range.length,
fragmentOffsets: fragmentOffsets
)
frames.append(
RDEPUBTextLayoutFrame(
contentRange: adjusted.range,
breakReason: adjusted.breakReason,
blockRange: adjusted.blockRange,
attachmentRanges: adjusted.attachmentRanges,
attachmentKinds: adjusted.attachmentKinds,
blockKinds: adjusted.blockKinds,
semanticHints: adjusted.semanticHints,
attachmentPlacements: adjusted.attachmentPlacements,
trailingFragmentID: trailingFragmentID,
diagnostics: adjusted.diagnostics
)
)
let nextLocation = adjusted.range.location + adjusted.range.length
guard nextLocation > location else {
location += max(proposedRange.length, 1)
continue
}
location = nextLocation
}
return frames
}
// MARK: - DTCoreText
#if canImport(DTCoreText)
/// 使 DTCoreTextLayouter
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard config.numberOfColumns == 1 else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
}
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
}
layouter.shouldCacheLayoutFrames = false
var frames: [RDEPUBTextLayoutFrame] = []
var location = 0
let pageRect = dtLayoutRect
while location < attributedString.length {
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
break
}
let visibleRange = layoutFrame.visibleStringRange()
guard visibleRange.length > 0 else {
break
}
let proposedRange = NSRange(location: location, length: visibleRange.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 verifiedRange: NSRange
if adjusted.breakReason == .attachmentBoundary {
verifiedRange = verifiedDisplayRange(for: adjusted.range)
} else {
verifiedRange = adjusted.range
}
let trailingFragmentID = nearestTrailingFragmentID(
endingAt: verifiedRange.location + verifiedRange.length,
fragmentOffsets: fragmentOffsets
)
let diagnostics = verifiedRange == adjusted.range
? adjusted.diagnostics
: adjusted.diagnostics + ["verified-display-range \(NSStringFromRange(adjusted.range)) -> \(NSStringFromRange(verifiedRange))"]
frames.append(
RDEPUBTextLayoutFrame(
contentRange: verifiedRange,
breakReason: adjusted.breakReason,
blockRange: blockRange(at: max(verifiedRange.location, verifiedRange.location + verifiedRange.length - 1)),
attachmentRanges: attachmentRanges(in: verifiedRange),
attachmentKinds: attachmentKinds(in: verifiedRange),
blockKinds: blockKinds(in: verifiedRange),
semanticHints: semanticHints(in: verifiedRange),
attachmentPlacements: attachmentPlacements(in: verifiedRange),
trailingFragmentID: trailingFragmentID,
diagnostics: diagnostics
)
)
let nextLocation = verifiedRange.location + verifiedRange.length
guard nextLocation > location else {
location += max(visibleRange.length, 1)
continue
}
location = nextLocation
}
return frames
}
private func verifiedDisplayRange(for range: NSRange) -> NSRange {
guard let clampedRange = clampedRange(range),
clampedRange.length > 0,
!attachmentRanges(in: clampedRange).isEmpty else {
return range
}
let pageContent = attributedString.attributedSubstring(from: clampedRange)
guard let layouter = DTCoreTextLayouter(attributedString: pageContent) else {
return clampedRange
}
layouter.shouldCacheLayoutFrames = false
guard let layoutFrame = layouter.layoutFrame(with: dtLayoutRect, range: NSRange(location: 0, length: 0)) else {
return clampedRange
}
let visibleRange = layoutFrame.visibleStringRange()
guard visibleRange.length > 0, visibleRange.length < clampedRange.length else {
return clampedRange
}
return NSRange(location: clampedRange.location, length: visibleRange.length)
}
#endif
private static func makeLayoutPath(pageSize: CGSize, config: RDEPUBTextLayoutConfig) -> CGPath {
let columnRects = config.columnRects(fallback: pageSize)
guard columnRects.count > 1 else {
return CGPath(rect: columnRects.first ?? CGRect(origin: .zero, size: pageSize), transform: nil)
}
let path = CGMutablePath()
for rect in columnRects {
path.addRect(rect)
}
return path
}
// MARK: -
/// CoreText
///
///
/// 1. chapterEnd
/// 2. pageBreakBefore/After
/// 3. pageRelate
/// 4.
/// 5. 使
///
/// 55%
private func adjustedRange(
from proposedRange: NSRange,
totalLength: Int,
lineRanges: [NSRange]
) -> (
range: NSRange,
breakReason: RDEPUBTextPageBreakReason,
blockRange: NSRange?,
attachmentRanges: [NSRange],
attachmentKinds: [RDEPUBTextAttachmentKind],
blockKinds: [RDEPUBTextBlockKind],
semanticHints: [RDEPUBTextSemanticHint],
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
diagnostics: [String]
) {
let pageEnd = proposedRange.location + proposedRange.length
let proposedBlockKinds = blockKinds(in: proposedRange)
let proposedSemanticHints = semanticHints(in: proposedRange)
let proposedAttachmentPlacements = attachmentPlacements(in: proposedRange)
guard pageEnd < totalLength else {
return (
range: proposedRange,
breakReason: .chapterEnd,
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
attachmentRanges: attachmentRanges(in: proposedRange),
attachmentKinds: attachmentKinds(in: proposedRange),
blockKinds: proposedBlockKinds,
semanticHints: proposedSemanticHints,
attachmentPlacements: proposedAttachmentPlacements,
diagnostics: diagnostics(
reason: .chapterEnd,
range: proposedRange,
attachmentRanges: attachmentRanges(in: proposedRange),
blockRange: blockRange(at: max(proposedRange.location, pageEnd - 1)),
blockKinds: proposedBlockKinds,
semanticHints: proposedSemanticHints,
attachmentPlacements: proposedAttachmentPlacements
)
)
}
let currentBlockRange = blockRange(at: max(proposedRange.location, pageEnd - 1))
let currentAttachmentRanges = attachmentRanges(in: proposedRange)
let currentAttachmentKinds = attachmentKinds(in: proposedRange)
let currentBlockKinds = proposedBlockKinds
let currentSemanticHints = proposedSemanticHints
let currentAttachmentPlacements = proposedAttachmentPlacements
// WXRead CTFrame pageRelate
// keepWithNext/attachmentBoundary/
//
if let pageRelateBoundary = preferredPageRelateBoundary(
after: proposedRange,
minimumEnd: proposedRange.location + 1,
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
)
)
}
// 4.
return (
range: proposedRange,
breakReason: .frameLimit,
blockRange: currentBlockRange,
attachmentRanges: currentAttachmentRanges,
attachmentKinds: currentAttachmentKinds,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements,
diagnostics: diagnostics(
reason: .frameLimit,
range: proposedRange,
attachmentRanges: currentAttachmentRanges,
blockRange: currentBlockRange,
blockKinds: currentBlockKinds,
semanticHints: currentSemanticHints,
attachmentPlacements: currentAttachmentPlacements
)
)
}
// MARK: -
/// pageBreakBefore / pageBreakAfter
///
/// minimumEnd
private func preferredSemanticBoundary(
in range: NSRange,
minimumEnd: Int
) -> (location: Int, trigger: String)? {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return nil
}
var boundary: (location: Int, trigger: String)?
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, attributeRange, stop in
guard let rawValue = value as? String else { return }
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
guard !hints.isEmpty else { return }
if hints.contains(.pageBreakBefore),
attributeRange.location > safeRange.location,
attributeRange.location >= minimumEnd {
boundary = (attributeRange.location, RDEPUBTextSemanticHint.pageBreakBefore.rawValue)
stop.pointee = true
return
}
let attributeEnd = attributeRange.location + attributeRange.length
if hints.contains(.pageBreakAfter),
attributeEnd > minimumEnd,
attributeEnd < safeRange.location + safeRange.length {
boundary = (attributeEnd, RDEPUBTextSemanticHint.pageBreakAfter.rawValue)
stop.pointee = true
return
}
}
return boundary
}
/// .attachment .centered
///
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return nil
}
var boundary: Int?
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, stop in
guard value != nil else { return }
let location = attributeRange.location
let placement = attachmentPlacement(at: location)
let blockKind = blockKind(at: location)
// WXRead
//
//
// /线 note.png
let isBlockLevelAttachment: Bool
switch placement {
case .centered:
isBlockLevelAttachment = true
case .inline, .baseline:
isBlockLevelAttachment = false
case nil:
isBlockLevelAttachment = blockKind == .attachment
}
guard isBlockLevelAttachment else { return }
let boundaryRange = blockRange(at: location) ?? paragraphRange(containing: location)
if boundaryRange.location > safeRange.location, boundaryRange.location >= minimumEnd {
boundary = boundaryRange.location
stop.pointee = true
}
}
return boundary
}
/// pageRelate pageRelate
///
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
}
// MARK: -
///
private func blockRange(at location: Int) -> NSRange? {
guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
if let encodedRange = attributes[.rdPageBlockRange] as? String {
return NSRangeFromString(encodedRange)
}
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) }
let safeLocation = min(max(location, 0), max(source.length - 1, 0))
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
}
///
private func attachmentRanges(in range: NSRange) -> [NSRange] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var results: [NSRange] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, attributeRange, _ in
guard value != nil else { return }
results.append(attributeRange)
}
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] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var kinds: [RDEPUBTextAttachmentKind] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: safeRange) { value, _, _ in
guard let rawValue = value as? String,
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue),
!kinds.contains(kind) else {
return
}
kinds.append(kind)
}
return kinds
}
///
private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var kinds: [RDEPUBTextBlockKind] = []
attributedString.enumerateAttribute(.rdPageBlockKind, in: safeRange) { value, _, _ in
guard let rawValue = value as? String,
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
!kinds.contains(kind) else {
return
}
kinds.append(kind)
}
return kinds
}
///
private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var hints: [RDEPUBTextSemanticHint] = []
attributedString.enumerateAttribute(.rdPageSemanticHints, in: safeRange) { value, _, _ in
guard let rawValue = value as? String else { return }
for hint in rawValue.split(separator: ",").compactMap({ RDEPUBTextSemanticHint(rawValue: String($0)) }) where !hints.contains(hint) {
hints.append(hint)
}
}
return hints
}
///
private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
guard let safeRange = clampedRange(range), safeRange.length > 0 else {
return []
}
var placements: [RDEPUBTextAttachmentPlacement] = []
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: safeRange) { value, _, _ in
guard let rawValue = value as? String,
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
!placements.contains(placement) else {
return
}
placements.append(placement)
}
return placements
}
/// attributedString
private func clampedRange(_ range: NSRange) -> NSRange? {
guard range.location >= 0, range.length >= 0 else { return nil }
guard attributedString.length > 0 else {
return range.location == 0 ? NSRange(location: 0, length: 0) : nil
}
guard range.location < attributedString.length else { return nil }
let maxLength = attributedString.length - range.location
return NSRange(location: range.location, length: min(range.length, maxLength))
}
private func clampedProbeRange(for lineRange: NSRange) -> NSRange? {
clampedRange(NSRange(location: lineRange.location, length: max(lineRange.length, 1)))
}
/// fragment ID
private func nearestTrailingFragmentID(
endingAt location: Int,
fragmentOffsets: [String: Int]
) -> String? {
fragmentOffsets
.filter { $0.value <= location }
.max { lhs, rhs in lhs.value < rhs.value }?
.key
}
// MARK: - avoidPageBreakInsideWXRead
/// CTFrame avoidPageBreakInside
/// WXRead WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded
/// 3 kMaxLinesToRemove
private func trimmedRangeForAvoidPageBreakInside(
from frame: CTFrame,
proposed: NSRange
) -> NSRange {
guard config.avoidPageBreakInsideEnabled else { return proposed }
let lines = CTFrameGetLines(frame) as! [CTLine]
guard !lines.isEmpty else { return proposed }
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
//
// kMaxLinesToRemove = 3 WXRead
let kMaxLinesToRemove = 3
var linesToRemove = 0
for i in stride(from: lines.count - 1, through: 0, by: -1) {
let lineRange = CTLineGetStringRange(lines[i])
let lineNSRange = NSRange(location: lineRange.location, length: lineRange.length)
if lineIsInAvoidPageBreakInsideBlock(lineNSRange) {
linesToRemove += 1
if linesToRemove >= kMaxLinesToRemove {
// kMaxLinesToRemove
linesToRemove = kMaxLinesToRemove
break
}
} else {
break
}
}
guard linesToRemove > 0 else { return proposed }
let validLineCount = lines.count - linesToRemove
guard validLineCount > 0 else {
// 退
return proposed
}
let lastValidLine = lines[validLineCount - 1]
let lastLineRange = CTLineGetStringRange(lastValidLine)
let endLocation = lastLineRange.location + lastLineRange.length
let adjustedLength = endLocation - proposed.location
guard adjustedLength > 0 else { return proposed }
return NSRange(location: proposed.location, length: adjustedLength)
}
/// CoreText keepWithNext
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)
}
// MARK: - DTCoreText
#if canImport(DTCoreText)
/// DTCoreText avoidPageBreakInside
private func trimmedRangeForAvoidPageBreakInside(
from layoutFrame: DTCoreTextLayoutFrame,
proposed: NSRange
) -> NSRange {
guard config.avoidPageBreakInsideEnabled else { return proposed }
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine], !lines.isEmpty else {
return proposed
}
let kMaxLinesToRemove = 3
var linesToRemove = 0
for line in lines.reversed() {
let lineRange = line.stringRange()
if lineIsInAvoidPageBreakInsideBlock(lineRange) {
linesToRemove += 1
if linesToRemove >= kMaxLinesToRemove {
linesToRemove = kMaxLinesToRemove
break
}
} else {
break
}
}
guard linesToRemove > 0 else { return proposed }
let validLineCount = lines.count - linesToRemove
guard validLineCount > 0 else { return proposed }
let lastValidLine = lines[validLineCount - 1]
let lastLineRange = lastValidLine.stringRange()
let endLocation = lastLineRange.location + lastLineRange.length
let adjustedLength = endLocation - proposed.location
guard adjustedLength > 0 else { return proposed }
return NSRange(location: proposed.location, length: adjustedLength)
}
/// DTCoreText keepWithNext
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
/// keepWithNext
/// 3
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)
}
/// avoidPageBreakInside
private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
guard let probeRange = clampedProbeRange(for: lineRange) else {
return false
}
var found = false
attributedString.enumerateAttributes(in: probeRange) { attributes, _, stop in
guard shouldTreatAvoidHintAsBlockProtection(attributes) else {
return
}
guard let rawValue = attributes[.rdPageSemanticHints] as? String else { return }
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
if hints.contains(.avoidPageBreakInside) {
found = true
stop.pointee = true
}
}
return found
}
/// avoidPageBreakInside
/// note.png `img`
private func shouldTreatAvoidHintAsBlockProtection(_ attributes: [NSAttributedString.Key: Any]) -> Bool {
guard let rawValue = attributes[.rdPageSemanticHints] as? String else {
return false
}
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
guard hints.contains(.avoidPageBreakInside) else {
return false
}
let placement = (attributes[.rdPageAttachmentPlacement] as? String)
.flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
let blockKind = (attributes[.rdPageBlockKind] as? String)
.flatMap(RDEPUBTextBlockKind.init(rawValue:))
if blockKind == .attachment, placement != .centered {
return false
}
return true
}
/// keepWithNext
private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
guard let probeRange = clampedProbeRange(for: lineRange) else {
return false
}
var found = false
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
}
/// CTFrame
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)
}
}
/// WXRead `WRChapterPageCount.recalculatePageRangesForAttributedString`
/// 1. CTFrame
/// 2. origin
/// 3. `lineY - ascent > usableHeight`
/// 4.
private func proposedRangeUsingWXReadPageCount(
from frame: CTFrame,
start location: Int,
usableHeight: CGFloat,
totalLength: Int
) -> NSRange {
let lines = CTFrameGetLines(frame) as! [CTLine]
guard !lines.isEmpty else {
return NSRange(location: location, length: 0)
}
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
var pageCharCount = 0
for (index, line) in lines.enumerated() {
let lineRange = CTLineGetStringRange(line)
let lineY = origins[index].y
var ascent: CGFloat = 0
var descent: CGFloat = 0
CTLineGetTypographicBounds(line, &ascent, &descent, nil)
if lineY - ascent > usableHeight {
break
}
pageCharCount += lineRange.length
}
if pageCharCount == 0 {
pageCharCount = 1
}
return NSRange(location: location, length: min(pageCharCount, totalLength - location))
}
#if canImport(DTCoreText)
/// DTCoreTextLayoutFrame
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
return []
}
return lines.map { $0.stringRange() }
}
#endif
// MARK: -
///
private func diagnostics(
reason: RDEPUBTextPageBreakReason,
range: NSRange,
attachmentRanges: [NSRange],
blockRange: NSRange?,
blockKinds: [RDEPUBTextBlockKind],
semanticHints: [RDEPUBTextSemanticHint],
attachmentPlacements: [RDEPUBTextAttachmentPlacement],
trigger: String? = nil
) -> [String] {
var items = ["page break: \(reason.rawValue)", "page range: \(NSStringFromRange(range))"]
if let blockRange {
items.append("block range: \(NSStringFromRange(blockRange))")
}
if !attachmentRanges.isEmpty {
items.append("attachment ranges: \(attachmentRanges.map(NSStringFromRange).joined(separator: ","))")
}
if !blockKinds.isEmpty {
items.append("block kinds: \(blockKinds.map(\.rawValue).joined(separator: ","))")
}
if !semanticHints.isEmpty {
items.append("semantic hints: \(semanticHints.map(\.rawValue).joined(separator: ","))")
}
if !attachmentPlacements.isEmpty {
items.append("attachment placements: \(attachmentPlacements.map(\.rawValue).joined(separator: ","))")
}
if let trigger, !trigger.isEmpty {
items.append("semantic trigger: \(trigger)")
}
return items
}
}

View File

@ -100,6 +100,8 @@ public struct RDEPUBTextLayoutConfig: Equatable {
public var hyphenation: Bool
///
public var imageMaxHeightRatio: CGFloat
/// pageSize viewport
public var fallbackViewportSize: CGSize
public init(
frameWidth: CGFloat = 0,
@ -111,7 +113,8 @@ public struct RDEPUBTextLayoutConfig: Equatable {
avoidWidows: Bool = true,
avoidPageBreakInsideEnabled: Bool = true,
hyphenation: Bool = true,
imageMaxHeightRatio: CGFloat = 0.85
imageMaxHeightRatio: CGFloat = 0.85,
fallbackViewportSize: CGSize = CGSize(width: 375, height: 667)
) {
self.frameWidth = frameWidth
self.frameHeight = frameHeight
@ -123,6 +126,7 @@ public struct RDEPUBTextLayoutConfig: Equatable {
self.avoidPageBreakInsideEnabled = avoidPageBreakInsideEnabled
self.hyphenation = hyphenation
self.imageMaxHeightRatio = imageMaxHeightRatio
self.fallbackViewportSize = fallbackViewportSize
}
///
@ -172,7 +176,9 @@ public struct RDEPUBTextLayoutConfig: Equatable {
avoidWidows ? "1" : "0",
avoidPageBreakInsideEnabled ? "1" : "0",
hyphenation ? "1" : "0",
String(format: "%.3f", imageMaxHeightRatio)
String(format: "%.3f", imageMaxHeightRatio),
String(format: "%.3f", fallbackViewportSize.width),
String(format: "%.3f", fallbackViewportSize.height)
].joined(separator: "|")
}
}

View File

@ -43,7 +43,20 @@ public final class RDPlainTextBookBuilder {
for (index, spec) in chapterSpecs.enumerated() {
let html = wrapTextAsHTML(spec.content)
let rendered = try renderer.renderChapter(html: html, baseURL: nil, style: style)
let request = RDEPUBTextChapterRenderRequest(
context: RDEPUBTextChapterContext(
href: "chapter_\(index).xhtml",
title: spec.title ?? "\(index + 1)",
html: html,
baseURL: nil,
stylesheet: RDEPUBTextStyleSheetPackage(layers: []),
resourceDiagnostics: []
),
style: style,
pageSize: pageSize,
layoutConfig: layoutConfig
)
let rendered = try renderer.renderChapter(request: request)
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
let layoutFrames = content.length > 0 ? content.rd_paginatedFrames(size: pageSize, config: layoutConfig) : []

View File

@ -0,0 +1,187 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
#endif
struct RDEPUBAttachmentNormalizer {
///
private static var didLogFootnoteAttachment = false
///
private static var didLogCoverAttachment = false
#if canImport(DTCoreText)
func normalize(
_ attachment: DTTextAttachment,
fontPointSize: CGFloat,
maxImageSize: CGSize
) {
Self.normalizeAttachmentLayoutForWXRead(
attachment,
fontPointSize: fontPointSize,
maxImageSize: maxImageSize
)
}
#endif
// MARK: - DTCoreText
#if canImport(DTCoreText)
/// DTTextAttachment
static func normalizeAttachmentLayoutForWXRead(
_ attachment: DTTextAttachment,
fontPointSize: CGFloat,
maxImageSize: CGSize? = nil
) {
let pointSize = max(fontPointSize, 1)
let originalSize = attachment.originalSize
if isFootnoteAttachment(attachment) {
let targetWidth = max(round(pointSize), 1)
let aspectRatio = originalSize.height > 0 ? originalSize.width / originalSize.height : 1
let targetHeight = max(round(targetWidth / max(aspectRatio, 0.1)), 1)
attachment.displaySize = CGSize(width: targetWidth, height: targetHeight)
attachment.verticalAlignment = .baseline
if !didLogFootnoteAttachment {
didLogFootnoteAttachment = true
print("[EPUB][Attachment] footnote original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize)) font=\(pointSize)")
}
return
}
if isCoverAttachment(attachment) {
let maxSize = maxImageSize ?? defaultMaxImageSize(fontPointSize: pointSize)
if originalSize.width > 0, originalSize.height > 0 {
let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height)
attachment.displaySize = CGSize(
width: round(originalSize.width * scale),
height: round(originalSize.height * scale)
)
} else {
attachment.displaySize = maxSize
}
attachment.verticalAlignment = .baseline
if !didLogCoverAttachment {
didLogCoverAttachment = true
print("[EPUB][Attachment] cover original=\(RDEPUBHTMLNormalizer.string(from: originalSize)) display=\(RDEPUBHTMLNormalizer.string(from: attachment.displaySize))")
}
return
}
var resolvedSize = attachment.displaySize
if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
resolvedSize = originalSize
}
if resolvedSize.width <= 0 || resolvedSize.height <= 0 {
resolvedSize = CGSize(width: pointSize, height: pointSize)
}
if let maxImageSize,
resolvedSize.width > 0,
resolvedSize.height > 0,
(resolvedSize.width > maxImageSize.width || resolvedSize.height > maxImageSize.height) {
let scale = min(maxImageSize.width / resolvedSize.width, maxImageSize.height / resolvedSize.height)
resolvedSize = CGSize(
width: round(resolvedSize.width * scale),
height: round(resolvedSize.height * scale)
)
}
attachment.displaySize = CGSize(width: round(resolvedSize.width), height: round(resolvedSize.height))
attachment.verticalAlignment = .center
}
/// DTCoreText willFlushCallback
static func prepareHTMLElementForReaderRendering(
_ element: DTHTMLElement,
style: RDEPUBTextRenderStyle,
maxImageSize: CGSize? = nil
) {
guard let attachment = element.textAttachment else { return }
let pointSize = max(element.fontDescriptor.pointSize, style.font.pointSize)
let fallbackSize = CGSize(
width: defaultMaxImageSize(fontPointSize: pointSize).width,
height: defaultMaxImageSize(fontPointSize: pointSize).height
)
normalizeAttachmentLayoutForWXRead(
attachment,
fontPointSize: pointSize,
maxImageSize: maxImageSize ?? fallbackSize
)
if isFootnoteAttachment(attachment) {
element.displayStyle = .inline
} else if isCoverAttachment(attachment) {
element.displayStyle = .block
}
}
private static func isFootnoteAttachment(_ attachment: DTTextAttachment) -> Bool {
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
return lowercasedClasses.contains("qqreader-footnote") || lowercasedPath == "note.png"
}
private static func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath == "cover.jpg"
}
private static func defaultMaxImageSize(fontPointSize: CGFloat) -> CGSize {
let referenceViewport = CGSize(width: 375, height: 667)
let horizontalInset = max(round(fontPointSize), 16)
let verticalInset = max(round(fontPointSize * 1.5), 28)
return CGSize(
width: max(round(referenceViewport.width - horizontalInset * 2), 1),
height: max(round((referenceViewport.height - verticalInset * 2) * 0.85), 1)
)
}
#endif
// MARK: -
/// NSAttributedString
static func normalizeAttachmentDisplayIfNeeded(
in attributes: inout [NSAttributedString.Key: Any],
font: UIFont
) {
guard let attachment = attributes[.attachment] else { return }
#if canImport(DTCoreText)
if let textAttachment = attachment as? DTTextAttachment {
normalizeAttachmentLayoutForWXRead(textAttachment, fontPointSize: font.pointSize)
attributes[.attachment] = textAttachment
return
}
#endif
if let textAttachment = attachment as? NSTextAttachment, textAttachment.bounds.height <= 0 {
let targetHeight = max(round(font.pointSize * 0.86), 1)
textAttachment.bounds = CGRect(x: 0, y: 0, width: targetHeight, height: targetHeight)
attributes[.attachment] = textAttachment
}
}
///
static func attachmentKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentKind? {
if let attachment = attributes[.attachment] as? NSTextAttachment {
if attachment.image != nil || attachment.fileType?.lowercased().contains("image") == true {
return .image
}
return .generic
}
for value in attributes.values {
let typeName = String(describing: type(of: value)).lowercased()
if typeName.contains("attachment") {
return typeName.contains("image") ? .image : .generic
}
}
return nil
}
}

View File

@ -0,0 +1,89 @@
import UIKit
import CoreText
struct RDEPUBFontNormalizer {
/// CTFontManager
private static var registeredFontPaths = Set<String>()
func registerEmbeddedFonts(
html: String,
inlinedCSS: String,
input: RDEPUBTypesettingInput
) {
Self.registerEmbeddedFonts(
in: inlinedCSS + "\n" + Self.inlineStyleCSS(in: html),
chapterHref: input.href,
resourceResolver: input.resourceResolver
)
}
// MARK: -
/// CSS @font-face
static func registerEmbeddedFonts(
in css: String,
chapterHref: String,
resourceResolver: RDEPUBResourceResolver?
) {
guard let resourceResolver,
let faceRegex = try? NSRegularExpression(pattern: #"@font-face\s*\{([\s\S]*?)\}"#, options: [.caseInsensitive]),
let urlRegex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
return
}
let nsCSS = css as NSString
for faceMatch in faceRegex.matches(in: css, range: NSRange(location: 0, length: nsCSS.length)) {
guard faceMatch.numberOfRanges > 1 else { continue }
let block = nsCSS.substring(with: faceMatch.range(at: 1))
let nsBlock = block as NSString
for urlMatch in urlRegex.matches(in: block, range: NSRange(location: 0, length: nsBlock.length)) {
guard urlMatch.numberOfRanges > 1 else { continue }
let rawReference = nsBlock.substring(with: urlMatch.range(at: 1))
.trimmingCharacters(in: CharacterSet(charactersIn: "\"' \n\r\t"))
guard !rawReference.isEmpty,
!rawReference.hasPrefix("data:"),
!rawReference.hasPrefix("http://"),
!rawReference.hasPrefix("https://"),
let fileURL = resourceResolver.fileURL(forReference: rawReference, relativeToHref: chapterHref) else {
continue
}
registerFontIfNeeded(at: fileURL)
}
}
}
static func registerFontIfNeeded(at fileURL: URL) {
let standardizedPath = fileURL.standardizedFileURL.path
guard !registeredFontPaths.contains(standardizedPath) else { return }
CTFontManagerRegisterFontsForURL(fileURL as CFURL, .process, nil)
registeredFontPaths.insert(standardizedPath)
}
// MARK: -
/// EPUB /
static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
guard let sourceFont else {
return baseFont
}
let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic])
if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
return UIFont(descriptor: descriptor, size: baseFont.pointSize)
}
return baseFont
}
/// HTML <style> CSS
static func inlineStyleCSS(in html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"<style\b[^>]*>([\s\S]*?)</style>"#, options: [.caseInsensitive]) else {
return ""
}
let nsHTML = html as NSString
return regex.matches(in: html, range: NSRange(location: 0, length: nsHTML.length))
.compactMap { match in
guard match.numberOfRanges > 1 else { return nil }
return nsHTML.substring(with: match.range(at: 1))
}
.joined(separator: "\n")
}
}

View File

@ -0,0 +1,59 @@
import Foundation
struct RDEPUBFragmentMarkerInjector: RDEPUBTypesettingStage {
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
Self.injectFragmentMarkers(into: html)
}
func extractOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
Self.extractFragmentOffsets(from: attributedString)
}
// MARK: - Fragment
/// HTML id fragment
/// `<tag id="xxx" ...>` `${id=xxx}<tag id="xxx" ...>`
static func injectFragmentMarkers(into html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
return html
}
return regex.stringByReplacingMatches(
in: html,
options: [],
range: NSRange(location: 0, length: html.utf16.count),
withTemplate: "${id=$2}$1"
)
}
// MARK: - Fragment
/// fragment
/// `${id=xxx}`
static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
let markerPattern = #"\$\{id=([^}]+)\}"#
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
return [:]
}
let mutableString = NSMutableString(string: attributedString.string)
var fragmentOffsets: [String: Int] = [:]
var searchRange = NSRange(location: 0, length: mutableString.length)
var offsetAdjustment = 0
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
let fullMatch = mutableString.substring(with: match.range) as NSString
let fragmentID = fullMatch
.replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length))
.replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression)
let adjustedLocation = max(0, match.range.location + offsetAdjustment)
fragmentOffsets[fragmentID] = adjustedLocation
attributedString.deleteCharacters(in: match.range)
mutableString.deleteCharacters(in: match.range)
offsetAdjustment -= match.range.length
searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location)
}
return fragmentOffsets
}
}

View File

@ -0,0 +1,214 @@
import Foundation
struct RDEPUBHTMLNormalizer: RDEPUBTypesettingStage {
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
Self.normalizeHTML(html)
}
// MARK: - HTML
/// CR HTML
static func normalizeHTML(_ html: String) -> String {
var cleanedHTML = html
let replacements: [(pattern: String, template: String)] = [
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
(#"\r"#, "\n"),
(#"\n+"#, "\n")
]
for replacement in replacements {
if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) {
cleanedHTML = regex.stringByReplacingMatches(
in: cleanedHTML,
options: [],
range: NSRange(location: 0, length: cleanedHTML.utf16.count),
withTemplate: replacement.template
)
}
}
cleanedHTML = normalizeAttachmentHTMLMarkers(in: cleanedHTML)
return cleanedHTML
}
// MARK: - HTML
/// bodyPic div img h1+img HTML
private static func normalizeAttachmentHTMLMarkers(in html: String) -> String {
var normalized = html
if let bodyPicContainerRegex = try? NSRegularExpression(
pattern: #"<div\b([^>]*class\s*=\s*["'][^"']*\b(?:qrbodyPic|bodyPic)\b[^"']*["'][^>]*)>([\s\S]*?)</div>"#,
options: [.caseInsensitive]
) {
normalized = replaceMatches(
using: bodyPicContainerRegex,
in: normalized
) { tag in
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, options: [.caseInsensitive]) else {
return tag
}
return replaceMatches(
using: imageTagRegex,
in: tag
) { imageTag in
mergeHTMLAttributes(
into: imageTag,
requiredClass: "bodyPic",
styleFragments: [
"wr-vertical-center-style:2",
"max-width:100%",
"height:auto",
"display:block",
"margin-left:auto",
"margin-right:auto"
]
)
}
}
}
if let footnoteRegex = try? NSRegularExpression(
pattern: #"<img\b([^>]*class\s*=\s*["'][^"']*\bqqreader-footnote\b[^"']*["'][^>]*)>"#,
options: [.caseInsensitive]
) {
normalized = replaceMatches(
using: footnoteRegex,
in: normalized
) { tag in
mergeHTMLAttributes(
into: tag,
requiredClass: nil,
styleFragments: [
"width:1em",
"height:1em",
"vertical-align:middle",
"display:inline-block"
]
)
}
}
if let coverRegex = try? NSRegularExpression(
pattern: #"<h1\b([^>]*class\s*=\s*["'][^"']*\bfrontCover\b[^"']*["'][^>]*)>\s*(<img\b[^>]*>)\s*</h1>"#,
options: [.caseInsensitive]
) {
normalized = replaceMatches(
using: coverRegex,
in: normalized
) { tag in
guard let imageTagRegex = try? NSRegularExpression(pattern: #"<img\b[^>]*>"#, options: [.caseInsensitive]),
let imageMatch = imageTagRegex.firstMatch(
in: tag,
options: [],
range: NSRange(location: 0, length: (tag as NSString).length)
),
let imageRange = Range(imageMatch.range, in: tag) else {
return tag
}
let imageTag = String(tag[imageRange])
let normalizedImageTag = mergeHTMLAttributes(
into: imageTag,
requiredClass: "rd-front-cover-image",
styleFragments: [
"display:block",
"width:100%",
"height:auto",
"margin-left:auto",
"margin-right:auto"
]
)
return tag.replacingCharacters(in: imageRange, with: normalizedImageTag)
}
}
return normalized
}
// MARK: - HTML
/// transform
static func replaceMatches(
using regex: NSRegularExpression,
in source: String,
transform: (String) -> String
) -> String {
let nsSource = source as NSString
let matches = regex.matches(in: source, options: [], range: NSRange(location: 0, length: nsSource.length))
guard !matches.isEmpty else { return source }
var rewritten = source
for match in matches.reversed() {
guard let range = Range(match.range, in: rewritten) else { continue }
let original = String(rewritten[range])
rewritten.replaceSubrange(range, with: transform(original))
}
return rewritten
}
/// HTML class style
static func mergeHTMLAttributes(
into tag: String,
requiredClass: String?,
styleFragments: [String]
) -> String {
var rewritten = tag
if let requiredClass {
if let classRegex = try? NSRegularExpression(pattern: #"class\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]),
let match = classRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)),
match.numberOfRanges > 1 {
let existingClasses = (rewritten as NSString).substring(with: match.range(at: 1))
if !existingClasses.localizedCaseInsensitiveContains(requiredClass) {
let replacement = #"class="\#(existingClasses) \#(requiredClass)""#
if let range = Range(match.range, in: rewritten) {
rewritten.replaceSubrange(range, with: replacement)
}
}
} else if let closing = rewritten.lastIndex(of: ">") {
rewritten.insert(contentsOf: #" class="\#(requiredClass)""#, at: closing)
}
}
let styleValue = styleFragments.joined(separator: ";") + ";"
if let styleRegex = try? NSRegularExpression(pattern: #"style\s*=\s*["']([^"']*)["']"#, options: [.caseInsensitive]),
let match = styleRegex.firstMatch(in: rewritten, options: [], range: NSRange(location: 0, length: (rewritten as NSString).length)),
match.numberOfRanges > 1 {
let existing = (rewritten as NSString).substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines)
let merged = existing.isEmpty ? styleValue : existing + (existing.hasSuffix(";") ? "" : ";") + styleValue
let replacement = #"style="\#(merged)""#
if let range = Range(match.range, in: rewritten) {
rewritten.replaceSubrange(range, with: replacement)
}
} else if let closing = rewritten.lastIndex(of: ">") {
rewritten.insert(contentsOf: #" style="\#(styleValue)""#, at: closing)
}
return rewritten
}
/// `<base>`
static func injectBaseHref(into html: String, baseURL: URL?) -> String {
guard let baseURL else {
return html
}
let baseTag = "<base href=\"\(baseURL.absoluteString)\">"
if html.range(of: "<base ", options: [.caseInsensitive]) != nil {
return html
}
if html.range(of: "<head>", options: [.caseInsensitive]) != nil {
return html.replacingOccurrences(of: "<head>", with: "<head>\n\(baseTag)", options: [.caseInsensitive])
}
if let htmlTagRange = html.range(of: "<html", options: [.caseInsensitive]),
let htmlRange = html.range(of: ">", range: htmlTagRange.lowerBound..<html.endIndex) {
return html.replacingCharacters(in: htmlRange.upperBound..<htmlRange.upperBound, with: "\n<head>\n\(baseTag)\n</head>")
}
return "<head>\n\(baseTag)\n</head>\n" + html
}
/// CGSize
static func string(from size: CGSize) -> String {
"{\(Int(round(size.width))), \(Int(round(size.height)))}"
}
}

View File

@ -0,0 +1,162 @@
import Foundation
struct RDEPUBRenderDiagnosticsCollector {
///
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
///
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
func collect(
in html: String,
input: RDEPUBTypesettingInput
) -> [RDEPUBTextResourceReferenceDiagnostic] {
Self.collectImageDiagnostics(
in: html,
chapterHref: input.href,
baseURL: input.baseURL,
resourceResolver: input.resourceResolver
)
}
// MARK: -
/// HTML <img>
static func collectImageDiagnostics(
in html: String,
chapterHref: String,
baseURL: URL?,
resourceResolver: RDEPUBResourceResolver?
) -> [RDEPUBTextResourceReferenceDiagnostic] {
guard let regex = try? NSRegularExpression(pattern: imageSourcePattern, options: [.caseInsensitive]) else {
return []
}
let nsHTML = html as NSString
return regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length)).compactMap { match in
guard match.numberOfRanges > 1 else { return nil }
let href = nsHTML.substring(with: match.range(at: 1))
return resolveReference(
href,
kind: .image,
chapterHref: chapterHref,
baseURL: baseURL,
resourceResolver: resourceResolver
).diagnostic
}
}
// MARK: -
/// `<link rel=stylesheet>` CSS
static func inlineLinkedStyleSheets(
in html: String,
chapterHref: String,
baseURL: URL?,
resourceResolver: RDEPUBResourceResolver?
) -> (html: String, inlinedCSS: String, diagnostics: [RDEPUBTextResourceReferenceDiagnostic]) {
guard let regex = try? NSRegularExpression(pattern: stylesheetLinkPattern, options: [.caseInsensitive]) else {
return (html, "", [])
}
let nsHTML = html as NSString
let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length))
guard !matches.isEmpty else {
return (html, "", [])
}
var rewrittenHTML = html
var inlinedCSSBlocks: [String] = []
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic] = []
for match in matches.reversed() {
guard match.numberOfRanges > 1 else { continue }
let href = nsHTML.substring(with: match.range(at: 1))
let resolution = resolveReference(
href,
kind: .stylesheet,
chapterHref: chapterHref,
baseURL: baseURL,
resourceResolver: resourceResolver
)
diagnostics.append(resolution.diagnostic)
if let fileURL = resolution.resolvedFileURL,
let css = try? String(contentsOf: fileURL),
resolution.diagnostic.existsOnDisk {
let cssWithResolvedURLs = rewriteCSSResourceURLs(
in: css,
styleSheetFileURL: fileURL
)
inlinedCSSBlocks.append(cssWithResolvedURLs)
}
if let range = Range(match.range, in: rewrittenHTML) {
rewrittenHTML.replaceSubrange(range, with: "")
}
}
return (rewrittenHTML, inlinedCSSBlocks.reversed().joined(separator: "\n\n"), diagnostics.reversed())
}
// MARK: - CSS URL
/// CSS url()
static func rewriteCSSResourceURLs(
in css: String,
styleSheetFileURL: URL
) -> String {
guard let regex = try? NSRegularExpression(pattern: #"url\(([^)]+)\)"#, options: [.caseInsensitive]) else {
return css
}
let nsCSS = css as NSString
let matches = regex.matches(in: css, options: [], range: NSRange(location: 0, length: nsCSS.length))
guard !matches.isEmpty else {
return css
}
var rewrittenCSS = css
for match in matches.reversed() {
guard match.numberOfRanges > 1 else { continue }
let rawValue = nsCSS.substring(with: match.range(at: 1))
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
guard !rawValue.isEmpty else { continue }
if rawValue.hasPrefix("data:") || rawValue.hasPrefix("http://") || rawValue.hasPrefix("https://") || rawValue.hasPrefix("file://") || rawValue.hasPrefix("#") {
continue
}
guard let resolvedURL = URL(string: rawValue, relativeTo: styleSheetFileURL.deletingLastPathComponent())?.standardizedFileURL else {
continue
}
let replacement = "url(\"\(resolvedURL.absoluteString)\")"
if let range = Range(match.range, in: rewrittenCSS) {
rewrittenCSS.replaceSubrange(range, with: replacement)
}
}
return rewrittenCSS
}
// MARK: -
static func resolveReference(
_ reference: String,
kind: RDEPUBTextResourceReferenceKind,
chapterHref: String,
baseURL: URL?,
resourceResolver: RDEPUBResourceResolver?
) -> (normalizedHref: String?, resolvedFileURL: URL?, diagnostic: RDEPUBTextResourceReferenceDiagnostic) {
let trimmedReference = reference.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedHref = resourceResolver?.normalizedHref(trimmedReference, relativeToHref: chapterHref)
let resolvedFileURL = resourceResolver?.fileURL(forReference: trimmedReference, relativeToHref: chapterHref)
?? URL(string: trimmedReference, relativeTo: baseURL)?.standardizedFileURL
let existsOnDisk = resolvedFileURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false
let diagnostic = RDEPUBTextResourceReferenceDiagnostic(
kind: kind,
chapterHref: chapterHref,
originalReference: trimmedReference,
normalizedHref: normalizedHref,
resolvedFileURL: resolvedFileURL,
existsOnDisk: existsOnDisk
)
return (normalizedHref, resolvedFileURL, diagnostic)
}
}

View File

@ -0,0 +1,332 @@
import Foundation
import UIKit
struct RDEPUBSemanticMarkerInjector: RDEPUBTypesettingStage {
/// ${rd-sem-start:...} / ${rd-sem-end:...}
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
func process(_ html: String, context: RDEPUBTypesettingInput) -> String {
Self.injectPaginationSemanticMarkers(into: html)
}
func apply(to attributedString: NSMutableAttributedString) {
Self.applyPaginationSemantics(in: attributedString)
}
// MARK: - HTML
/// HTML ${rd-sem-start/end}
static func injectPaginationSemanticMarkers(into html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"<[^>]+>"#, options: [.caseInsensitive]) else {
return html
}
let nsHTML = html as NSString
let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsHTML.length))
guard !matches.isEmpty else {
return html
}
var output = ""
var cursor = 0
var openTagStack: [(name: String, id: String)] = []
var nextMarkerID = 0
for match in matches {
let tagRange = match.range
guard tagRange.location >= cursor else { continue }
output += nsHTML.substring(with: NSRange(location: cursor, length: tagRange.location - cursor))
let tag = nsHTML.substring(with: tagRange)
let loweredTag = tag.lowercased()
let tagName = htmlTagName(from: loweredTag)
if loweredTag.hasPrefix("</"), let tagName {
if let index = openTagStack.lastIndex(where: { $0.name == tagName }) {
let markerID = openTagStack.remove(at: index).id
output += semanticEndMarker(id: markerID)
}
output += tag
} else if let tagName,
let semantics = paginationSemantics(forTagName: tagName, rawTag: tag) {
nextMarkerID += 1
let markerID = String(nextMarkerID)
let startMarker = semanticStartMarker(id: markerID, semantics: semantics)
if isVoidHTMLTag(tagName) || loweredTag.hasSuffix("/>") {
output += startMarker + tag + semanticEndMarker(id: markerID)
} else {
openTagStack.append((name: tagName, id: markerID))
output += tag + startMarker
}
} else {
output += tag
}
cursor = tagRange.location + tagRange.length
}
output += nsHTML.substring(from: cursor)
return output
}
// MARK: -
/// HTML NSAttributedString
static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
return
}
let mutableString = NSMutableString(string: attributedString.string)
var searchRange = NSRange(location: 0, length: mutableString.length)
var openRanges: [String: (location: Int, semantics: RDPaginationSemantics)] = [:]
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
let kind = mutableString.substring(with: match.range(at: 1))
let payload = mutableString.substring(with: match.range(at: 2))
let markerLocation = match.range.location
attributedString.deleteCharacters(in: match.range)
mutableString.deleteCharacters(in: match.range)
if kind == "start" {
let semantics = parseSemanticMarkerPayload(payload)
openRanges[semantics.id] = (markerLocation, semantics)
} else {
let markerID = parseSemanticEndID(payload)
if let markerID, let opened = openRanges.removeValue(forKey: markerID) {
let length = max(markerLocation - opened.location, 0)
if length > 0 {
apply(semantics: opened.semantics, to: NSRange(location: opened.location, length: length), in: attributedString)
}
}
}
searchRange = NSRange(location: markerLocation, length: mutableString.length - markerLocation)
}
}
// MARK: -
static func htmlTagName(from loweredTag: String) -> String? {
let trimmed = loweredTag.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("<") else { return nil }
let body = trimmed.dropFirst().drop(while: { $0 == "/" || $0 == "!" || $0 == "?" })
let name = body.prefix { $0.isLetter || $0.isNumber }
return name.isEmpty ? nil : String(name)
}
private static func paginationSemantics(forTagName tagName: String, rawTag: String) -> RDPaginationSemantics? {
let loweredTag = rawTag.lowercased()
let blockKind = inferredBlockKind(forTagName: tagName, rawTag: loweredTag)
let hints = inferredHints(forTagName: tagName, rawTag: loweredTag)
let placement = inferredAttachmentPlacement(forTagName: tagName, rawTag: loweredTag)
guard blockKind != nil || !hints.isEmpty || placement != nil else {
return nil
}
return RDPaginationSemantics(
id: "",
blockKind: blockKind,
hints: hints,
attachmentPlacement: placement
)
}
private static func inferredBlockKind(forTagName tagName: String, rawTag: String) -> RDEPUBTextBlockKind? {
if rawTag.contains("bodypic") || tagName == "img" || tagName == "figure" {
return .attachment
}
switch tagName {
case "h1", "h2", "h3", "h4", "h5", "h6":
return .generic
case "blockquote":
return .blockquote
case "ul", "ol", "li":
return .list
case "table", "thead", "tbody", "tfoot", "tr", "td", "th":
return .table
case "pre", "code":
return .code
case "p":
return .paragraph
case "div":
if rawTag.contains("code") || rawTag.contains("highlight") {
return .code
}
if rawTag.contains("quote") || rawTag.contains("blockquote") {
return .blockquote
}
if rawTag.contains("table") {
return .table
}
if rawTag.contains("list") {
return .list
}
return .generic
default:
return nil
}
}
private static func inferredHints(forTagName tagName: String, rawTag: String) -> [RDEPUBTextSemanticHint] {
var hints: [RDEPUBTextSemanticHint] = []
if rawTag.contains("avoidpagebreakinside") ||
rawTag.contains("break-inside: avoid") ||
rawTag.contains("page-break-inside: avoid") ||
["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") {
hints.append(.pageBreakBefore)
}
if rawTag.contains("pagebreakafter") ||
rawTag.contains("page-break-after: always") ||
rawTag.contains("break-after: page") {
hints.append(.pageBreakAfter)
}
if rawTag.contains("pageRelate".lowercased()) || rawTag.contains("weread-page-relate") {
hints.append(.pageRelate)
}
return hints
.reduce(into: [RDEPUBTextSemanticHint]()) { result, hint in
if !result.contains(hint) {
result.append(hint)
}
}
.sorted { $0.rawValue < $1.rawValue }
}
private static func inferredAttachmentPlacement(forTagName tagName: String, rawTag: String) -> RDEPUBTextAttachmentPlacement? {
guard tagName == "img" || rawTag.contains("bodypic") || rawTag.contains("wr-vertical-center") else {
return nil
}
if rawTag.contains("wr-vertical-center-style: 2") || rawTag.contains("bodypic") {
return .centered
}
if rawTag.contains("wr-vertical-center-style: 1") || rawTag.contains("wr-vertical-center") {
return .baseline
}
return .inline
}
private static func isVoidHTMLTag(_ tagName: String) -> Bool {
["img", "br", "hr", "input", "meta", "link"].contains(tagName)
}
private static func semanticStartMarker(id: String, semantics: RDPaginationSemantics) -> String {
var segments = ["id=\(id)"]
if let blockKind = semantics.blockKind {
segments.append("block=\(blockKind.rawValue)")
}
if !semantics.hints.isEmpty {
segments.append("hints=\(semantics.hints.map(\.rawValue).joined(separator: ","))")
}
if let placement = semantics.attachmentPlacement {
segments.append("placement=\(placement.rawValue)")
}
return "${rd-sem-start:\(segments.joined(separator: ";"))}"
}
private static func semanticEndMarker(id: String) -> String {
"${rd-sem-end:id=\(id)}"
}
private static func parseSemanticMarkerPayload(_ payload: String) -> RDPaginationSemantics {
var values: [String: String] = [:]
payload.split(separator: ";").forEach { entry in
let parts = entry.split(separator: "=", maxSplits: 1)
guard parts.count == 2 else { return }
values[String(parts[0])] = String(parts[1])
}
let blockKind = values["block"].flatMap(RDEPUBTextBlockKind.init(rawValue:))
let hints = values["hints"]?
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) } ?? []
let placement = values["placement"].flatMap(RDEPUBTextAttachmentPlacement.init(rawValue:))
return RDPaginationSemantics(
id: values["id"] ?? UUID().uuidString,
blockKind: blockKind,
hints: hints,
attachmentPlacement: placement
)
}
private static func parseSemanticEndID(_ payload: String) -> String? {
payload.split(separator: ";").first { $0.hasPrefix("id=") }.map { String($0.dropFirst(3)) }
}
private static func apply(
semantics: RDPaginationSemantics,
to range: NSRange,
in attributedString: NSMutableAttributedString
) {
var attributes: [NSAttributedString.Key: Any] = [:]
attributes[.rdPageBlockRange] = NSStringFromRange(range)
if let blockKind = semantics.blockKind {
attributes[.rdPageBlockKind] = blockKind.rawValue
}
if !semantics.hints.isEmpty {
attributes[.rdPageSemanticHints] = semantics.hints.map(\.rawValue).joined(separator: ",")
}
if let placement = semantics.attachmentPlacement {
attributes[.rdPageAttachmentPlacement] = placement.rawValue
}
guard !attributes.isEmpty else { return }
attributedString.addAttributes(attributes, range: range)
}
///
static func normalizeBlockKind(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextBlockKind? {
if let rawValue = attributes[.rdPageBlockKind] as? String,
let blockKind = RDEPUBTextBlockKind(rawValue: rawValue) {
return blockKind
}
return nil
}
///
static func normalizeSemanticHints(for attributes: [NSAttributedString.Key: Any]) -> [RDEPUBTextSemanticHint]? {
if let rawValue = attributes[.rdPageSemanticHints] as? String {
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
return hints.isEmpty ? nil : hints
}
return nil
}
///
static func normalizeAttachmentPlacement(for attributes: [NSAttributedString.Key: Any]) -> RDEPUBTextAttachmentPlacement? {
if let rawValue = attributes[.rdPageAttachmentPlacement] as? String,
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue) {
return placement
}
if let attachmentKind = RDEPUBAttachmentNormalizer.attachmentKind(for: attributes), attachmentKind == .image {
return .inline
}
return nil
}
// MARK: -
struct RDPaginationSemantics {
var id: String
var blockKind: RDEPUBTextBlockKind?
var hints: [RDEPUBTextSemanticHint]
var attachmentPlacement: RDEPUBTextAttachmentPlacement?
}
}

View File

@ -0,0 +1,288 @@
import UIKit
struct RDEPUBStyleSheetComposition {
var html: String
var layers: [RDEPUBTextStyleSheetLayer]
var inlinedCSS: String
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
}
struct RDEPUBStyleSheetComposer {
func compose(html: String, input: RDEPUBTypesettingInput) -> RDEPUBStyleSheetComposition {
let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
in: html,
chapterHref: input.href,
baseURL: input.baseURL,
resourceResolver: input.resourceResolver
)
let layers = Self.makeStyleSheetLayers(
style: input.style,
epubCSS: stylesheetHrefReplacements.inlinedCSS,
contentLanguageCode: input.contentLanguageCode,
sourceHTML: input.rawHTML
)
let htmlWithBase = RDEPUBHTMLNormalizer.injectBaseHref(
into: stylesheetHrefReplacements.html,
baseURL: input.baseURL
)
let htmlWithDefaultLayers = Self.injectStyleTag(
into: htmlWithBase,
styleID: "rd-native-default-replace-dark",
css: layers
.filter { $0.kind != .user && $0.kind != .epub }
.map(\.css)
.joined(separator: "\n\n"),
position: .headStart
)
let htmlWithEPUBLayer = Self.injectStyleTag(
into: htmlWithDefaultLayers,
styleID: "rd-native-epub",
css: layers.first(where: { $0.kind == .epub })?.css ?? "",
position: .headEnd
)
let composedHTML = Self.injectStyleTag(
into: htmlWithEPUBLayer,
styleID: "rd-native-user",
css: layers.first(where: { $0.kind == .user })?.css ?? "",
position: .headEnd
)
return RDEPUBStyleSheetComposition(
html: composedHTML,
layers: layers,
inlinedCSS: stylesheetHrefReplacements.inlinedCSS,
diagnostics: stylesheetHrefReplacements.diagnostics
)
}
// MARK: - CSS
/// CSS default/replace/dark/epub/user
static func makeStyleSheetLayers(
style: RDEPUBTextRenderStyle,
epubCSS: String,
contentLanguageCode: String?,
sourceHTML: String
) -> [RDEPUBTextStyleSheetLayer] {
let useLatinReplace = prefersLatinLanguageCSS(
languageCode: contentLanguageCode,
sourceHTML: sourceHTML
)
var layers: [RDEPUBTextStyleSheetLayer] = [
.init(kind: .default, css: defaultCSS()),
.init(kind: .replace, css: replaceCSS(useLatinVariant: useLatinReplace))
]
if isDarkTheme(style: style) {
layers.append(.init(kind: .dark, css: darkCSS(style: style)))
}
if !epubCSS.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
layers.append(.init(kind: .epub, css: epubCSS))
}
layers.append(.init(kind: .user, css: userCSS(style: style)))
return layers
}
// MARK: - Style
enum StyleInjectionPosition {
case headStart
case headEnd
}
/// HTML <style>
static func injectStyleTag(
into html: String,
styleID: String,
css: String,
position: StyleInjectionPosition
) -> String {
let trimmedCSS = css.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedCSS.isEmpty else {
return html
}
let styleTag = "<style id=\"\(styleID)\">\n\(trimmedCSS)\n</style>"
switch position {
case .headStart:
if html.range(of: "<head>", options: [.caseInsensitive]) != nil {
return html.replacingOccurrences(of: "<head>", with: "<head>\n\(styleTag)", options: [.caseInsensitive])
}
case .headEnd:
if html.range(of: "</head>", options: [.caseInsensitive]) != nil {
return html.replacingOccurrences(of: "</head>", with: "\(styleTag)\n</head>", options: [.caseInsensitive])
}
}
if html.range(of: "<body", options: [.caseInsensitive]) != nil {
return html.replacingOccurrences(of: "<body", with: "\(styleTag)\n<body", options: [.caseInsensitive])
}
return styleTag + "\n" + html
}
// MARK: - CSS
private static func defaultCSS() -> String {
RDEPUBAssetRepository.string(for: .wxReadDefaultCSS)
}
private static func replaceCSS(useLatinVariant: Bool) -> String {
let asset: RDEPUBAsset = useLatinVariant ? .wxReadLatinReplaceCSS : .wxReadReplaceCSS
return RDEPUBAssetRepository.string(for: asset)
}
private static func darkCSS(style: RDEPUBTextRenderStyle) -> String {
let background = style.backgroundColor?.ss_cssString ?? "rgba(0, 0, 0, 1.000)"
let text = style.textColor?.ss_cssString ?? "rgba(255, 255, 255, 1.000)"
return RDEPUBAssetRepository.string(for: .wxReadDarkCSS) + "\n\n" + """
html, body {
background: \(background) !important;
color: \(text) !important;
}
a {
color: \(text) !important;
}
"""
}
private static func userCSS(style: RDEPUBTextRenderStyle) -> String {
let lineHeight = max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1)
let text = style.textColor.map { "color: \($0.ss_cssString) !important;" } ?? ""
let background = style.backgroundColor.map { "background: \($0.ss_cssString) !important;" } ?? ""
return """
html, body {
font-family: "\(style.font.familyName)" !important;
font-size: \(String(format: "%.3f", style.font.pointSize))px !important;
line-height: \(String(format: "%.3f", lineHeight)) !important;
\(text)
\(background)
}
"""
}
private static func isDarkTheme(style: RDEPUBTextRenderStyle) -> Bool {
guard let backgroundColor = style.backgroundColor else {
return false
}
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
backgroundColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue)
return luminance < 0.5
}
// MARK: -
private static func prefersLatinLanguageCSS(
languageCode: String?,
sourceHTML: String
) -> Bool {
let candidateCodes = inferredLanguageCodes(
explicitLanguageCode: languageCode,
sourceHTML: sourceHTML
)
if candidateCodes.contains(where: isExplicitLatinLanguageCode) {
return true
}
if candidateCodes.contains(where: isExplicitCJKLanguageCode) {
return false
}
let textSample = plainTextSample(from: sourceHTML)
guard !textSample.isEmpty else { return false }
var alphabeticCount = 0
var latinCount = 0
for scalar in textSample.unicodeScalars {
guard CharacterSet.letters.contains(scalar) else { continue }
alphabeticCount += 1
if isLatinScalar(scalar) {
latinCount += 1
}
}
guard alphabeticCount >= 80 else { return false }
return (Double(latinCount) / Double(alphabeticCount)) >= 0.6
}
private static func inferredLanguageCodes(
explicitLanguageCode: String?,
sourceHTML: String
) -> [String] {
var codes: [String] = []
if let explicitLanguageCode {
let normalized = explicitLanguageCode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if !normalized.isEmpty {
codes.append(normalized)
}
}
if let regex = try? NSRegularExpression(
pattern: #"\b(?:xml:lang|lang)\s*=\s*["']([^"']+)["']"#,
options: [.caseInsensitive]
) {
let nsHTML = sourceHTML as NSString
let range = NSRange(location: 0, length: min(nsHTML.length, 8_000))
for match in regex.matches(in: sourceHTML, options: [], range: range) {
guard match.numberOfRanges > 1 else { continue }
let code = nsHTML.substring(with: match.range(at: 1))
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if !code.isEmpty {
codes.append(code)
}
}
}
return Array(NSOrderedSet(array: codes)) as? [String] ?? codes
}
private static func isExplicitLatinLanguageCode(_ code: String) -> Bool {
let normalized = code.lowercased()
if normalized.contains("latn") {
return true
}
let prefix = normalized.split(separator: "-").first.map(String.init) ?? normalized
let latinPrefixes: Set<String> = [
"en", "fr", "de", "es", "it", "pt", "nl", "sv", "da", "no", "fi",
"is", "ga", "cy", "pl", "cs", "sk", "sl", "hr", "hu", "ro", "tr",
"vi", "id", "ms", "tl", "sw", "af", "sq", "et", "lv", "lt"
]
return latinPrefixes.contains(prefix)
}
private static func isExplicitCJKLanguageCode(_ code: String) -> Bool {
let prefix = code.lowercased().split(separator: "-").first.map(String.init) ?? code.lowercased()
return ["zh", "ja", "ko"].contains(prefix)
}
private static func plainTextSample(from html: String) -> String {
let maxLength = min(html.count, 20_000)
let sample = String(html.prefix(maxLength))
let withoutTags = sample.replacingOccurrences(
of: #"<[^>]+>"#,
with: " ",
options: .regularExpression
)
return withoutTags.replacingOccurrences(
of: #"&[A-Za-z0-9#]+;"#,
with: " ",
options: .regularExpression
)
}
private static func isLatinScalar(_ scalar: UnicodeScalar) -> Bool {
switch scalar.value {
case 0x0041...0x007A,
0x00C0...0x00FF,
0x0100...0x024F,
0x1E00...0x1EFF:
return true
default:
return false
}
}
}

View File

@ -0,0 +1,167 @@
import UIKit
import CoreText
#if canImport(DTCoreText)
import DTCoreText
#endif
/// Facade stage HTML
///
/// 线 stage
/// `RDEPUBDTCoreTextRenderer` stage
enum RDEPUBTextRendererSupport {
// MARK: - 线
/// HTML
///
///
/// 1. HTMLNormalizer.normalizeHTML
/// 2. SemanticMarkerInjector.injectPaginationSemanticMarkers
/// 3. DiagnosticsCollector.inlineLinkedStyleSheets
/// 4. StyleSheetComposer.makeStyleSheetLayers + injectStyleTag
/// 5. FontNormalizer.registerEmbeddedFonts
/// 6. HTMLNormalizer.injectBaseHref
/// 7. FragmentMarkerInjector.injectFragmentMarkers
/// 8. DiagnosticsCollector.collectImageDiagnostics
static func makeChapterRenderRequest(
href: String,
title: String,
rawHTML: String,
baseURL: URL?,
style: RDEPUBTextRenderStyle,
resourceResolver: RDEPUBResourceResolver?,
contentLanguageCode: String? = nil,
pageSize: CGSize? = nil,
layoutConfig: RDEPUBTextLayoutConfig? = nil
) -> RDEPUBTextChapterRenderRequest {
let normalizedHTML = RDEPUBSemanticMarkerInjector.injectPaginationSemanticMarkers(
into: RDEPUBHTMLNormalizer.normalizeHTML(rawHTML)
)
let stylesheetHrefReplacements = RDEPUBRenderDiagnosticsCollector.inlineLinkedStyleSheets(
in: normalizedHTML,
chapterHref: href,
baseURL: baseURL,
resourceResolver: resourceResolver
)
let layers = RDEPUBStyleSheetComposer.makeStyleSheetLayers(
style: style,
epubCSS: stylesheetHrefReplacements.inlinedCSS,
contentLanguageCode: contentLanguageCode,
sourceHTML: rawHTML
)
RDEPUBFontNormalizer.registerEmbeddedFonts(
in: stylesheetHrefReplacements.inlinedCSS + "\n" + RDEPUBFontNormalizer.inlineStyleCSS(in: normalizedHTML),
chapterHref: href,
resourceResolver: resourceResolver
)
let htmlWithBase = RDEPUBHTMLNormalizer.injectBaseHref(into: stylesheetHrefReplacements.html, baseURL: baseURL)
let htmlWithDefaultLayers = RDEPUBStyleSheetComposer.injectStyleTag(
into: htmlWithBase,
styleID: "rd-native-default-replace-dark",
css: layers
.filter { $0.kind != .user && $0.kind != .epub }
.map(\.css)
.joined(separator: "\n\n"),
position: .headStart
)
let htmlWithEPUBLayer = RDEPUBStyleSheetComposer.injectStyleTag(
into: htmlWithDefaultLayers,
styleID: "rd-native-epub",
css: layers.first(where: { $0.kind == .epub })?.css ?? "",
position: .headEnd
)
let composedHTML = RDEPUBStyleSheetComposer.injectStyleTag(
into: htmlWithEPUBLayer,
styleID: "rd-native-user",
css: layers.first(where: { $0.kind == .user })?.css ?? "",
position: .headEnd
)
let markedHTML = RDEPUBFragmentMarkerInjector.injectFragmentMarkers(into: composedHTML)
let resourceDiagnostics = stylesheetHrefReplacements.diagnostics + RDEPUBRenderDiagnosticsCollector.collectImageDiagnostics(
in: markedHTML,
chapterHref: href,
baseURL: baseURL,
resourceResolver: resourceResolver
)
let context = RDEPUBTextChapterContext(
href: href,
title: title,
html: markedHTML,
baseURL: baseURL,
stylesheet: RDEPUBTextStyleSheetPackage(layers: layers),
resourceDiagnostics: resourceDiagnostics
)
return RDEPUBTextChapterRenderRequest(
context: context,
style: style,
pageSize: pageSize,
layoutConfig: layoutConfig
)
}
// MARK: - RDEPUBDTCoreTextRenderer
///
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
let fullRange = NSRange(location: 0, length: attributedString.length)
var blockIndex = 0
let sourceText = attributedString.string as NSString
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
let sourceFont = attributes[.font] as? UIFont
let normalizedFont = RDEPUBFontNormalizer.normalizedFont(from: sourceFont, baseFont: style.font)
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
paragraph.lineSpacing = style.lineSpacing
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
var updatedAttributes = attributes
updatedAttributes[.font] = normalizedFont
updatedAttributes[.paragraphStyle] = paragraph
if let textColor = style.textColor {
updatedAttributes[.foregroundColor] = textColor
}
RDEPUBAttachmentNormalizer.normalizeAttachmentDisplayIfNeeded(in: &updatedAttributes, font: normalizedFont)
let semanticBlockRange = (attributes[.rdPageBlockRange] as? String)
.flatMap(NSRangeFromString)
.flatMap { $0.length > 0 ? $0 : nil }
let paragraphRange = sourceText.length > 0
? sourceText.paragraphRange(for: NSRange(location: min(range.location, max(sourceText.length - 1, 0)), length: 0))
: range
updatedAttributes[.rdPageBlockRange] = NSStringFromRange(semanticBlockRange ?? paragraphRange)
updatedAttributes[.rdPageBlockIndex] = blockIndex
if let attachmentKind = RDEPUBAttachmentNormalizer.attachmentKind(for: attributes) {
updatedAttributes[.rdPageAttachmentKind] = attachmentKind.rawValue
}
if let placement = RDEPUBSemanticMarkerInjector.normalizeAttachmentPlacement(for: attributes) {
updatedAttributes[.rdPageAttachmentPlacement] = placement.rawValue
}
if let blockKind = RDEPUBSemanticMarkerInjector.normalizeBlockKind(for: attributes) {
updatedAttributes[.rdPageBlockKind] = blockKind.rawValue
}
if let hints = RDEPUBSemanticMarkerInjector.normalizeSemanticHints(for: attributes), !hints.isEmpty {
updatedAttributes[.rdPageSemanticHints] = hints.map(\.rawValue).joined(separator: ",")
}
attributedString.setAttributes(updatedAttributes, range: range)
blockIndex += 1
}
}
/// 退 DTCoreText HTML
static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
let fallbackAttributes: [NSAttributedString.Key: Any] = [
.font: style.font,
.paragraphStyle: paragraphStyle(lineSpacing: style.lineSpacing),
.foregroundColor: style.textColor ?? UIColor.black
]
return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
}
///
static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle {
let style = NSMutableParagraphStyle()
style.lineSpacing = lineSpacing
style.paragraphSpacing = max(6, lineSpacing / 2)
return style
}
}

View File

@ -0,0 +1,65 @@
import UIKit
struct RDEPUBTypesettingInput {
var href: String
var title: String
var rawHTML: String
var baseURL: URL?
var style: RDEPUBTextRenderStyle
var resourceResolver: RDEPUBResourceResolver?
var contentLanguageCode: String?
var pageSize: CGSize?
var layoutConfig: RDEPUBTextLayoutConfig?
}
struct RDEPUBTypesettingOutput {
var request: RDEPUBTextChapterRenderRequest
var diagnostics: [RDEPUBTextResourceReferenceDiagnostic]
}
protocol RDEPUBTypesettingStage {
func process(_ html: String, context: RDEPUBTypesettingInput) -> String
}
struct RDEPUBTextTypesetterPipeline {
func makeRequest(from input: RDEPUBTypesettingInput) -> RDEPUBTypesettingOutput {
let htmlNormalizer = RDEPUBHTMLNormalizer()
let semanticMarkerInjector = RDEPUBSemanticMarkerInjector()
let styleSheetComposer = RDEPUBStyleSheetComposer()
let fontNormalizer = RDEPUBFontNormalizer()
let fragmentMarkerInjector = RDEPUBFragmentMarkerInjector()
let diagnosticsCollector = RDEPUBRenderDiagnosticsCollector()
let normalizedHTML = semanticMarkerInjector.process(
htmlNormalizer.process(input.rawHTML, context: input),
context: input
)
let styleSheetComposition = styleSheetComposer.compose(html: normalizedHTML, input: input)
fontNormalizer.registerEmbeddedFonts(
html: normalizedHTML,
inlinedCSS: styleSheetComposition.inlinedCSS,
input: input
)
let markedHTML = fragmentMarkerInjector.process(styleSheetComposition.html, context: input)
let diagnostics = styleSheetComposition.diagnostics + diagnosticsCollector.collect(in: markedHTML, input: input)
let context = RDEPUBTextChapterContext(
href: input.href,
title: input.title,
html: markedHTML,
baseURL: input.baseURL,
stylesheet: RDEPUBTextStyleSheetPackage(layers: styleSheetComposition.layers),
resourceDiagnostics: diagnostics
)
let request = RDEPUBTextChapterRenderRequest(
context: context,
style: input.style,
pageSize: input.pageSize,
layoutConfig: input.layoutConfig
)
return RDEPUBTypesettingOutput(
request: request,
diagnostics: diagnostics
)
}
}

View File

@ -0,0 +1,176 @@
import UIKit
// MARK: - Web EPUB /Web
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
guard readerView.currentPage >= 0,
activePages.indices.contains(readerView.currentPage) else {
return
}
let currentPage = activePages[readerView.currentPage]
guard readingSession?.pageContains(spineIndex: spineIndex, in: currentPage) == true else {
return
}
persist(location: location)
readingSession?.updateReadingContext(
pageNumber: readerView.currentPage + 1,
location: location,
spineIndex: spineIndex,
chapterIndex: currentPage.chapterIndex,
bookIdentifier: currentBookIdentifier
)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
if let selection {
updateCurrentSelection(scopedSelection(selection, relativeToSpineIndex: spineIndex))
} else {
updateCurrentSelection(nil)
}
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
handleSelectionMenuAction(action, selection: currentSelection)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
guard let readingSession,
let pageNumber = readingSession.queueNavigation(
to: location,
relativeToSpineIndex: fromSpineIndex,
bookIdentifier: currentBookIdentifier
) else {
return
}
readerView.transitionToPage(pageNum: max(pageNumber - 1, 0), animated: true)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL) {
delegate?.epubReader(self, didActivateExternalLink: url)
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String) {
print("EPUB JS Error: \(message)")
}
}
// MARK: - Native Text
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
guard let selection else {
updateCurrentSelection(nil)
return
}
updateCurrentSelection(normalizedTextSelection(selection))
}
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
handleSelectionMenuAction(action, selection: currentSelection)
contentView.clearSelection()
}
private func normalizedTextSelection(_ selection: RDEPUBSelection) -> RDEPUBSelection? {
guard let textBook,
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(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 absoluteRange = NSRange(location: start, length: endExclusive - start)
let location = chapterData.location(for: absoluteRange, bookIdentifier: currentBookIdentifier)
return RDEPUBSelection(
bookIdentifier: currentBookIdentifier,
location: location,
text: selection.text,
rangeInfo: selection.rangeInfo,
createdAt: selection.createdAt
)
}
func pageNumber(for location: RDEPUBLocation) -> Int? {
if let textBook, let publication {
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,
bookIdentifier: currentBookIdentifier
) ?? location
return textBook.pageNumber(
for: normalizedLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
}
return readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
)
}
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
guard let textBook,
let publication,
let page = textBook.page(at: pageNumber) else {
return nil
}
let location = textBook.chapterData(forPageNumber: pageNumber)?.location(forPage: page, bookIdentifier: currentBookIdentifier)
?? textBook.location(forPageNumber: pageNumber, bookIdentifier: currentBookIdentifier)
guard let location else { return nil }
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
guard let textBook,
let page = textBook.page(at: pageNumber) else {
readingSession?.transition(to: .idle)
return
}
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: page.spineIndex,
chapterIndex: page.chapterIndex,
bookIdentifier: currentBookIdentifier
)
}
func nativeTextSnapshot(from textBook: RDEPUBTextBook) -> RDEPUBNativeTextSnapshot {
let chapters = textBook.chapterInfos
let pages = textBook.pages.map {
EPUBPage(
spineIndex: $0.spineIndex,
chapterIndex: $0.chapterIndex,
pageIndexInChapter: $0.pageIndexInChapter,
totalPagesInChapter: $0.totalPagesInChapter,
chapterTitle: $0.chapterTitle,
fixedSpread: nil
)
}
return (pages, chapters)
}
}

View File

@ -0,0 +1,133 @@
import UIKit
// MARK: - RDReaderView
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
textBook?.pages.count ?? activePages.count
}
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
if let textBook, let page = textBook.page(at: pageNum + 1) {
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
contentView.configure(
page: page,
pageNumber: pageNum + 1,
totalPages: textBook.pages.count,
configuration: configuration,
highlights: textHighlights(for: page),
searchState: searchState
)
return contentView
}
guard let publication,
let request = request(for: pageNum) else {
return containerView ?? UIView()
}
let contentView = (containerView as? RDEPUBWebContentView) ?? RDEPUBWebContentView()
contentView.delegate = self
contentView.configure(
publication: publication,
request: request,
pageNumber: pageNum + 1,
totalPages: activePages.count,
theme: configuration.theme
)
return contentView
}
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
textBook == nil
? NSStringFromClass(RDEPUBWebContentView.self)
: NSStringFromClass(RDEPUBTextContentView.self)
}
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 }
}
let pageHref = publication.resourceResolver.normalizedHref(page.href) ?? page.href
return activeHighlights.filter {
(publication.resourceResolver.normalizedHref($0.location.href) ?? $0.location.href) == pageHref
}
}
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
readerContext.textChapterData(forNormalizedHref: href)
}
public func topToolView(readerView: RDReaderView) -> UIView? {
topToolView
}
public func bottomToolView(readerView: RDReaderView) -> UIView? {
bottomToolView
}
public func pageNum(readerView: RDReaderView, pageNum: Int) {
updateCurrentSelection(nil)
reconcileTextPaginationSizeIfNeeded(for: pageNum)
let totalPages = pageCountOfReaderView(readerView: readerView)
if totalPages > 0, pageNum == totalPages - 1 {
delegate?.epubReaderDidReachEnd(self)
}
if textBook != nil,
let location = resolvedTextLocation(forPageNumber: pageNum + 1) {
persist(location: location)
synchronizeTextReadingState(pageNumber: pageNum + 1, location: location)
return
}
if let location = fallbackLocation(for: pageNum) {
persist(location: location)
}
if readingSession?.navigatorState == .jumping || readingSession?.navigatorState == .moving {
readingSession?.transition(to: .idle)
}
}
public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) {
_ = isLandscape
runtime.viewportMonitor.capturePendingPresentationRestoreLocation()
}
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
guard textBook != nil,
!isRepaginating,
!isReconcilingTextPaginationSize,
pageNum >= 0,
let lastTextPaginationPageSize else {
return
}
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
guard resolvedSize.width > 0,
resolvedSize.height > 0 else {
return
}
let sizeChanged = abs(resolvedSize.width - lastTextPaginationPageSize.width) > 0.5
|| abs(resolvedSize.height - lastTextPaginationPageSize.height) > 0.5
guard sizeChanged else {
return
}
isReconcilingTextPaginationSize = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.isReconcilingTextPaginationSize = false
guard self.textBook != nil, !self.isRepaginating else { return }
self.repaginatePreservingCurrentLocation()
}
}
}

View File

@ -0,0 +1,176 @@
import UIKit
// MARK: - Public Reader Commands
extension RDEPUBReaderController {
/// EPUB
public func reloadBook() {
runtime.reloadBook()
}
///
/// - Parameter location:
public func go(to location: RDEPUBLocation) {
guard publication != nil else { return }
_ = runtime.go(to: location)
}
///
/// - Parameters:
/// - pageNumber: 1
/// - animated:
/// - Returns:
@discardableResult
public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
runtime.go(toPageNumber: pageNumber, animated: animated)
}
///
public func clearSelection() {
runtime.clearSelection()
}
public func bookmark(withID id: String) -> RDEPUBBookmark? {
runtime.bookmark(withID: id)
}
public func highlight(withID id: String) -> RDEPUBHighlight? {
runtime.highlight(withID: id)
}
public func nativeTextSemanticSummary() -> String? {
guard let textBook,
let page = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first else {
return nil
}
let metadata = page.metadata
var parts = [
"page \(page.absolutePageIndex + 1)",
"break \(metadata.breakReason.rawValue)",
metadata.blockKinds.isEmpty ? nil : "block kinds [\(metadata.blockKinds.map(\.rawValue).joined(separator: ","))]",
metadata.semanticHints.isEmpty ? nil : "hints [\(metadata.semanticHints.map(\.rawValue).joined(separator: ","))]",
metadata.attachmentPlacements.isEmpty ? nil : "placements [\(metadata.attachmentPlacements.map(\.rawValue).joined(separator: ","))]"
].compactMap { $0 }
if let firstDiagnostic = metadata.diagnostics.first {
parts.append(firstDiagnostic)
}
return parts.joined(separator: " · ")
}
///
/// - Parameters:
/// - selection: 使 currentSelection
/// - color: CSS
/// - note:
/// - Returns: nil
@discardableResult
public func addHighlight(
from selection: RDEPUBSelection? = nil,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
runtime.addHighlight(from: selection, color: color, note: note)
}
@discardableResult
public func addAnnotation(
from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
runtime.addAnnotation(from: selection, style: style, color: color, note: note)
}
@discardableResult
public func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
runtime.upsertHighlight(highlight)
}
@discardableResult
public func removeHighlight(id: String) -> RDEPUBHighlight? {
runtime.removeHighlight(id: id)
}
@discardableResult
public func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
runtime.updateHighlightNote(id: id, note: note)
}
@discardableResult
public func go(toHighlightID id: String, animated: Bool = true) -> Bool {
runtime.go(toHighlightID: id, animated: animated)
}
public func removeAllHighlights() {
runtime.removeAllHighlights()
}
@discardableResult
public func go(toTableOfContentsItem item: EPUBTableOfContentsItem, animated: Bool = true) -> Bool {
go(toTableOfContentsHref: item.href, animated: animated)
}
@discardableResult
public func go(toTableOfContentsItem item: RDEPUBReaderTableOfContentsItem, animated: Bool = true) -> Bool {
go(toTableOfContentsHref: item.href, animated: animated)
}
@discardableResult
public func go(toTableOfContentsHref href: String, animated: Bool = true) -> Bool {
guard publication != nil else { return false }
let components = href.components(separatedBy: "#")
let baseHref = components.first ?? href
let fragment = components.count > 1 ? components[1] : nil
let location = RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: baseHref,
progression: 0,
fragment: fragment
)
return restoreReadingLocation(location, animated: animated)
}
@discardableResult
public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
runtime.addBookmark(note: note)
}
@discardableResult
public func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
runtime.toggleBookmark(note: note)
}
@discardableResult
public func removeBookmark(id: String) -> RDEPUBBookmark? {
runtime.removeBookmark(id: id)
}
@discardableResult
public func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
runtime.go(toBookmarkID: id, animated: animated)
}
///
/// - Parameter keyword:
public func search(keyword: String) {
runtime.search(keyword: keyword)
}
@discardableResult
public func searchNext() -> Bool {
runtime.searchNext()
}
@discardableResult
public func searchPrevious() -> Bool {
runtime.searchPrevious()
}
public func clearSearch() {
runtime.clearSearch()
}
}

View File

@ -0,0 +1,80 @@
import UIKit
extension RDEPUBReaderController {
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
readerContext.currentLayoutContext()
}
func currentPreferences() -> RDEPUBPreferences {
readerContext.currentPreferences()
}
func currentTextPageSize() -> CGSize {
readerContext.currentTextPageSize()
}
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
readerContext.currentTextRenderStyle()
}
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
readerContext.currentTextLayoutConfig(pageSize: pageSize)
}
func resolvedTextRenderer() -> RDEPUBTextRenderer {
readerContext.resolvedTextRenderer()
}
func ensurePaginationHostView() -> UIView {
let viewportSize = currentLayoutContext().viewportSize
let hostFrame = CGRect(x: -viewportSize.width - 32, y: 0, width: viewportSize.width, height: viewportSize.height)
if paginationHostView.superview == nil {
view.addSubview(paginationHostView)
view.sendSubviewToBack(paginationHostView)
}
paginationHostView.frame = hostFrame
return paginationHostView
}
func request(for pageIndex: Int) -> RDEPUBRenderRequest? {
guard let publication, activePages.indices.contains(pageIndex) else {
return nil
}
let page = activePages[pageIndex]
let pendingLocation = readingSession?.pendingLocation(forPageNumber: pageIndex + 1, spineIndex: page.spineIndex)
return currentPreferences().renderRequest(
for: page,
publication: publication,
viewportSize: currentLayoutContext().viewportSize,
targetLocation: pendingLocation,
highlights: highlights(for: page),
searchPresentation: searchPresentation(for: page)
)
}
func fallbackLocation(for pageIndex: Int) -> RDEPUBLocation? {
guard activePages.indices.contains(pageIndex) else { return nil }
return readingSession?.fallbackLocation(for: activePages[pageIndex], bookIdentifier: currentBookIdentifier)
}
private func highlights(for page: EPUBPage) -> [RDEPUBHighlight] {
guard let publication else { return [] }
if let spread = page.fixedSpread {
let hrefs = Set(spread.resources.compactMap { publication.resourceResolver.normalizedHref($0.href) })
return activeHighlights.filter { highlight in
guard let normalizedHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
return false
}
return hrefs.contains(normalizedHref)
}
}
guard publication.spine.indices.contains(page.spineIndex) else { return [] }
let href = publication.spine[page.spineIndex].href
let normalizedHref = publication.resourceResolver.normalizedHref(href)
return activeHighlights.filter { highlight in
publication.resourceResolver.normalizedHref(highlight.location.href) == normalizedHref
}
}
}

View File

@ -0,0 +1,234 @@
import UIKit
extension RDEPUBReaderController {
func applyReaderViewConfiguration() {
let resolvedDirection = resolvedPageDirection()
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|| readerView.landscapeDualPageEnabled != configuration.landscapeDualPageEnabled
|| readerView.pageDirection != resolvedDirection
let preservedLocation = presentationDidChange
? (runtime.viewportMonitor.consumePendingPresentationRestoreLocation() ?? currentVisibleLocation() ?? persistenceLocation())
: nil
view.backgroundColor = configuration.theme.contentBackgroundColor
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
readerView.pageDirection = resolvedDirection
updateReaderChrome()
if presentationDidChange {
readerView.switchReaderDisplayType(configuration.displayType)
if let preservedLocation {
_ = restoreReadingLocation(preservedLocation)
}
}
}
func startInitialLoadIfNeeded() {
runtime.startInitialLoadIfNeeded()
}
func loadPublication() {
runtime.loadPublication()
}
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
bookIdentifier: String,
restoreLocation: RDEPUBLocation?,
bookmarks: [RDEPUBBookmark],
highlights: [RDEPUBHighlight]
) {
runtime.applyParsedPublication(
parser: parser,
publication: publication,
bookIdentifier: bookIdentifier,
restoreLocation: restoreLocation,
bookmarks: bookmarks,
highlights: highlights
)
}
func paginatePublication(restoreLocation: RDEPUBLocation?) {
runtime.paginatePublication(restoreLocation: restoreLocation)
}
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
runtime.applyTextBook(textBook, restoreLocation: restoreLocation)
}
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
) {
runtime.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
func finishPagination(restoreLocation: RDEPUBLocation?) {
runtime.finishPagination(restoreLocation: restoreLocation)
}
func repaginatePreservingCurrentLocation() {
runtime.repaginatePreservingCurrentLocation()
}
@discardableResult
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
runtime.restoreReadingLocation(location, animated: animated)
}
func currentVisibleLocation() -> RDEPUBLocation? {
runtime.currentVisibleLocation()
}
func persistenceLocation() -> RDEPUBLocation? {
readerContext.persistenceLocation()
}
func persist(location: RDEPUBLocation) {
readerContext.persist(location: location)
}
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
runtime.annotationCoordinator.updateCurrentSelection(selection)
}
func scopedSelection(
_ selection: RDEPUBSelection,
relativeToSpineIndex spineIndex: Int?
) -> RDEPUBSelection? {
runtime.annotationCoordinator.scopedSelection(selection, relativeToSpineIndex: spineIndex)
}
func refreshVisibleContentPreservingLocation() {
runtime.refreshVisibleContentPreservingLocation()
}
func rebuildExternalTextBook() {
runtime.rebuildExternalTextBook()
}
func updateReaderChrome() {
runtime.updateReaderChrome()
}
func updateBookmarkChrome() {
runtime.updateBookmarkChrome()
}
func presentBookmarksManager() {
runtime.presentBookmarksManager()
}
func presentHighlightsManager() {
runtime.presentHighlightsManager()
}
func presentAnnotationCreation() {
runtime.presentAnnotationCreation()
}
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
runtime.handleSelectionMenuAction(action, selection: selection)
}
func presentSettings() {
runtime.presentSettings()
}
func updateConfiguration(_ update: (inout RDEPUBReaderConfiguration) -> Void) {
var nextConfiguration = configuration
update(&nextConfiguration)
configuration = nextConfiguration
}
func setScreenBrightness(_ brightness: CGFloat) {
currentBrightness = max(0, min(1, brightness))
persistReaderSettingsIfNeeded()
}
func persistReaderSettingsIfNeeded() {
let settings = RDEPUBReaderSettings.capture(
configuration: configuration,
brightness: currentBrightness
)
persistence?.saveReaderSettings(settings)
}
func requiresRepagination(
from oldConfiguration: RDEPUBReaderConfiguration,
to newConfiguration: RDEPUBReaderConfiguration
) -> Bool {
oldConfiguration.fontSize != newConfiguration.fontSize ||
oldConfiguration.lineHeightMultiple != newConfiguration.lineHeightMultiple ||
oldConfiguration.numberOfColumns != newConfiguration.numberOfColumns ||
oldConfiguration.columnGap != newConfiguration.columnGap ||
oldConfiguration.reflowableContentInsets != newConfiguration.reflowableContentInsets ||
oldConfiguration.fixedContentInset != newConfiguration.fixedContentInset ||
oldConfiguration.fixedLayoutFit != newConfiguration.fixedLayoutFit ||
oldConfiguration.fixedLayoutSpreadMode != newConfiguration.fixedLayoutSpreadMode ||
oldConfiguration.textRenderingEngine != newConfiguration.textRenderingEngine
}
func requiresVisibleRefresh(
from oldConfiguration: RDEPUBReaderConfiguration,
to newConfiguration: RDEPUBReaderConfiguration
) -> Bool {
oldConfiguration.theme != newConfiguration.theme
}
func presentTableOfContents() {
runtime.presentTableOfContents()
}
func handleBackAction() {
print("[Debug] handleBackAction called")
runtime.handleBackAction()
}
func handle(error: Error) {
isRepaginating = false
hideLoading()
readerContext.clearActiveSnapshot()
textBook = nil
readerView.reloadData()
errorLabel.text = error.localizedDescription
errorLabel.isHidden = false
delegate?.epubReader(self, didFailWithError: error)
}
func showLoading() {
errorLabel.isHidden = true
loadingIndicator.startAnimating()
}
func hideLoading() {
loadingIndicator.stopAnimating()
}
func currentViewportSignature() -> RDEPUBViewportSignature? {
runtime.currentViewportSignature()
}
func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
) {
runtime.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
}
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
runtime.searchPresentation(for: page)
}
private func resolvedPageDirection() -> RDReaderView.PageDirection {
publication?.readingProgression == .rtl ? .rightToLeft : .leftToRight
}
}
// MARK: - NavigationController
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
true
}
}

View File

@ -0,0 +1,84 @@
import Foundation
extension RDEPUBReaderController {
func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
let items = flattenedTableOfContents
guard !items.isEmpty else { return nil }
let currentPageNumber = max(readerView.currentPage + 1, 1)
let pageAnchoredMatch = items.last { item in
guard let pageNumber = item.pageNumber else { return false }
return pageNumber <= currentPageNumber
}
if let pageAnchoredMatch {
return pageAnchoredMatch
}
guard let publication,
let currentLocation = currentVisibleLocation(),
let normalizedCurrentHref = publication.resourceResolver.normalizedHref(currentLocation.href) else {
return nil
}
if let textBook,
let chapterData = textBook.chapterData(
for: currentLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
),
let tocItem = chapterData.primaryTableOfContentsItem(
from: publication.tableOfContents,
normalizer: { publication.resourceResolver.normalizedHref($0) }
) {
return items.last { $0.href == tocItem.href } ?? items.last {
guard let normalizedItemHref = publication.resourceResolver.normalizedHref($0.href.components(separatedBy: "#").first ?? $0.href) else {
return false
}
return normalizedItemHref == normalizedCurrentHref
}
}
return items.last { item in
guard let normalizedItemHref = publication.resourceResolver.normalizedHref(item.href.components(separatedBy: "#").first ?? item.href) else {
return false
}
return normalizedItemHref == normalizedCurrentHref
}
}
func flattenedTableOfContentsItems(
from items: [EPUBTableOfContentsItem],
depth: Int = 0
) -> [RDEPUBReaderTableOfContentsItem] {
items.flatMap { item in
let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
let pageNumber: Int?
if let textBook, let publication,
let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
bookIdentifier: currentBookIdentifier
) ?? location
pageNumber = chapterData.pageNumber(for: normalizedLocation)
?? textBook.pageNumber(
for: location,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
} else if let readingSession {
pageNumber = readingSession.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
} else {
pageNumber = nil
}
let current = RDEPUBReaderTableOfContentsItem(
title: item.title,
href: item.href,
depth: depth,
pageNumber: pageNumber
)
return [current] + flattenedTableOfContentsItems(from: item.children, depth: depth + 1)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -100,6 +100,7 @@ public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
}
@objc private func backAction() {
print("[Debug] backAction fired, onBack: \(String(describing: onBack))")
onBack?()
}

View File

@ -1,752 +0,0 @@
import UIKit
import Foundation
#if canImport(DTCoreText)
import DTCoreText
#endif
// MARK: -
///
///
protocol RDEPUBTextContentViewDelegate: AnyObject {
///
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
/// //
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
}
// MARK: -
/// UITextView UIMenuItem
final class RDEPUBSelectableTextView: UITextView {
///
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
switch action {
case #selector(rd_copy(_:)),
#selector(rd_highlight(_:)),
#selector(rd_annotate(_:)):
return selectedRange.location != NSNotFound && selectedRange.length > 0
default:
return false
}
}
@objc func rd_copy(_ sender: Any?) {
onSelectionAction?(.copy)
}
@objc func rd_highlight(_ sender: Any?) {
onSelectionAction?(.highlight)
}
@objc func rd_annotate(_ sender: Any?) {
onSelectionAction?(.annotate)
}
}
// MARK: - DTCoreText
/// DTCoreText Core Text
/// DTCoreText UIView UITextView
#if canImport(DTCoreText)
final class RDEPUBDirectCoreTextPageView: UIView {
var layoutFrame: DTCoreTextLayoutFrame? {
didSet {
setNeedsDisplay()
}
}
var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
contentMode = .redraw
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(),
let layoutFrame else { return }
context.saveGState()
layoutFrame.draw(in: context, options: drawOptions)
context.restoreGState()
}
}
#endif
// MARK: -
/// EPUB
///
/// 1. DTCoreText CoreText
/// 2. 退 UITextView attributedText
///
///
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)
private let coreTextContentView: RDEPUBDirectCoreTextPageView = {
let view = RDEPUBDirectCoreTextPageView()
view.backgroundColor = .clear
view.isOpaque = false
return view
}()
private var coreTextDisplayContent: NSAttributedString?
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
view.isScrollEnabled = false
view.isSelectable = true
view.backgroundColor = .clear
view.textContainerInset = .zero
view.textContainer.lineFragmentPadding = 0
return view
}()
private let coverImageView: UIImageView = {
let view = UIImageView()
view.contentMode = .scaleAspectFit
view.isHidden = true
return view
}()
private let pageNumberLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 13)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(coverImageView)
#if canImport(DTCoreText)
addSubview(backgroundOverlayView)
addSubview(coreTextContentView)
#endif
addSubview(overlayView)
addSubview(textView)
addSubview(pageNumberLabel)
textView.delegate = self
textView.onSelectionAction = { [weak self] action in
guard let self else { return }
self.delegate?.textContentView(self, didRequestSelectionAction: action)
}
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)
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
pageNumberLabel.frame = CGRect(
x: bounds.width - labelSize.width - 24,
y: bounds.height - labelSize.height - 20,
width: labelSize.width,
height: labelSize.height
)
}
func configure(
page: RDEPUBTextPage,
pageNumber: Int,
totalPages: Int,
configuration: RDEPUBReaderConfiguration,
highlights: [RDEPUBHighlight] = [],
searchState: RDEPUBSearchState? = nil
) {
currentPage = page
highlightedRanges = highlights
currentSearchState = searchState
contentInsets = configuration.reflowableContentInsets
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
if configureCoverIfNeeded(for: page) {
#if canImport(DTCoreText)
coreTextContentView.isHidden = true
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
textView.isHidden = true
textView.isUserInteractionEnabled = false
#endif
textView.attributedText = nil
textView.selectedRange = NSRange(location: 0, length: 0)
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
return
}
coverImageView.isHidden = true
coverImageView.image = nil
let selectionContent = normalizedPageContent(from: page)
let selectionRange = NSRange(location: 0, length: selectionContent.length)
selectionContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: selectionRange
)
#if canImport(DTCoreText)
let displayContent = normalizedPageContent(from: page)
let fullRange = NSRange(location: 0, length: displayContent.length)
displayContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: fullRange
)
coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear
coreTextDisplayContent = displayContent
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
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 chapterLength = max(page.chapterContent.length - 1, 1)
let chapterStart = max(range.location, 0)
let chapterEnd = max(chapterStart + range.length - 1, chapterStart)
let selection = RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(chapterStart) / Double(chapterLength),
lastProgression: Double(chapterEnd) / Double(chapterLength),
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)
}
private func applyHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
contentBaseOffset: Int
) {
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
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 relativeRange = NSRange(
location: overlapStart - contentBaseOffset,
length: overlapEnd - overlapStart
)
switch highlight.style {
case .highlight:
content.addAttribute(
.backgroundColor,
value: UIColor(rdHexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
range: relativeRange
)
case .underline:
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
if let color = UIColor(rdHexString: highlight.color, alpha: 1) {
content.addAttribute(.underlineColor, value: color, range: relativeRange)
}
}
}
}
private func applySearchHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
searchState: RDEPUBSearchState?
) {
applySearchHighlights(
to: content,
page: page,
searchState: searchState,
contentBaseOffset: page.pageStartOffset
)
}
private func applySearchHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
searchState: RDEPUBSearchState?,
contentBaseOffset: Int
) {
guard let searchState else { return }
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)
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
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 relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
let color = match == searchState.currentMatch ? activeColor : normalColor
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
}
}
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
let lowerBound = page.pageStartOffset
let upperBound = page.pageEndOffset + 1
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(rdHexString: 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"),
let image = coverImage(from: page.content) else {
return false
}
coverImageView.image = image
coverImageView.isHidden = false
#if canImport(DTCoreText)
coreTextContentView.isHidden = true
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
textView.isHidden = true
textView.isUserInteractionEnabled = false
#endif
textView.attributedText = nil
return true
}
private func coverImage(from content: NSAttributedString) -> UIImage? {
guard content.length > 0 else { return nil }
var resolvedImage: UIImage?
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
guard let image = image(from: value) else { return }
resolvedImage = image
stop.pointee = true
}
return resolvedImage
}
private func image(from attachmentValue: Any?) -> UIImage? {
#if canImport(DTCoreText)
if let attachment = attachmentValue as? DTTextAttachment,
let url = attachment.contentURL {
return UIImage(contentsOfFile: url.path)
}
#endif
if let attachment = attachmentValue as? NSTextAttachment {
if let image = attachment.image {
return image
}
if let data = attachment.contents {
return UIImage(data: data)
}
if let fileWrapper = attachment.fileWrapper,
let data = fileWrapper.regularFileContents {
return UIImage(data: data)
}
}
return nil
}
private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
let proxy = NSMutableAttributedString(attributedString: content)
let fullRange = NSRange(location: 0, length: proxy.length)
proxy.removeAttribute(.backgroundColor, range: fullRange)
proxy.addAttribute(.foregroundColor, value: UIColor.clear, range: fullRange)
var attachmentRanges: [NSRange] = []
proxy.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
guard value != nil else { return }
attachmentRanges.append(range)
}
for range in attachmentRanges.reversed() {
let replacement = NSAttributedString(
string: String(repeating: " ", count: max(range.length, 1)),
attributes: [
.font: proxy.attribute(.font, at: max(range.location - 1, 0), effectiveRange: nil) as Any,
.foregroundColor: UIColor.clear
]
)
proxy.replaceCharacters(in: range, with: replacement)
}
return proxy
}
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
let content = NSMutableAttributedString(attributedString: page.content)
guard shouldNormalizeContinuationParagraph(for: page) else {
return content
}
let text = content.string as NSString
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
guard firstParagraphRange.length > 0 else {
return content
}
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
guard let style = value as? NSParagraphStyle else { return }
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
mutableStyle.firstLineHeadIndent = mutableStyle.headIndent
mutableStyle.paragraphSpacingBefore = 0
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range)
}
return content
}
private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
let pageStart = page.pageStartOffset
guard pageStart > 0, pageStart < page.chapterContent.length else {
return false
}
let chapterText = page.chapterContent.string as NSString
guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else {
return false
}
return !CharacterSet.newlines.contains(previousScalar)
}
#if canImport(DTCoreText)
private func updateCoreTextLayoutFrameIfNeeded() {
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
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
}
extension RDEPUBTextContentView: UITextViewDelegate {
func textViewDidChangeSelection(_ textView: UITextView) {
guard !isSelectionFromInteraction else { return }
guard let page = currentPage else {
delegate?.textContentView(self, didChangeSelection: nil)
return
}
let selectedRange = textView.selectedRange
guard selectedRange.location != NSNotFound,
selectedRange.length > 0,
let attributedText = textView.attributedText else {
delegate?.textContentView(self, didChangeSelection: nil)
return
}
let source = attributedText.string as NSString
let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedText.isEmpty else {
delegate?.textContentView(self, didChangeSelection: nil)
return
}
let globalStart = page.pageStartOffset + selectedRange.location
let globalEnd = globalStart + selectedRange.length
let totalLength = max(page.chapterContent.length - 1, 1)
let selection = RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(globalStart) / Double(totalLength),
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
fragment: nil
),
text: selectedText,
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
)
delegate?.textContentView(self, didChangeSelection: selection)
}
}

View File

@ -0,0 +1,29 @@
import UIKit
///
///
struct RDEPUBViewportSignature: Equatable {
let width: CGFloat
let height: CGFloat
let safeTop: CGFloat
let safeLeft: CGFloat
let safeBottom: CGFloat
let safeRight: CGFloat
func differsSignificantly(from other: RDEPUBViewportSignature, threshold: CGFloat = 1) -> Bool {
abs(width - other.width) > threshold ||
abs(height - other.height) > threshold ||
abs(safeTop - other.safeTop) > threshold ||
abs(safeLeft - other.safeLeft) > threshold ||
abs(safeBottom - other.safeBottom) > threshold ||
abs(safeRight - other.safeRight) > threshold
}
}
///
enum RDEPUBViewportChangeReason {
case viewLayout
case orientationTransition
}
typealias RDEPUBNativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])

View File

@ -0,0 +1,496 @@
import UIKit
final class RDEPUBReaderAnnotationCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
private var controller: RDEPUBReaderController? {
context.controller
}
func bookmark(withID id: String) -> RDEPUBBookmark? {
guard let controller else { return nil }
return controller.activeBookmarks.first { $0.id == id }
}
func highlight(withID id: String) -> RDEPUBHighlight? {
guard let controller else { return nil }
return controller.activeHighlights.first { $0.id == id }
}
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
guard let controller else { return }
controller.currentSelection = selection?.isEmpty == false ? selection : nil
controller.bottomToolView.setAddHighlightEnabled(
controller.configuration.allowsHighlights && controller.currentSelection != nil
)
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
}
@discardableResult
func addHighlight(
from selection: RDEPUBSelection? = nil,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
addAnnotation(from: selection, style: .highlight, color: color, note: note)
}
@discardableResult
func addAnnotation(
from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
guard let controller else { return nil }
let sourceSelection = selection ?? controller.currentSelection
guard let sourceSelection,
let scopedSelection = scopedSelection(sourceSelection, relativeToSpineIndex: nil),
!scopedSelection.isEmpty else {
return nil
}
let newHighlight = RDEPUBHighlight(
bookIdentifier: controller.currentBookIdentifier,
location: scopedSelection.location,
text: scopedSelection.text,
rangeInfo: scopedSelection.rangeInfo,
style: style,
color: color,
note: note
)
let isDuplicate = controller.activeHighlights.contains { highlight in
highlight.location.href == newHighlight.location.href &&
highlight.location.fragment == newHighlight.location.fragment &&
highlight.text == newHighlight.text &&
highlight.rangeInfo == newHighlight.rangeInfo &&
highlight.style == newHighlight.style
}
guard !isDuplicate else {
return nil
}
controller.activeHighlights.append(newHighlight)
persistHighlightsAndRefreshContent()
updateCurrentSelection(nil)
return newHighlight
}
@discardableResult
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
guard let controller else { return nil }
guard let scopedHighlight = scopedHighlight(highlight) else {
return nil
}
if let index = controller.activeHighlights.firstIndex(where: { $0.id == scopedHighlight.id }) {
controller.activeHighlights[index] = scopedHighlight
} else {
controller.activeHighlights.append(scopedHighlight)
}
persistHighlightsAndRefreshContent()
return scopedHighlight
}
@discardableResult
func removeHighlight(id: String) -> RDEPUBHighlight? {
guard let controller else { return nil }
guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
return nil
}
let removed = controller.activeHighlights.remove(at: index)
persistHighlightsAndRefreshContent()
return removed
}
@discardableResult
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
guard let controller else { return nil }
guard let index = controller.activeHighlights.firstIndex(where: { $0.id == id }) else {
return nil
}
controller.activeHighlights[index].note = normalizedNote(note)
persistHighlightsAndRefreshContent()
return controller.activeHighlights[index]
}
@discardableResult
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
guard let controller else { return false }
guard let highlight = highlight(withID: id) else {
return false
}
return controller.restoreReadingLocation(highlight.location, animated: animated)
}
func removeAllHighlights() {
guard let controller else { return }
guard !controller.activeHighlights.isEmpty else { return }
controller.activeHighlights.removeAll()
persistHighlightsAndRefreshContent()
}
func scopedSelection(
_ selection: RDEPUBSelection,
relativeToSpineIndex spineIndex: Int?
) -> RDEPUBSelection? {
guard let controller else { return nil }
guard let publication = controller.publication else { return nil }
let normalizedLocation = publication.resourceResolver.normalizedLocation(
selection.location,
relativeToSpineIndex: spineIndex,
bookIdentifier: controller.currentBookIdentifier
) ?? RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: selection.location.href,
progression: selection.location.progression,
lastProgression: selection.location.lastProgression,
fragment: selection.location.fragment,
rangeAnchor: selection.location.rangeAnchor
)
return RDEPUBSelection(
bookIdentifier: controller.currentBookIdentifier,
location: normalizedLocation,
text: selection.text,
rangeInfo: selection.rangeInfo,
createdAt: selection.createdAt
)
}
func presentHighlightsManager() {
guard let controller else { return }
guard controller.configuration.allowsHighlights else { return }
guard !controller.activeHighlights.isEmpty else { return }
let highlightsController = RDEPUBReaderHighlightsViewController(
highlights: controller.activeHighlights,
theme: controller.configuration.theme,
sectionTitleProvider: { [weak self] highlight in
self?.titleForHighlight(highlight)
}
)
highlightsController.onSelectHighlight = { [weak self, weak highlightsController] highlight in
guard let controller = self?.controller else { return }
highlightsController?.dismiss(animated: true) {
controller.go(to: highlight.location)
}
}
highlightsController.onUpdateHighlight = { [weak self] highlight in
_ = self?.controller?.updateHighlightNote(id: highlight.id, note: highlight.note)
}
highlightsController.onDeleteHighlight = { [weak self] highlight in
_ = self?.controller?.removeHighlight(id: highlight.id)
}
let navigationController = UINavigationController(rootViewController: highlightsController)
navigationController.modalPresentationStyle = .pageSheet
controller.present(navigationController, animated: true)
}
func presentAnnotationCreation() {
guard let controller else { return }
guard controller.configuration.allowsHighlights,
let currentSelection = controller.currentSelection else {
return
}
presentAnnotationActionSheet(for: currentSelection)
}
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
guard let selection else { return }
switch action {
case .copy:
UIPasteboard.general.string = selection.text
updateCurrentSelection(nil)
case .highlight:
createAnnotation(from: selection, style: .highlight)
case .annotate:
presentAnnotationNoteEditor(for: selection)
}
}
@discardableResult
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
return nil
}
guard bookmark(matching: location) == nil else {
return nil
}
let newBookmark = RDEPUBBookmark(
bookIdentifier: controller.currentBookIdentifier,
location: location,
chapterTitle: titleForBookmarkLocation(location),
note: normalizedBookmarkNote(note)
)
controller.activeBookmarks.append(newBookmark)
persistBookmarksAndRefreshChrome()
return newBookmark
}
@discardableResult
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
return nil
}
if let existingBookmark = bookmark(matching: location) {
_ = removeBookmark(id: existingBookmark.id)
return nil
}
return addBookmark(note: note)
}
@discardableResult
func removeBookmark(id: String) -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let index = controller.activeBookmarks.firstIndex(where: { $0.id == id }) else {
return nil
}
let removed = controller.activeBookmarks.remove(at: index)
persistBookmarksAndRefreshChrome()
return removed
}
@discardableResult
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
guard let controller else { return false }
guard let bookmark = bookmark(withID: id) else {
return false
}
return controller.restoreReadingLocation(bookmark.location, animated: animated)
}
func updateBookmarkChrome() {
guard let controller else { return }
controller.topToolView.setBookmarkSelected(currentBookmark() != nil)
controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty)
}
func presentBookmarksManager() {
guard let controller else { return }
guard !controller.activeBookmarks.isEmpty else { return }
let bookmarksController = RDEPUBReaderBookmarksViewController(
bookmarks: controller.activeBookmarks,
theme: controller.configuration.theme
)
bookmarksController.onSelectBookmark = { [weak self, weak bookmarksController] bookmark in
guard let controller = self?.controller else { return }
bookmarksController?.dismiss(animated: true) {
_ = controller.restoreReadingLocation(bookmark.location, animated: true)
}
}
bookmarksController.onDeleteBookmark = { [weak self] bookmark in
_ = self?.controller?.removeBookmark(id: bookmark.id)
}
let navigationController = UINavigationController(rootViewController: bookmarksController)
navigationController.modalPresentationStyle = .pageSheet
controller.present(navigationController, animated: true)
}
private func scopedHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
guard let controller else { return nil }
guard let publication = controller.publication else { return nil }
let normalizedLocation = publication.resourceResolver.normalizedLocation(
highlight.location,
relativeToSpineIndex: nil,
bookIdentifier: controller.currentBookIdentifier
) ?? RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: highlight.location.href,
progression: highlight.location.progression,
lastProgression: highlight.location.lastProgression,
fragment: highlight.location.fragment,
rangeAnchor: highlight.location.rangeAnchor
)
return RDEPUBHighlight(
id: highlight.id,
bookIdentifier: controller.currentBookIdentifier,
location: normalizedLocation,
text: highlight.text,
rangeInfo: highlight.rangeInfo,
style: highlight.style,
color: highlight.color,
note: highlight.note,
createdAt: highlight.createdAt
)
}
private func persistHighlightsAndRefreshContent() {
guard let controller else { return }
guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
controller.persistence?.saveHighlights(controller.activeHighlights, for: currentBookIdentifier)
controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights)
controller.updateReaderChrome()
controller.refreshVisibleContentPreservingLocation()
}
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
guard let controller else { return }
let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "高亮", style: .default) { [weak self] _ in
self?.createAnnotation(from: selection, style: .highlight)
})
alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in
self?.createAnnotation(from: selection, style: .underline)
})
alert.addAction(UIAlertAction(title: "批注", style: .default) { [weak self] _ in
self?.presentAnnotationNoteEditor(for: selection)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = controller.bottomToolView
popover.sourceRect = controller.bottomToolView.bounds
}
controller.present(alert, animated: true)
}
private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) {
_ = addAnnotation(from: selection, style: style, note: note)
}
private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) {
guard let controller else { return }
let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "输入批注内容"
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
self?.createAnnotation(
from: selection,
style: .highlight,
note: alert?.textFields?.first?.text
)
})
controller.present(alert, animated: true)
}
private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {
guard let controller else { return nil }
guard let publication = controller.publication,
let normalizedHighlightHref = publication.resourceResolver.normalizedHref(highlight.location.href) else {
return nil
}
return controller.flattenedTableOfContents.first { item in
let rawHref = item.href.components(separatedBy: "#").first ?? item.href
return publication.resourceResolver.normalizedHref(rawHref) == normalizedHighlightHref
}?.title
}
private func normalizedNote(_ note: String?) -> String? {
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func titleForBookmarkLocation(_ location: RDEPUBLocation) -> String? {
guard let controller else { return nil }
if let currentLocation = controller.currentVisibleLocation(),
bookmarkHref(for: currentLocation) == bookmarkHref(for: location) {
return controller.currentTableOfContentsItem?.title
}
return controller.flattenedTableOfContents.last { item in
bookmarkHref(forTableOfContentsHref: item.href) == bookmarkHref(for: location)
}?.title
}
private func persistBookmarksAndRefreshChrome() {
guard let controller else { return }
guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier)
controller.delegate?.epubReader(controller, didUpdateBookmarks: controller.activeBookmarks)
updateBookmarkChrome()
}
private func currentBookmark() -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
return nil
}
return bookmark(matching: location)
}
private func bookmark(matching location: RDEPUBLocation?) -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let location else { return nil }
return controller.activeBookmarks.first { bookmarkMatchesLocation($0, location: location) }
}
private func bookmarkMatchesLocation(_ bookmark: RDEPUBBookmark, location: RDEPUBLocation) -> Bool {
guard let controller else { return false }
guard bookmarkHref(for: bookmark.location) == bookmarkHref(for: location) else {
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
}
let progressionDelta = abs(bookmark.location.navigationProgression - location.navigationProgression)
let threshold: Double = controller.publication?.layout == .fixed ? 0.01 : 0.05
return progressionDelta <= threshold
}
private func bookmarkHref(for location: RDEPUBLocation) -> String {
controller?.publication?.resourceResolver.normalizedHref(location.href) ?? location.href
}
private func bookmarkHref(forTableOfContentsHref href: String) -> String {
let rawHref = href.components(separatedBy: "#").first ?? href
return controller?.publication?.resourceResolver.normalizedHref(rawHref) ?? rawHref
}
private func scopedBookmarkLocation(_ location: RDEPUBLocation?) -> RDEPUBLocation? {
guard let controller else { return nil }
guard let location else { return nil }
guard let publication = controller.publication else {
return RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: location.href,
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment,
rangeAnchor: location.rangeAnchor
)
}
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: controller.currentBookIdentifier
) ?? RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: location.href,
progression: location.progression,
lastProgression: location.lastProgression,
fragment: location.fragment,
rangeAnchor: location.rangeAnchor
)
}
private func normalizedBookmarkNote(_ note: String?) -> String? {
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@ -0,0 +1,71 @@
import UIKit
final class RDEPUBReaderAssemblyCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
func assembleInterface() {
guard let controller = context.controller,
let readerView = context.readerView else { return }
controller.view.backgroundColor = context.configuration.theme.contentBackgroundColor
setupReaderView(readerView, in: controller.view)
setupLoadingIndicator(controller.loadingIndicator, in: controller.view)
setupErrorLabel(controller.errorLabel, in: controller.view)
controller.delegate?.epubReader(controller, configureTopToolView: controller.topToolView)
}
func finishExternalTextBookLaunchIfNeeded() {
guard let runtime = context.runtime,
context.isExternalTextBook else {
return
}
let restoreLocation = context.currentBookIdentifier.flatMap { context.persistence?.loadLocation(for: $0) }
if let id = context.currentBookIdentifier {
context.activeBookmarks = context.persistence?.loadBookmarks(for: id) ?? []
context.activeHighlights = context.persistence?.loadHighlights(for: id) ?? []
}
runtime.finishPagination(restoreLocation: restoreLocation)
}
private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) {
readerView.dataSource = context.controller as? RDReaderDataSource
readerView.delegate = context.controller as? RDReaderDelegate
readerView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(readerView)
NSLayoutConstraint.activate([
readerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
readerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
readerView.topAnchor.constraint(equalTo: containerView.topAnchor),
readerView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
])
readerView.register(contentView: RDEPUBTextContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBTextContentView.self))
readerView.register(contentView: RDEPUBWebContentView.self, contentViewWithReuseIdentifier: NSStringFromClass(RDEPUBWebContentView.self))
context.controller?.applyReaderViewConfiguration()
}
private func setupLoadingIndicator(_ loadingIndicator: UIActivityIndicatorView, in containerView: UIView) {
loadingIndicator.hidesWhenStopped = true
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(loadingIndicator)
NSLayoutConstraint.activate([
loadingIndicator.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
loadingIndicator.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
])
}
private func setupErrorLabel(_ errorLabel: UILabel, in containerView: UIView) {
errorLabel.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(errorLabel)
NSLayoutConstraint.activate([
errorLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 24),
errorLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -24),
errorLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
])
}
}

View File

@ -0,0 +1,168 @@
import UIKit
final class RDEPUBReaderChromeCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
private var controller: RDEPUBReaderController? {
context.controller
}
func makeTopToolView() -> RDEPUBReaderTopToolView {
let toolView = RDEPUBReaderTopToolView()
toolView.onBack = { [weak self] in
print("[Debug] onBack fired, controller: \(String(describing: self?.controller))")
self?.handleBackAction()
}
toolView.onToggleBookmark = { [weak self] in
_ = self?.context.runtime?.toggleBookmark()
}
return toolView
}
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
let toolView = RDEPUBReaderBottomToolView()
toolView.onShowTableOfContents = { [weak self] in
self?.presentTableOfContents()
}
toolView.onShowBookmarks = { [weak self] in
self?.context.runtime?.presentBookmarksManager()
}
toolView.onShowHighlights = { [weak self] in
self?.context.runtime?.presentHighlightsManager()
}
toolView.onAddHighlight = { [weak self] in
self?.context.runtime?.presentAnnotationCreation()
}
toolView.onShowSettings = { [weak self] in
self?.presentSettings()
}
return toolView
}
func updateReaderChrome() {
guard let controller else { return }
controller.topToolView.apply(theme: controller.configuration.theme)
controller.topToolView.setTitle(
controller.title
?? controller.parser?.metadata.title
?? controller.epubURL.deletingPathExtension().lastPathComponent
)
controller.topToolView.setBookmarkEnabled(controller.currentBookIdentifier != nil)
controller.bottomToolView.apply(theme: controller.configuration.theme)
controller.bottomToolView.updateVisibility(
showsTableOfContents: controller.configuration.showsTableOfContents,
allowsHighlights: controller.configuration.allowsHighlights,
showsSettingsPanel: controller.configuration.showsSettingsPanel
)
controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty)
controller.bottomToolView.setAddHighlightEnabled(
controller.configuration.allowsHighlights && controller.currentSelection != nil
)
controller.bottomToolView.setHighlightsEnabled(
controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty
)
controller.updateBookmarkChrome()
}
func presentSettings() {
guard let controller else { return }
guard controller.configuration.showsSettingsPanel else { return }
let settingsController = RDEPUBReaderSettingsViewController(
configuration: controller.configuration,
brightness: controller.currentBrightness
)
settingsController.onBrightnessChange = { [weak controller] brightness in
controller?.setScreenBrightness(brightness)
}
settingsController.onFontSizeChange = { [weak controller] fontSize in
controller?.updateConfiguration { $0.fontSize = fontSize }
}
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
}
settingsController.onColumnCountChange = { [weak controller] numberOfColumns in
controller?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
}
settingsController.onDisplayTypeChange = { [weak controller] displayType in
controller?.updateConfiguration { $0.displayType = displayType }
}
settingsController.onThemeChange = { [weak controller] theme in
controller?.updateConfiguration { $0.theme = theme }
}
let navigationController = UINavigationController(rootViewController: settingsController)
navigationController.modalPresentationStyle = .pageSheet
controller.present(navigationController, animated: true)
}
func presentTableOfContents() {
guard let controller else { return }
guard controller.configuration.showsTableOfContents else { return }
let items = controller.flattenedTableOfContents
guard !items.isEmpty else { return }
let chapterController = RDEPUBReaderChapterListController(
items: items,
currentItem: controller.currentTableOfContentsItem,
theme: controller.configuration.theme
)
chapterController.onSelectItem = { [weak controller] item in
guard let controller else { return }
chapterController.dismiss(animated: true) {
_ = controller.go(toTableOfContentsItem: item, animated: true)
}
}
let navigationController = UINavigationController(rootViewController: chapterController)
navigationController.modalPresentationStyle = .pageSheet
controller.present(navigationController, animated: true)
}
func handleBackAction() {
guard let controller else { return }
close(controller)
}
private func close(_ controller: UIViewController) {
let target = closestDismissTarget(from: controller)
if let navigationController = target.navigationController,
navigationController.viewControllers.first !== target {
navigationController.popViewController(animated: true)
return
}
if let navigationController = target.navigationController,
navigationController.presentingViewController != nil {
navigationController.dismiss(animated: true)
return
}
if target.presentingViewController != nil {
target.dismiss(animated: true)
return
}
controller.dismiss(animated: true)
}
private func closestDismissTarget(from controller: UIViewController) -> UIViewController {
var candidate: UIViewController = controller
var current = controller.parent
while let parent = current {
if let navigationController = parent.navigationController,
navigationController.viewControllers.contains(parent) {
return parent
}
if parent.presentingViewController != nil || parent.navigationController?.presentingViewController != nil {
return parent
}
candidate = parent
current = parent.parent
}
return candidate
}
}

View File

@ -0,0 +1,209 @@
import UIKit
/// coordinator context 访便
///
/// context
/// - parserpublicationtextBookpages
/// - UI configurationbrightness
/// - persistence
/// - 便renderStylelayoutConfig
/// - controller UIKit
final class RDEPUBReaderContext {
// MARK: -
weak var controller: RDEPUBReaderController?
weak var readerView: RDReaderView?
var dependencies: RDEPUBReaderDependencies = .live
var runtime: RDEPUBReaderRuntime? {
controller?.runtime
}
// MARK: -
var parser: RDEPUBParser?
var publication: RDEPUBPublication?
var readingSession: RDEPUBReadingSession?
var textBook: RDEPUBTextBook?
var activeBookmarks: [RDEPUBBookmark] = []
var activeHighlights: [RDEPUBHighlight] = []
var currentBookIdentifier: String?
var paginationToken = UUID()
var paginator: RDEPUBPaginator?
var searchState: RDEPUBSearchState?
var lastTextPaginationPageSize: CGSize?
var currentSelection: RDEPUBSelection?
// MARK: - controller
var configuration: RDEPUBReaderConfiguration = .default
var persistence: RDEPUBReaderPersistence?
var epubURL: URL = URL(string: "about:blank")!
var isRepaginating: Bool = false
var didStartInitialLoad: Bool = false
var isExternalTextBook: Bool = false
var textFileURL: URL?
var textBookCache = RDEPUBTextBookCache()
// MARK: -
init(controller: RDEPUBReaderController) {
self.controller = controller
self.readerView = controller.readerView
}
// MARK: - 便
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
let containerSize = readerView?.bounds.size ?? .zero
let viewSize = controller?.view.bounds.size ?? containerSize
let resolvedSize = containerSize == .zero ? viewSize : containerSize
return RDEPUBNavigatorLayoutContext(
containerSize: resolvedSize,
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
safeAreaInsets: controller?.view.safeAreaInsets ?? .zero,
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
reflowableContentInsets: configuration.reflowableContentInsets
)
}
func currentPreferences() -> RDEPUBPreferences {
configuration.makePreferences()
}
func currentTextPageSize() -> CGSize {
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
if let readerView, let pageNum {
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
if resolvedSize.width > 0, resolvedSize.height > 0 {
return resolvedSize
}
}
return currentLayoutContext().viewportSize
}
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
let font = UIFont.systemFont(ofSize: configuration.fontSize)
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
return RDEPUBTextRenderStyle(
font: font,
lineSpacing: lineSpacing,
textColor: configuration.theme.contentTextColor,
backgroundColor: configuration.theme.contentBackgroundColor
)
}
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
return RDEPUBTextLayoutConfig(
frameWidth: max(pageSize.width, 1),
frameHeight: max(pageSize.height, 1),
edgeInsets: configuration.reflowableContentInsets,
numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap,
avoidOrphans: true,
avoidWidows: true,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85,
fallbackViewportSize: dependencies.environment.fallbackViewportSize
)
}
func resolvedTextRenderer() -> RDEPUBTextRenderer {
dependencies.makeTextRenderer(configuration.textRenderingEngine)
}
var activePages: [EPUBPage] {
readingSession?.activePages ?? []
}
var activeChapters: [EPUBChapterInfo] {
readingSession?.activeChapters ?? []
}
var currentBrightness: CGFloat {
get { dependencies.environment.currentBrightness }
set { dependencies.environment.currentBrightness = newValue }
}
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
readingSession?.setActiveSnapshot(snapshot)
}
func clearActiveSnapshot() {
readingSession?.resetRuntimeState()
}
func makeParser() -> RDEPUBParser {
dependencies.makeParser()
}
func makePaginator() -> RDEPUBPaginator {
dependencies.makePaginator()
}
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
}
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
}
func currentVisibleLocation() -> RDEPUBLocation? {
controller?.currentVisibleLocation()
}
func persistenceLocation() -> RDEPUBLocation? {
guard let currentBookIdentifier else { return nil }
return persistence?.loadLocation(for: currentBookIdentifier)
}
func persist(location: RDEPUBLocation) {
guard let currentBookIdentifier else { return }
persistence?.saveLocation(location, for: currentBookIdentifier)
}
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) }
}
func showLoading() {
controller?.showLoading()
}
func hideLoading() {
controller?.hideLoading()
}
func handle(error: Error) {
controller?.handle(error: error)
}
func updateReaderChrome() {
controller?.updateReaderChrome()
}
func refreshVisibleContentPreservingLocation() {
controller?.refreshVisibleContentPreservingLocation()
}
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
controller?.restoreReadingLocation(location, animated: animated) ?? false
}
func repaginatePreservingCurrentLocation() {
controller?.repaginatePreservingCurrentLocation()
}
func applyReaderViewConfiguration() {
controller?.applyReaderViewConfiguration()
}
func updateBookmarkChrome() {
controller?.updateBookmarkChrome()
}
}

View File

@ -0,0 +1,64 @@
import UIKit
public protocol RDEPUBReaderDisplayEnvironment: AnyObject {
var currentBrightness: CGFloat { get set }
var fallbackViewportSize: CGSize { get }
}
public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
public init() {}
public var currentBrightness: CGFloat {
get { CGFloat(UIScreen.main.brightness) }
set { UIScreen.main.brightness = newValue }
}
public var fallbackViewportSize: CGSize {
UIScreen.main.bounds.size
}
}
public struct RDEPUBReaderDependencies {
public var environment: any RDEPUBReaderDisplayEnvironment
public var makeParser: () -> RDEPUBParser
public var makePaginator: () -> RDEPUBPaginator
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
public init(
environment: any RDEPUBReaderDisplayEnvironment,
makeParser: @escaping () -> RDEPUBParser,
makePaginator: @escaping () -> RDEPUBPaginator,
makeTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder,
makePlainTextBookBuilder: @escaping (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder,
makeTextRenderer: @escaping (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
) {
self.environment = environment
self.makeParser = makeParser
self.makePaginator = makePaginator
self.makeTextBookBuilder = makeTextBookBuilder
self.makePlainTextBookBuilder = makePlainTextBookBuilder
self.makeTextRenderer = makeTextRenderer
}
public static var live: RDEPUBReaderDependencies {
RDEPUBReaderDependencies(
environment: RDEPUBUIScreenEnvironment(),
makeParser: { RDEPUBParser() },
makePaginator: { RDEPUBPaginator() },
makeTextBookBuilder: { renderer, cache, layoutConfig in
RDEPUBTextBookBuilder(renderer: renderer, cache: cache, layoutConfig: layoutConfig)
},
makePlainTextBookBuilder: { renderer, layoutConfig in
RDPlainTextBookBuilder(renderer: renderer, layoutConfig: layoutConfig)
},
makeTextRenderer: { engine in
switch engine {
case .dtCoreText:
return RDEPUBDTCoreTextRenderer()
}
}
)
}
}

View File

@ -0,0 +1,84 @@
import Foundation
final class RDEPUBReaderLoadCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
func startInitialLoadIfNeeded() {
guard let controller = context.controller,
let readerView = context.readerView,
!controller.didStartInitialLoad,
readerView.bounds.width > 0,
readerView.bounds.height > 0 else {
return
}
controller.didStartInitialLoad = true
loadPublication()
}
func loadPublication() {
guard let controller = context.controller else { return }
context.showLoading()
let loadToken = UUID()
context.paginationToken = loadToken
DispatchQueue.global(qos: .userInitiated).async { [weak controller] in
guard let controller else { return }
let parser = self.context.makeParser()
do {
try parser.parse(epubURL: controller.epubURL)
let publication = parser.makePublication()
let bookIdentifier = parser.metadata.identifier ?? controller.epubURL.lastPathComponent
let restoreLocation = controller.persistence?.loadLocation(for: bookIdentifier)
let bookmarks = controller.persistence?.loadBookmarks(for: bookIdentifier) ?? []
let highlights = controller.persistence?.loadHighlights(for: bookIdentifier) ?? []
DispatchQueue.main.async {
guard self.context.paginationToken == loadToken else { return }
self.context.runtime?.applyParsedPublication(
parser: parser,
publication: publication,
bookIdentifier: bookIdentifier,
restoreLocation: restoreLocation,
bookmarks: bookmarks,
highlights: highlights
)
}
} catch {
DispatchQueue.main.async {
guard self.context.paginationToken == loadToken else { return }
self.context.handle(error: error)
}
}
}
}
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
bookIdentifier: String,
restoreLocation: RDEPUBLocation?,
bookmarks: [RDEPUBBookmark],
highlights: [RDEPUBHighlight]
) {
guard let controller = context.controller else { return }
context.parser = parser
context.publication = publication
context.currentBookIdentifier = bookIdentifier
context.activeBookmarks = bookmarks
context.activeHighlights = highlights
context.readingSession = RDEPUBReadingSession(publication: publication)
context.readingSession?.transition(to: .loading)
controller.title = parser.metadata.title.isEmpty
? controller.epubURL.deletingPathExtension().lastPathComponent
: parser.metadata.title
controller.applyReaderViewConfiguration()
context.updateReaderChrome()
controller.delegate?.epubReader(controller, didOpen: publication)
context.runtime?.paginatePublication(restoreLocation: restoreLocation)
}
}

View File

@ -0,0 +1,60 @@
import Foundation
final class RDEPUBReaderLocationCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
@discardableResult
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
guard let controller = context.controller,
let readerView = context.readerView else { return false }
guard let targetPageNumber = controller.pageNumber(for: location) else {
readerView.transitionToPage(pageNum: 0)
context.readingSession?.transition(to: .idle)
return false
}
if context.textBook == nil {
_ = context.readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: context.currentBookIdentifier
)
} else {
context.readingSession?.transition(to: .jumping)
}
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
return true
}
func currentVisibleLocation() -> RDEPUBLocation? {
guard let controller = context.controller,
let readerView = context.readerView else {
return nil
}
if context.textBook != nil, readerView.currentPage >= 0 {
return controller.resolvedTextLocation(forPageNumber: readerView.currentPage + 1)
}
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
}
func persistenceLocation() -> RDEPUBLocation? {
guard let controller = context.controller,
let currentBookIdentifier = context.currentBookIdentifier else {
return nil
}
return controller.persistence?.loadLocation(for: currentBookIdentifier)
}
func persist(location: RDEPUBLocation) {
guard let controller = context.controller,
let currentBookIdentifier = context.currentBookIdentifier else { return }
controller.persistence?.saveLocation(location, for: currentBookIdentifier)
controller.delegate?.epubReader(controller, didUpdateLocation: location)
controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem)
controller.updateBookmarkChrome()
}
}

View File

@ -0,0 +1,156 @@
import Foundation
final class RDEPUBReaderPaginationCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
func paginatePublication(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let parser = context.parser,
let publication = context.publication,
let readingSession = context.readingSession else {
return
}
controller.isRepaginating = true
controller.errorLabel.isHidden = true
controller.showLoading()
let token = UUID()
context.paginationToken = token
if publication.readingProfile == .textReflowable {
let pageSize = controller.currentTextPageSize()
context.lastTextPaginationPageSize = pageSize
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
let renderStyle = controller.currentTextRenderStyle()
DispatchQueue.global(qos: .userInitiated).async { [weak controller] in
guard let controller else { return }
do {
let textBook = try builder.build(
parser: parser,
publication: publication,
pageSize: pageSize,
style: renderStyle
)
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.context.runtime?.applyTextBook(textBook, restoreLocation: restoreLocation)
}
} catch {
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.context.handle(error: error)
}
}
}
return
}
if publication.layout == .fixed {
let snapshot = readingSession.makePaginationSnapshot(
pageCounts: Array(repeating: 1, count: publication.spine.count),
preferences: controller.currentPreferences(),
layoutContext: controller.currentLayoutContext()
)
context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
return
}
let paginator = context.makePaginator()
context.paginator = paginator
paginator.calculate(
parser: parser,
hostingView: controller.ensurePaginationHostView(),
presentation: controller.currentPreferences().presentationStyle(viewportSize: controller.currentLayoutContext().viewportSize)
) { [weak controller] pageCounts in
guard let controller, self.context.paginationToken == token else { return }
let snapshot = readingSession.makePaginationSnapshot(
pageCounts: pageCounts,
preferences: controller.currentPreferences(),
layoutContext: controller.currentLayoutContext()
)
self.context.paginator = nil
self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
}
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller else { return }
context.textBook = textBook
let snapshot = controller.nativeTextSnapshot(from: textBook)
context.replaceActiveSnapshot(snapshot)
guard !textBook.pages.isEmpty else {
context.handle(error: RDEPUBParserError.emptySpine)
return
}
finishPagination(restoreLocation: restoreLocation)
}
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
) {
guard let controller = context.controller else { return }
context.textBook = nil
context.replaceActiveSnapshot(snapshot)
guard !snapshot.pages.isEmpty else {
context.handle(error: RDEPUBParserError.emptySpine)
return
}
finishPagination(restoreLocation: restoreLocation)
}
func finishPagination(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let readerView = context.readerView else { return }
controller.isRepaginating = false
controller.hideLoading()
readerView.reloadData()
if let targetLocation = restoreLocation {
controller.restoreReadingLocation(targetLocation)
} else {
readerView.transitionToPage(pageNum: 0)
context.readingSession?.transition(to: .idle)
}
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
}
func repaginatePreservingCurrentLocation() {
guard context.publication != nil else { return }
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
?? context.currentVisibleLocation()
?? context.persistenceLocation()
paginatePublication(restoreLocation: restoreLocation)
}
func refreshVisibleContentPreservingLocation() {
guard let readerView = context.readerView else { return }
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
readerView.reloadData()
if let restoreLocation {
_ = context.restoreReadingLocation(restoreLocation)
}
}
func rebuildExternalTextBook() {
guard let controller = context.controller,
let textFileURL = controller.textFileURL else { return }
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
let pageSize = controller.currentTextPageSize()
let style = controller.currentTextRenderStyle()
let builder = context.makePlainTextBookBuilder(layoutConfig: controller.currentTextLayoutConfig(pageSize: pageSize))
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
}
}
}

View File

@ -0,0 +1,281 @@
import UIKit
final class RDEPUBReaderRuntime {
private unowned let context: RDEPUBReaderContext
lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context)
lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context)
lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context)
lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context)
init(context: RDEPUBReaderContext) {
self.context = context
}
func makeTopToolView() -> RDEPUBReaderTopToolView {
chromeCoordinator.makeTopToolView()
}
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
chromeCoordinator.makeBottomToolView()
}
func startInitialLoadIfNeeded() {
loadCoordinator.startInitialLoadIfNeeded()
}
func reloadBook() {
guard let readerView = context.readerView else { return }
context.didStartInitialLoad = false
context.parser = nil
context.publication = nil
context.clearActiveSnapshot()
context.readingSession = nil
context.textBook = nil
context.activeBookmarks = []
context.activeHighlights = []
context.searchState = nil
viewportMonitor.resetForReload()
annotationCoordinator.updateCurrentSelection(nil)
readerView.reloadData()
startInitialLoadIfNeeded()
}
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
locationCoordinator.restoreReadingLocation(location, animated: animated)
}
@discardableResult
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
guard let controller = context.controller,
let readerView = context.readerView,
pageNumber > 0 else {
return false
}
if context.textBook != nil {
guard let location = controller.resolvedTextLocation(forPageNumber: pageNumber) else {
return false
}
return locationCoordinator.restoreReadingLocation(location, animated: animated)
}
guard context.activePages.indices.contains(pageNumber - 1) else {
return false
}
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
if let location = locationCoordinator.currentVisibleLocation() {
context.persist(location: location)
}
return true
}
func clearSelection() {
annotationCoordinator.updateCurrentSelection(nil)
}
func bookmark(withID id: String) -> RDEPUBBookmark? {
annotationCoordinator.bookmark(withID: id)
}
func highlight(withID id: String) -> RDEPUBHighlight? {
annotationCoordinator.highlight(withID: id)
}
@discardableResult
func addHighlight(
from selection: RDEPUBSelection? = nil,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
annotationCoordinator.addHighlight(from: selection, color: color, note: note)
}
@discardableResult
func addAnnotation(
from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
annotationCoordinator.addAnnotation(from: selection, style: style, color: color, note: note)
}
@discardableResult
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
annotationCoordinator.upsertHighlight(highlight)
}
@discardableResult
func removeHighlight(id: String) -> RDEPUBHighlight? {
annotationCoordinator.removeHighlight(id: id)
}
@discardableResult
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
annotationCoordinator.updateHighlightNote(id: id, note: note)
}
@discardableResult
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
annotationCoordinator.go(toHighlightID: id, animated: animated)
}
func removeAllHighlights() {
annotationCoordinator.removeAllHighlights()
}
@discardableResult
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
annotationCoordinator.addBookmark(note: note)
}
@discardableResult
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
annotationCoordinator.toggleBookmark(note: note)
}
@discardableResult
func removeBookmark(id: String) -> RDEPUBBookmark? {
annotationCoordinator.removeBookmark(id: id)
}
@discardableResult
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
annotationCoordinator.go(toBookmarkID: id, animated: animated)
}
func updateBookmarkChrome() {
annotationCoordinator.updateBookmarkChrome()
}
func presentBookmarksManager() {
annotationCoordinator.presentBookmarksManager()
}
func presentHighlightsManager() {
annotationCoordinator.presentHighlightsManager()
}
func presentAnnotationCreation() {
annotationCoordinator.presentAnnotationCreation()
}
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
}
func search(keyword: String) {
searchCoordinator.search(keyword: keyword)
}
@discardableResult
func searchNext() -> Bool {
searchCoordinator.searchNext()
}
@discardableResult
func searchPrevious() -> Bool {
searchCoordinator.searchPrevious()
}
func clearSearch() {
searchCoordinator.clearSearch()
}
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
searchCoordinator.searchPresentation(for: page)
}
func updateReaderChrome() {
chromeCoordinator.updateReaderChrome()
}
func presentSettings() {
chromeCoordinator.presentSettings()
}
func presentTableOfContents() {
chromeCoordinator.presentTableOfContents()
}
func handleBackAction() {
chromeCoordinator.handleBackAction()
}
func loadPublication() {
loadCoordinator.loadPublication()
}
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
bookIdentifier: String,
restoreLocation: RDEPUBLocation?,
bookmarks: [RDEPUBBookmark],
highlights: [RDEPUBHighlight]
) {
loadCoordinator.applyParsedPublication(
parser: parser,
publication: publication,
bookIdentifier: bookIdentifier,
restoreLocation: restoreLocation,
bookmarks: bookmarks,
highlights: highlights
)
}
func paginatePublication(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
}
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
}
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
) {
paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
func finishPagination(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
}
func repaginatePreservingCurrentLocation() {
paginationCoordinator.repaginatePreservingCurrentLocation()
}
func refreshVisibleContentPreservingLocation() {
paginationCoordinator.refreshVisibleContentPreservingLocation()
}
func rebuildExternalTextBook() {
paginationCoordinator.rebuildExternalTextBook()
}
@discardableResult
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
locationCoordinator.restoreReadingLocation(location, animated: animated)
}
func currentVisibleLocation() -> RDEPUBLocation? {
locationCoordinator.currentVisibleLocation()
}
func currentViewportSignature() -> RDEPUBViewportSignature? {
viewportMonitor.currentViewportSignature()
}
func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
) {
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
}
}

View File

@ -0,0 +1,183 @@
import Foundation
final class RDEPUBReaderSearchCoordinator {
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
private var controller: RDEPUBReaderController? {
context.controller
}
func search(keyword: String) {
guard let controller else { return }
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
clearSearch()
return
}
let matches = resolvedSearchMatches(for: normalizedKeyword)
controller.searchState = RDEPUBSearchState(
keyword: normalizedKeyword,
matches: matches,
currentMatchIndex: matches.isEmpty ? nil : 0
)
notifySearchStateChanged()
if matches.isEmpty {
controller.refreshVisibleContentPreservingLocation()
} else {
_ = navigateToCurrentSearchMatch(animated: false)
}
}
@discardableResult
func searchNext() -> Bool {
advanceSearch(by: 1)
}
@discardableResult
func searchPrevious() -> Bool {
advanceSearch(by: -1)
}
func clearSearch() {
guard let controller else { return }
controller.searchState = nil
notifySearchStateChanged()
controller.refreshVisibleContentPreservingLocation()
}
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
guard let controller else { return nil }
guard let searchState = controller.searchState,
let publication = controller.publication else {
return nil
}
let pageHrefs: [String]
if let fixedSpread = page.fixedSpread {
pageHrefs = fixedSpread.resources.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
} else if publication.spine.indices.contains(page.spineIndex) {
pageHrefs = [
publication.resourceResolver.normalizedHref(publication.spine[page.spineIndex].href)
?? publication.spine[page.spineIndex].href
]
} else {
pageHrefs = []
}
guard !pageHrefs.isEmpty else {
return nil
}
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 {
(publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == href
}.count
let activeLocalMatchIndex = normalizedCurrentHref == href ? currentMatch?.localMatchIndex : nil
return RDEPUBSearchPresentationResource(
href: href,
matchCount: matchCount,
activeLocalMatchIndex: activeLocalMatchIndex
)
}
return RDEPUBSearchPresentation(keyword: searchState.keyword, resources: resources)
}
private func resolvedSearchMatches(for keyword: String) -> [RDEPUBSearchMatch] {
guard let controller else { return [] }
if let textBook = controller.textBook, let publication = controller.publication {
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
}
if let parser = controller.parser, let publication = controller.publication {
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
}
return []
}
private func advanceSearch(by delta: Int) -> Bool {
guard let controller else { return false }
guard var searchState = controller.searchState, !searchState.matches.isEmpty else {
return false
}
let currentIndex = searchState.currentMatchIndex ?? 0
let nextIndex = (currentIndex + delta + searchState.matches.count) % searchState.matches.count
searchState.currentMatchIndex = nextIndex
controller.searchState = searchState
notifySearchStateChanged()
return navigateToCurrentSearchMatch(animated: true)
}
private func notifySearchStateChanged() {
guard let controller else { return }
controller.delegate?.epubReader(controller, didUpdateSearchResult: controller.searchState?.result)
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: controller.searchState?.currentMatch)
}
private func navigateToCurrentSearchMatch(animated: Bool) -> Bool {
guard let controller else { return false }
guard let searchMatch = controller.searchState?.currentMatch else {
controller.refreshVisibleContentPreservingLocation()
return false
}
if let targetPageNumber = pageNumber(for: searchMatch),
controller.readerView.currentPage == targetPageNumber - 1 {
controller.refreshVisibleContentPreservingLocation()
return true
}
let location = RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: searchMatch.href,
progression: searchMatch.progression,
lastProgression: searchMatch.progression,
fragment: nil,
rangeAnchor: searchMatch.rangeAnchor
)
return controller.restoreReadingLocation(location, animated: animated)
}
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
guard let controller else { return nil }
if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) {
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
return pageNumber
}
if let rangeLocation = searchMatch.rangeLocation,
let page = chapterData.page(containing: rangeLocation) {
return page.absolutePageIndex + 1
}
}
let location = RDEPUBLocation(
bookIdentifier: controller.currentBookIdentifier,
href: searchMatch.href,
progression: searchMatch.progression,
lastProgression: searchMatch.progression,
fragment: nil,
rangeAnchor: searchMatch.rangeAnchor
)
if let textBook = controller.textBook, let publication = controller.publication {
return textBook.pageNumber(
for: location,
resolver: publication.resourceResolver,
bookIdentifier: controller.currentBookIdentifier
)
}
return controller.readingSession?.pageIndex(
for: location,
bookIdentifier: controller.currentBookIdentifier
).map { $0 + 1 }
}
}

View File

@ -0,0 +1,125 @@
import UIKit
final class RDEPUBReaderViewportMonitor {
private unowned let context: RDEPUBReaderContext
private var lastAppliedViewportSignature: RDEPUBViewportSignature?
private var pendingViewportChangeReason: RDEPUBViewportChangeReason?
private var pendingPresentationRestoreLocation: RDEPUBLocation?
private var isWaitingForViewportTransitionCompletion = false
init(context: RDEPUBReaderContext) {
self.context = context
}
private var controller: RDEPUBReaderController? {
context.controller
}
func viewDidLayoutSubviews() {
guard let controller else { return }
guard let viewportSignature = currentViewportSignature() else { return }
if !controller.didStartInitialLoad {
lastAppliedViewportSignature = viewportSignature
controller.startInitialLoadIfNeeded()
return
}
guard controller.publication != nil || controller.isExternalTextBook else {
lastAppliedViewportSignature = viewportSignature
return
}
guard !isWaitingForViewportTransitionCompletion else {
return
}
handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature)
}
func viewWillTransition(with coordinator: UIViewControllerTransitionCoordinator) {
guard let controller else { return }
guard controller.didStartInitialLoad else { return }
pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
isWaitingForViewportTransitionCompletion = true
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
guard let self, let controller = self.controller else { return }
self.isWaitingForViewportTransitionCompletion = false
controller.view.layoutIfNeeded()
self.handleViewportChangeIfNeeded(reason: .orientationTransition)
}
}
func resetForReload() {
lastAppliedViewportSignature = currentViewportSignature()
pendingViewportChangeReason = nil
pendingPresentationRestoreLocation = nil
isWaitingForViewportTransitionCompletion = false
}
func consumePendingPresentationRestoreLocation() -> RDEPUBLocation? {
defer { pendingPresentationRestoreLocation = nil }
return pendingPresentationRestoreLocation
}
func capturePendingPresentationRestoreLocation() {
guard let controller else { return }
pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
}
func processPendingChangeAfterPagination() {
guard let pendingReason = pendingViewportChangeReason else { return }
pendingViewportChangeReason = nil
DispatchQueue.main.async { [weak self] in
self?.handleViewportChangeIfNeeded(reason: pendingReason)
}
}
func currentViewportSignature() -> RDEPUBViewportSignature? {
guard let controller else { return nil }
let containerSize = controller.readerView.bounds.size == .zero ? controller.view.bounds.size : controller.readerView.bounds.size
guard containerSize.width > 0, containerSize.height > 0 else { return nil }
let insets = controller.view.safeAreaInsets
return RDEPUBViewportSignature(
width: containerSize.width,
height: containerSize.height,
safeTop: insets.top,
safeLeft: insets.left,
safeBottom: insets.bottom,
safeRight: insets.right
)
}
func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
) {
guard let controller else { return }
guard controller.didStartInitialLoad,
let signature = viewportSignature ?? currentViewportSignature() else {
return
}
if controller.isRepaginating {
pendingViewportChangeReason = reason
return
}
if let lastAppliedViewportSignature,
!signature.differsSignificantly(from: lastAppliedViewportSignature) {
return
}
lastAppliedViewportSignature = signature
if controller.isExternalTextBook {
controller.rebuildExternalTextBook()
return
}
guard controller.publication != nil else { return }
controller.repaginatePreservingCurrentLocation()
}
}

View File

@ -0,0 +1,30 @@
import UIKit
/// UITextView UIMenuItem
final class RDEPUBSelectableTextView: UITextView {
///
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
switch action {
case #selector(rd_copy(_:)),
#selector(rd_highlight(_:)),
#selector(rd_annotate(_:)):
return selectedRange.location != NSNotFound && selectedRange.length > 0
default:
return false
}
}
@objc func rd_copy(_ sender: Any?) {
onSelectionAction?(.copy)
}
@objc func rd_highlight(_ sender: Any?) {
onSelectionAction?(.highlight)
}
@objc func rd_annotate(_ sender: Any?) {
onSelectionAction?(.annotate)
}
}

View File

@ -36,7 +36,7 @@ struct RDEPUBTextOverlayDecoration {
///
///
/// 使 Core Graphics 线
final class RDEPUBSelectionOverlayView: UIView {
class RDEPUBSelectionOverlayView: UIView {
private(set) var page: RDEPUBTextPage?
private var snapshot: RDEPUBPageLayoutSnapshot?
private(set) var selectionRange: NSRange?

View File

@ -0,0 +1,136 @@
import UIKit
///
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
func applyHighlights(
_ highlights: [RDEPUBHighlight],
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
contentBaseOffset: Int
) {
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
for highlight in highlights 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 relativeRange = NSRange(
location: overlapStart - contentBaseOffset,
length: overlapEnd - overlapStart
)
switch highlight.style {
case .highlight:
content.addAttribute(
.backgroundColor,
value: UIColor(rdHexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
range: relativeRange
)
case .underline:
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
if let color = UIColor(rdHexString: highlight.color, alpha: 1) {
content.addAttribute(.underlineColor, value: color, range: relativeRange)
}
}
}
}
func applySearchHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
searchState: RDEPUBSearchState?,
contentBaseOffset: Int
) {
guard let searchState else { return }
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)
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
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 relativeRange = NSRange(location: Int(overlapStart - contentBaseOffset), length: Int(overlapEnd - overlapStart))
let color = match == searchState.currentMatch ? activeColor : normalColor
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
}
}
func buildDecorations(
page: RDEPUBTextPage,
highlights: [RDEPUBHighlight],
searchState: RDEPUBSearchState?,
interactionController: RDEPUBPageInteractionController
) -> (background: [RDEPUBTextOverlayDecoration], foreground: [RDEPUBTextOverlayDecoration]) {
var background: [RDEPUBTextOverlayDecoration] = []
var foreground: [RDEPUBTextOverlayDecoration] = []
let pageRange = absoluteOffsetRange(for: page)
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
if let searchState {
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))
}
}
for highlight in highlights 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(rdHexString: 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)
}
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
let lowerBound = page.pageStartOffset
let upperBound = page.pageEndOffset + 1
return lowerBound..<max(upperBound, lowerBound)
}
}

View File

@ -0,0 +1,438 @@
import UIKit
import Foundation
#if canImport(DTCoreText)
import DTCoreText
#endif
// MARK: -
///
///
protocol RDEPUBTextContentViewDelegate: AnyObject {
///
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
/// //
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
}
// MARK: -
/// EPUB
///
/// 1. DTCoreText CoreText
/// 2. 退 UITextView attributedText
///
///
final class RDEPUBTextContentView: UIView {
private var contentInsets: UIEdgeInsets = .zero
private var currentPage: RDEPUBTextPage?
weak var delegate: RDEPUBTextContentViewDelegate?
#if canImport(DTCoreText)
private let coreTextContentView: RDEPUBTextPageRenderView = {
let view = RDEPUBTextPageRenderView()
view.backgroundColor = .clear
view.isOpaque = false
return view
}()
private var coreTextDisplayContent: NSAttributedString?
private var coreTextDisplayRange: NSRange?
#endif
private let interactionController = RDEPUBPageInteractionController()
private let selectionController = RDEPUBTextSelectionController()
private let backgroundOverlayView: RDEPUBTextPageDecorationView = {
let view = RDEPUBTextPageDecorationView()
return view
}()
private let overlayView: RDEPUBTextAnnotationOverlay = {
let view = RDEPUBTextAnnotationOverlay()
return view
}()
private let textView: RDEPUBSelectableTextView = {
let view = RDEPUBSelectableTextView()
view.isEditable = false
view.isScrollEnabled = false
view.isSelectable = true
view.backgroundColor = .clear
view.textContainerInset = .zero
view.textContainer.lineFragmentPadding = 0
return view
}()
private let coverImageView: UIImageView = {
let view = UIImageView()
view.contentMode = .scaleAspectFit
view.isHidden = true
return view
}()
private let pageNumberLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 13)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(coverImageView)
#if canImport(DTCoreText)
addSubview(backgroundOverlayView)
addSubview(coreTextContentView)
#endif
addSubview(overlayView)
addSubview(textView)
addSubview(pageNumberLabel)
textView.delegate = selectionController
textView.onSelectionAction = { [weak self] action in
guard let self else { return }
self.delegate?.textContentView(self, didRequestSelectionAction: action)
}
selectionController.onSelectionChanged = { [weak self] selection in
guard let self else { return }
self.delegate?.textContentView(self, didChangeSelection: selection)
}
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 selectionController.canPerformSelectionAction(in: overlayView)
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)
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
pageNumberLabel.frame = CGRect(
x: bounds.width - labelSize.width - 24,
y: bounds.height - labelSize.height - 20,
width: labelSize.width,
height: labelSize.height
)
}
func configure(
page: RDEPUBTextPage,
pageNumber: Int,
totalPages: Int,
configuration: RDEPUBReaderConfiguration,
highlights: [RDEPUBHighlight] = [],
searchState: RDEPUBSearchState? = nil
) {
currentPage = page
contentInsets = configuration.reflowableContentInsets
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
if configureCoverIfNeeded(for: page) {
#if canImport(DTCoreText)
coreTextContentView.isHidden = true
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
textView.isHidden = true
textView.isUserInteractionEnabled = false
#endif
textView.attributedText = nil
textView.selectedRange = NSRange(location: 0, length: 0)
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
return
}
coverImageView.isHidden = true
coverImageView.image = nil
let selectionContent = normalizedPageContent(from: page)
let selectionRange = NSRange(location: 0, length: selectionContent.length)
selectionContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: selectionRange
)
#if canImport(DTCoreText)
let displayContent = normalizedPageContent(from: page)
let fullRange = NSRange(location: 0, length: displayContent.length)
displayContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: fullRange
)
coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear
coreTextDisplayContent = displayContent
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
textView.isHidden = true
textView.isUserInteractionEnabled = false
textView.attributedText = nil
updateCoreTextLayoutFrameIfNeeded()
#else
overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
overlayView.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) = overlayView.buildDecorations(
page: page,
highlights: highlights,
searchState: searchState,
interactionController: interactionController
)
backgroundOverlayView.applyDecorations(bgDecorations)
overlayView.applyDecorations(fgDecorations)
#endif
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
}
func clearSelection() {
selectionController.clearSelection(
textView: textView,
overlayView: overlayView,
backgroundOverlayView: backgroundOverlayView
)
}
// MARK: - Gesture Handling
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
selectionController.handleLongPress(
gesture,
page: currentPage,
overlayView: overlayView,
interactionController: interactionController
)
if gesture.state == .ended {
showSelectionMenuIfNeeded()
}
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
selectionController.handleTap(
textView: textView,
overlayView: overlayView,
backgroundOverlayView: backgroundOverlayView
)
}
@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 showSelectionMenuIfNeeded() {
#if canImport(DTCoreText)
selectionController.showSelectionMenuIfNeeded(
in: self,
overlayView: overlayView,
interactionController: interactionController,
copyAction: #selector(RDEPUBTextContentView.rd_copy(_:)),
highlightAction: #selector(RDEPUBTextContentView.rd_highlight(_:)),
annotateAction: #selector(RDEPUBTextContentView.rd_annotate(_:))
)
#endif
}
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
guard page.pageIndexInChapter == 0,
page.href.lowercased().contains("cover"),
let image = coverImage(from: page.content) else {
return false
}
coverImageView.image = image
coverImageView.isHidden = false
#if canImport(DTCoreText)
coreTextContentView.isHidden = true
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
textView.isHidden = true
textView.isUserInteractionEnabled = false
#endif
textView.attributedText = nil
return true
}
private func coverImage(from content: NSAttributedString) -> UIImage? {
guard content.length > 0 else { return nil }
var resolvedImage: UIImage?
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
guard let image = image(from: value) else { return }
resolvedImage = image
stop.pointee = true
}
return resolvedImage
}
private func image(from attachmentValue: Any?) -> UIImage? {
#if canImport(DTCoreText)
if let attachment = attachmentValue as? DTTextAttachment,
let url = attachment.contentURL {
return UIImage(contentsOfFile: url.path)
}
#endif
if let attachment = attachmentValue as? NSTextAttachment {
if let image = attachment.image {
return image
}
if let data = attachment.contents {
return UIImage(data: data)
}
if let fileWrapper = attachment.fileWrapper,
let data = fileWrapper.regularFileContents {
return UIImage(data: data)
}
}
return nil
}
private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
let proxy = NSMutableAttributedString(attributedString: content)
let fullRange = NSRange(location: 0, length: proxy.length)
proxy.removeAttribute(.backgroundColor, range: fullRange)
proxy.addAttribute(.foregroundColor, value: UIColor.clear, range: fullRange)
var attachmentRanges: [NSRange] = []
proxy.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
guard value != nil else { return }
attachmentRanges.append(range)
}
for range in attachmentRanges.reversed() {
let replacement = NSAttributedString(
string: String(repeating: " ", count: max(range.length, 1)),
attributes: [
.font: proxy.attribute(.font, at: max(range.location - 1, 0), effectiveRange: nil) as Any,
.foregroundColor: UIColor.clear
]
)
proxy.replaceCharacters(in: range, with: replacement)
}
return proxy
}
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
let content = NSMutableAttributedString(attributedString: page.content)
guard shouldNormalizeContinuationParagraph(for: page) else {
return content
}
let text = content.string as NSString
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
guard firstParagraphRange.length > 0 else {
return content
}
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
guard let style = value as? NSParagraphStyle else { return }
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
mutableStyle.firstLineHeadIndent = mutableStyle.headIndent
mutableStyle.paragraphSpacingBefore = 0
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range)
}
return content
}
private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
let pageStart = page.pageStartOffset
guard pageStart > 0, pageStart < page.chapterContent.length else {
return false
}
let chapterText = page.chapterContent.string as NSString
guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else {
return false
}
return !CharacterSet.newlines.contains(previousScalar)
}
#if canImport(DTCoreText)
private func updateCoreTextLayoutFrameIfNeeded() {
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
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
}

View File

@ -0,0 +1,4 @@
import UIKit
///
final class RDEPUBTextPageDecorationView: RDEPUBSelectionOverlayView {}

View File

@ -0,0 +1,41 @@
import UIKit
#if canImport(DTCoreText)
import DTCoreText
/// DTCoreText Core Text
/// DTCoreText UIView UITextView
final class RDEPUBTextPageRenderView: UIView {
var layoutFrame: DTCoreTextLayoutFrame? {
didSet {
setNeedsDisplay()
}
}
var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
didSet {
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
contentMode = .redraw
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(),
let layoutFrame else { return }
context.saveGState()
layoutFrame.draw(in: context, options: drawOptions)
context.restoreGState()
}
}
#endif

View File

@ -0,0 +1,189 @@
import UIKit
///
final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
private var isSelectionFromInteraction = false
private var selectionAnchorPoint: CGPoint?
private var selectionMenuAnchorRect: CGRect?
var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
func canPerformSelectionAction(in overlayView: RDEPUBSelectionOverlayView) -> Bool {
overlayView.selectionRange?.length ?? 0 > 0
}
func clearSelection(
textView: UITextView,
overlayView: RDEPUBSelectionOverlayView,
backgroundOverlayView: RDEPUBSelectionOverlayView? = nil
) {
textView.selectedRange = NSRange(location: 0, length: 0)
overlayView.clearSelection()
backgroundOverlayView?.clearSelection()
selectionAnchorPoint = nil
selectionMenuAnchorRect = nil
isSelectionFromInteraction = false
UIMenuController.shared.setMenuVisible(false, animated: true)
onSelectionChanged?(nil)
}
func handleLongPress(
_ gesture: UILongPressGestureRecognizer,
page: RDEPUBTextPage?,
overlayView: RDEPUBSelectionOverlayView,
interactionController: RDEPUBPageInteractionController
) {
let point = gesture.location(in: overlayView)
switch gesture.state {
case .began:
selectionAnchorPoint = point
isSelectionFromInteraction = true
handleSelectionFromInteraction(
point: point,
anchorPoint: nil,
page: page,
overlayView: overlayView,
interactionController: interactionController
)
case .changed:
guard let anchor = selectionAnchorPoint else { return }
handleSelectionFromInteraction(
point: point,
anchorPoint: anchor,
page: page,
overlayView: overlayView,
interactionController: interactionController
)
case .ended:
isSelectionFromInteraction = false
default:
break
}
}
func handleTap(
textView: UITextView,
overlayView: RDEPUBSelectionOverlayView,
backgroundOverlayView: RDEPUBSelectionOverlayView? = nil
) {
clearSelection(
textView: textView,
overlayView: overlayView,
backgroundOverlayView: backgroundOverlayView
)
}
func showSelectionMenuIfNeeded(
in hostView: UIView,
overlayView: RDEPUBSelectionOverlayView,
interactionController: RDEPUBPageInteractionController,
copyAction: Selector,
highlightAction: Selector,
annotateAction: Selector
) {
guard overlayView.selectionRange?.length ?? 0 > 0,
let anchorRect = selectionMenuAnchorRect ?? overlayView.selectionRange.flatMap({ interactionController.menuAnchorRect(for: $0) }) else {
return
}
hostView.becomeFirstResponder()
let menuRect = overlayView.convert(anchorRect, to: hostView)
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "拷贝", action: copyAction),
UIMenuItem(title: "高亮", action: highlightAction),
UIMenuItem(title: "批注", action: annotateAction)
]
menuController.setTargetRect(menuRect, in: hostView)
menuController.setMenuVisible(true, animated: true)
}
func textViewDidChangeSelection(_ textView: UITextView, page: RDEPUBTextPage?) {
guard !isSelectionFromInteraction else { return }
guard let page else {
onSelectionChanged?(nil)
return
}
let selectedRange = textView.selectedRange
guard selectedRange.location != NSNotFound,
selectedRange.length > 0,
let attributedText = textView.attributedText else {
onSelectionChanged?(nil)
return
}
let source = attributedText.string as NSString
let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedText.isEmpty else {
onSelectionChanged?(nil)
return
}
let globalStart = page.pageStartOffset + selectedRange.location
let globalEnd = globalStart + selectedRange.length
let totalLength = max(page.chapterContent.length - 1, 1)
let selection = RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(globalStart) / Double(totalLength),
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
fragment: nil
),
text: selectedText,
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
)
onSelectionChanged?(selection)
}
private func handleSelectionFromInteraction(
point: CGPoint,
anchorPoint: CGPoint?,
page: RDEPUBTextPage?,
overlayView: RDEPUBSelectionOverlayView,
interactionController: RDEPUBPageInteractionController
) {
guard let page else { return }
let range: NSRange?
if let anchorPoint {
range = interactionController.selectionRange(from: anchorPoint, 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)
onSelectionChanged?(makeSelection(from: range, page: page))
}
private func makeSelection(from range: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? {
let source = page.chapterContent.string as NSString
let selectedText = source.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedText.isEmpty else {
return nil
}
let chapterLength = max(page.chapterContent.length - 1, 1)
let chapterStart = max(range.location, 0)
let chapterEnd = max(chapterStart + range.length - 1, chapterStart)
return RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(chapterStart) / Double(chapterLength),
lastProgression: Double(chapterEnd) / Double(chapterLength),
fragment: nil
),
text: selectedText,
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: range.location, end: range.location + range.length).jsonString()
)
}
}

View File

@ -0,0 +1,59 @@
import UIKit
/// UIPageViewController
///
/// RDReaderView pageCurl
struct RDReaderPagingController {
/// pageCurl
struct PageTransitionRequest: Equatable {
let pageNum: Int
let animated: Bool
}
/// pageCurl
var pendingTransitionRequest: PageTransitionRequest?
///
var isTransitioning: Bool = false
/// UI
var didBuildUI = false
// MARK: - PageViewController
/// spine UIPageViewController
static func createPageViewController(isDualPage: Bool) -> UIPageViewController {
let options: [UIPageViewController.OptionsKey: Any]?
if isDualPage {
options = [.spineLocation: NSNumber(value: UIPageViewController.SpineLocation.mid.rawValue)]
} else {
options = nil
}
let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: options)
pageVC.isDoubleSided = isDualPage
return pageVC
}
// MARK: -
/// pageCurl
mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDReaderView.DisplayType) -> Bool {
guard currentDisplayType == .pageCurl, isTransitioning else { return false }
pendingTransitionRequest = request
return true
}
/// pageCurl
mutating func finishPageCurlTransition() -> PageTransitionRequest? {
isTransitioning = false
guard let pending = pendingTransitionRequest else { return nil }
pendingTransitionRequest = nil
return pending
}
///
mutating func resetPendingState() {
isTransitioning = false
pendingTransitionRequest = nil
}
}

View File

@ -0,0 +1,256 @@
import UIKit
final class RDReaderPreloadController {
var radius: Int = 1
private let preloadHostView = UIView()
private var preloadedPageViews: [Int: UIView] = [:]
private var pageCurlCachedViews: [Int: UIView] = [:]
private var cacheSignature: CacheSignature?
struct Environment {
let displayType: RDReaderView.DisplayType
let isLandscape: Bool
let pagesPerScreen: Int
let boundsSize: CGSize
let landscapeDualPageEnabled: Bool
let coverPageIndex: Int?
let totalPages: Int
let spreadResolver: RDReaderSpreadResolver
}
private struct CacheSignature: Equatable {
let displayType: RDReaderView.DisplayType
let isLandscape: Bool
let pagesPerScreen: Int
let boundsSize: CGSize
}
func setHostFrame(_ frame: CGRect) {
preloadHostView.frame = frame
}
func ensureHostView(in parentView: UIView) {
guard preloadHostView.superview == nil else { return }
preloadHostView.isHidden = true
preloadHostView.isUserInteractionEnabled = false
preloadHostView.clipsToBounds = true
preloadHostView.frame = parentView.bounds
preloadHostView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
parentView.insertSubview(preloadHostView, at: 0)
}
func initializeSignature(_ environment: Environment) {
cacheSignature = currentCacheSignature(environment)
}
func invalidate(environment: Environment) {
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
pageCurlCachedViews.removeAll()
preloadedPageViews.removeAll()
cacheSignature = currentCacheSignature(environment)
}
func pageViewForDisplay(
pageNum: Int,
environment: Environment,
contentViewProvider: (Int, UIView?) -> UIView?
) -> UIView {
let reusableView = detachedReusablePageView(for: pageNum)
let view = contentViewProvider(pageNum, reusableView) ?? reusableView ?? UIView()
pageCurlCachedViews[pageNum] = view
return view
}
func takePreloadedView(for pageNum: Int) -> UIView? {
let preloaded = preloadedPageViews.removeValue(forKey: pageNum)
preloaded?.removeFromSuperview()
return preloaded
}
func prime(
around pageNum: Int,
preferredForward: Bool? = nil,
parentView: UIView,
environment: Environment,
contentViewProvider: (Int, UIView?) -> UIView?
) {
refreshCacheSignatureIfNeeded(environment)
let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment)
let keepSet = Set(targets).union(visiblePageNumbers(for: pageNum, environment: environment))
trimCachedPageViews(keeping: keepSet)
guard !targets.isEmpty else { return }
ensureHostView(in: parentView)
preloadHostView.frame = parentView.bounds
for targetPage in targets {
let existing = detachedReusablePageView(for: targetPage)
let contentView = contentViewProvider(targetPage, existing) ?? existing ?? UIView()
preloadedPageViews[targetPage] = contentView
if contentView.superview !== preloadHostView {
contentView.removeFromSuperview()
preloadHostView.addSubview(contentView)
}
contentView.frame = preloadHostView.bounds
}
}
private func currentCacheSignature(_ environment: Environment) -> CacheSignature {
CacheSignature(
displayType: environment.displayType,
isLandscape: environment.isLandscape,
pagesPerScreen: environment.pagesPerScreen,
boundsSize: environment.boundsSize
)
}
private func refreshCacheSignatureIfNeeded(_ environment: Environment) {
let signature = currentCacheSignature(environment)
if cacheSignature != signature {
invalidate(environment: environment)
} else if cacheSignature == nil {
cacheSignature = signature
}
}
private func shouldCachePage(_ pageNum: Int, environment: Environment) -> Bool {
pageNum >= 0
&& pageNum != RDReaderView.blankPageNum
&& pageNum != RDReaderView.blankEndPageNum
&& pageNum < environment.totalPages
}
private func isDualPage(environment: Environment) -> Bool {
environment.landscapeDualPageEnabled
&& environment.isLandscape
&& environment.displayType != .verticalScroll
}
private func spreadStart(for pageNum: Int, environment: Environment) -> Int? {
guard shouldCachePage(pageNum, environment: environment) else { return nil }
guard isDualPage(environment: environment) else { return pageNum }
return environment.spreadResolver.dualPagePair(
for: pageNum,
totalPages: environment.totalPages,
coverPageIndex: environment.coverPageIndex
).left
}
private func spreadPageNumbers(startingAt pageNum: Int, environment: Environment) -> Set<Int> {
guard shouldCachePage(pageNum, environment: environment) else { return [] }
guard isDualPage(environment: environment) else { return [pageNum] }
let pair = environment.spreadResolver.dualPagePair(
for: pageNum,
totalPages: environment.totalPages,
coverPageIndex: environment.coverPageIndex
)
var pages: Set<Int> = [pair.left]
if let right = pair.right, shouldCachePage(right, environment: environment) {
pages.insert(right)
}
return pages
}
private func adjacentSpreadStart(from pageNum: Int, forward: Bool, environment: Environment) -> Int? {
guard environment.totalPages > 0,
let spreadStart = spreadStart(for: pageNum, environment: environment) else {
return nil
}
guard isDualPage(environment: environment) else {
let target = forward ? spreadStart + 1 : spreadStart - 1
return shouldCachePage(target, environment: environment) ? target : nil
}
if forward {
return environment.spreadResolver.adjacentDualPage(
from: spreadStart,
totalPages: environment.totalPages,
coverPageIndex: environment.coverPageIndex,
forward: true
)
}
let pair = environment.spreadResolver.dualPagePair(
for: spreadStart,
totalPages: environment.totalPages,
coverPageIndex: environment.coverPageIndex
)
let prevEnd = pair.left - 1
guard prevEnd >= 0 else { return nil }
return environment.spreadResolver.dualPagePair(
for: prevEnd,
totalPages: environment.totalPages,
coverPageIndex: environment.coverPageIndex
).left
}
private func visiblePageNumbers(for anchorPage: Int, environment: Environment) -> Set<Int> {
spreadPageNumbers(startingAt: anchorPage, environment: environment)
}
private func detachedReusablePageView(for pageNum: Int) -> UIView? {
if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) {
preloaded.removeFromSuperview()
return preloaded
}
guard let cached = pageCurlCachedViews[pageNum], cached.superview == nil else {
return nil
}
pageCurlCachedViews.removeValue(forKey: pageNum)
return cached
}
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?, environment: Environment) -> [Int] {
guard environment.totalPages > 0,
shouldCachePage(pageNum, environment: environment) else {
return []
}
var targets = Set<Int>()
var previousAnchor = pageNum
for _ in 0..<max(radius, 0) {
guard let previous = adjacentSpreadStart(from: previousAnchor, forward: false, environment: environment) else { break }
targets.formUnion(spreadPageNumbers(startingAt: previous, environment: environment))
previousAnchor = previous
}
var nextAnchor = pageNum
for _ in 0..<max(radius, 0) {
guard let next = adjacentSpreadStart(from: nextAnchor, forward: true, environment: environment) else { break }
targets.formUnion(spreadPageNumbers(startingAt: next, environment: environment))
nextAnchor = next
}
if let preferredForward,
let edgeAnchor = preferredForward ? adjacentSpreadStart(from: nextAnchor, forward: true, environment: environment)
: adjacentSpreadStart(from: previousAnchor, forward: false, environment: environment) {
targets.formUnion(spreadPageNumbers(startingAt: edgeAnchor, environment: environment))
}
return targets.sorted()
}
}

View File

@ -0,0 +1,72 @@
import Foundation
struct RDReaderSpreadResolver {
func isFullScreenPage(
_ pageNum: Int,
landscapeDualPageEnabled: Bool,
isLandscape: Bool,
coverPageIndex: Int?
) -> Bool {
guard landscapeDualPageEnabled, isLandscape, let coverIndex = coverPageIndex else { return false }
return pageNum == coverIndex
}
func dualPagePair(
for pageNum: Int,
totalPages: Int,
coverPageIndex: Int?
) -> (left: Int, right: Int?) {
if let coverIndex = coverPageIndex {
if pageNum == coverIndex {
return (coverIndex, nil)
}
let adjustedIndex = pageNum - (coverIndex + 1)
let pairStart = coverIndex + 1 + (adjustedIndex / 2) * 2
let left = pairStart
let right = pairStart + 1 < totalPages ? pairStart + 1 : nil
return (left, right)
}
let left = (pageNum / 2) * 2
let right = left + 1 < totalPages ? left + 1 : nil
return (left, right)
}
func adjacentDualPage(
from pageNum: Int,
totalPages: Int,
coverPageIndex: Int?,
forward: Bool
) -> Int? {
let pair = dualPagePair(for: pageNum, totalPages: totalPages, coverPageIndex: coverPageIndex)
if forward {
let nextStart = (pair.right ?? pair.left) + 1
return nextStart < totalPages ? nextStart : nil
}
let prevEnd = pair.left - 1
guard prevEnd >= 0 else { return nil }
return dualPagePair(for: prevEnd, totalPages: totalPages, coverPageIndex: coverPageIndex).left
}
func nextPage(
from currentPage: Int,
totalPages: Int,
pagesPerScreen: Int,
coverPageIndex: Int?,
forward: Bool
) -> Int? {
if pagesPerScreen > 1 {
return adjacentDualPage(
from: currentPage,
totalPages: totalPages,
coverPageIndex: coverPageIndex,
forward: forward
)
}
let target = currentPage + (forward ? 1 : -1)
guard target >= 0, target < totalPages else { return nil }
return target
}
}

View File

@ -0,0 +1,27 @@
import CoreGraphics
struct RDReaderTapRegionHandler {
func resolveTapEvent(
point: CGPoint,
viewFrame: CGRect,
isToolViewVisible: Bool
) -> RDReaderView.TapEvent {
let leftFrame = CGRect(x: 0, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
let centerFrame = CGRect(x: viewFrame.width / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
let rightFrame = CGRect(x: viewFrame.width * 2 / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
if leftFrame.contains(point) {
return isToolViewVisible ? .center : .left
}
if centerFrame.contains(point) {
return .center
}
if rightFrame.contains(point) {
return isToolViewVisible ? .center : .right
}
return .none
}
}

View File

@ -0,0 +1,49 @@
import UIKit
/// UICollectionView
///
extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, RDReaderFlowLayoutDataSoure {
/// UICollectionViewCell
/// DataSource cell
/// RTL cell 使
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if let identifer = pageReuseIdentifier(for: indexPath.row) {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifer, for: IndexPath(item: indexPath.row, section: 0)) as! RDReaderContentCell
let preloadedView = preloadController.takePreloadedView(for: indexPath.row)
let reusableView = cell.containerView ?? preloadedView
cell.containerView = contentViewForPage(indexPath.row, reusableView: reusableView)
// RTL cell 使collectionView
if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll {
cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1)
} else {
cell.contentView.transform = .identity
}
return cell
}
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NSStringFromClass(UICollectionViewCell.self), for: indexPath)
return cell
}
/// item
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return numberOfPages()
}
///
public func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) {
if currentPage >= 0, currentPage != pageIndex {
predictedPageDirection = pageIndex >= currentPage
}
currentPage = pageIndex
primePageCache(around: pageIndex, preferredForward: predictedPageDirection)
}
///
/// nil 使
public func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? {
return nil
}
}

View File

@ -0,0 +1,75 @@
import UIKit
///
/// UITableView register/dequeueReusable
extension RDReaderView {
///
public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String) {
contentViews[identifier] = contentView
collectionView.register(RDReaderContentCell.self, forCellWithReuseIdentifier: identifier)
}
///
/// cell
public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView {
if self.currentDisplayType != .pageCurl, let cell = self.collectionView.cellForItem(at: IndexPath(row: pageNum, section: 0)) as? RDReaderContentCell, let containerView = cell.containerView {
return containerView
}
let contentViewClass = contentViews[identifier]
assert(contentViewClass != nil, "请调用register(contentViewcontentViewWithReuseIdentifier")
var contentView = contentViewClass!.init()
return contentView
}
///
/// pageCurl PageViewController CollectionView cell
public func pageContentView(pageNum: Int) -> UIView? {
if currentDisplayType == .pageCurl {
return (self.pageViewController.viewControllers?.first as? RDReaderPageChildViewController)?.contentView
} else {
let cell = collectionView.cellForItem(at: IndexPath(item: pageNum, section: 0)) as? RDReaderContentCell
return cell?.containerView
}
}
///
/// 使退
public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize {
let targetPage = pageNum ?? (currentPage >= 0 ? currentPage : nil)
if let targetPage,
let contentView = pageContentView(pageNum: targetPage),
contentView.bounds.width > 0,
contentView.bounds.height > 0 {
return contentView.bounds.size
}
if currentDisplayType == .pageCurl {
if let childViewController = pageViewController.viewControllers?.first as? RDReaderPageChildViewController,
childViewController.view.bounds.width > 0,
childViewController.view.bounds.height > 0 {
return childViewController.view.bounds.size
}
if pageViewController.view.bounds.width > 0, pageViewController.view.bounds.height > 0 {
return pageViewController.view.bounds.size
}
return bounds.size
}
let containerBounds = collectionView.bounds.size == .zero ? bounds.size : collectionView.bounds.size
guard containerBounds.width > 0, containerBounds.height > 0 else {
return bounds.size
}
switch currentDisplayType {
case .horizontalScroll:
return CGSize(
width: containerBounds.width / CGFloat(max(pagesPerScreen, 1)),
height: containerBounds.height
)
case .verticalScroll:
return containerBounds
case .pageCurl:
return bounds.size
}
}
}

View File

@ -0,0 +1,138 @@
import UIKit
/// UIPageViewController
/// 仿
extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDelegate {
///
///
private func makeSinglePageChildVC(for pageNum: Int) -> RDReaderPageChildViewController {
if pageNum == RDReaderView.blankPageNum || pageNum == RDReaderView.blankEndPageNum {
return RDReaderPageChildViewController(contentView: UIView(), pageNum: pageNum)
}
let contentView = pageViewForDisplay(pageNum: pageNum)
return RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum)
}
/// ""
///
private func nextPageNum(after pageNum: Int, isDualPage: Bool) -> Int? {
let totalPages = numberOfPages()
//
if pageNum == RDReaderView.blankEndPageNum {
return nil
}
if isDualPage, let coverIndex = coverPageIndex {
if pageNum == coverIndex {
return RDReaderView.blankPageNum
}
if pageNum == RDReaderView.blankPageNum {
let firstContent = coverIndex + 1
return firstContent < totalPages ? firstContent : nil
}
}
let next = pageNum + 1
if next < totalPages {
return next
}
// pageNum
if isDualPage {
if let coverIndex = coverPageIndex {
let adjustedIndex = pageNum - (coverIndex + 1)
if adjustedIndex >= 0 && adjustedIndex % 2 == 0 {
return RDReaderView.blankEndPageNum
}
} else {
if pageNum % 2 == 0 {
return RDReaderView.blankEndPageNum
}
}
}
return nil
}
/// ""
///
private func prevPageNum(before pageNum: Int, isDualPage: Bool) -> Int? {
if pageNum == RDReaderView.blankEndPageNum {
let totalPages = numberOfPages()
return totalPages > 0 ? totalPages - 1 : nil
}
if isDualPage, let coverIndex = coverPageIndex {
if pageNum == RDReaderView.blankPageNum {
return coverIndex
}
if pageNum == coverIndex + 1 {
return RDReaderView.blankPageNum
}
}
let prev = pageNum - 1
return prev >= 0 ? prev : nil
}
/// UIPageViewController /
/// RTL before/after before=
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let vc = viewController as? RDReaderPageChildViewController else { return nil }
let isDualPage = landscapeDualPageEnabled && isLandscape
let isRTL = pageDirection == .rightToLeft
let targetNum = isRTL
? nextPageNum(after: vc.pageNum, isDualPage: isDualPage)
: prevPageNum(before: vc.pageNum, isDualPage: isDualPage)
guard let num = targetNum else { return nil }
let targetVC = makeSinglePageChildVC(for: num)
willPreviousTransitionToViewController = targetVC
return targetVC
}
/// UIPageViewController /
/// RTL before/after
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let vc = viewController as? RDReaderPageChildViewController else { return nil }
let isDualPage = landscapeDualPageEnabled && isLandscape
let isRTL = pageDirection == .rightToLeft
let targetNum = isRTL
? prevPageNum(before: vc.pageNum, isDualPage: isDualPage)
: nextPageNum(after: vc.pageNum, isDualPage: isDualPage)
guard let num = targetNum else { return nil }
let targetVC = makeSinglePageChildVC(for: num)
willNextTransitionToViewController = targetVC
return targetVC
}
/// UIPageViewController
/// PageViewController
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed, let firstVC = pageViewController.viewControllers?.first as? RDReaderPageChildViewController {
let pn = firstVC.pageNum
if pn != RDReaderView.blankPageNum && pn != RDReaderView.blankEndPageNum {
currentPage = pn
primePageCache(around: pn, preferredForward: predictedPageDirection)
}
}
predictedPageDirection = nil
finishPageCurlTransition()
}
/// UIPageViewController
///
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
pagingController.isTransitioning = true
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)
}
}
}

View File

@ -0,0 +1,115 @@
import UIKit
/// /
extension RDReaderView {
/// /
func tapCenter() {
refreshToolViewsFromProviderIfNeeded()
print("[Debug] tapCenter called, isShowToolView was: \(isShowToolView), topToolView: \(String(describing: topToolView)), onBack: \(String(describing: (topToolView as? RDEPUBReaderTopToolView)?.onBack))")
isShowToolView = !isShowToolView
if isShowToolView {
if let topToolView = topToolView {
installToolViewIfNeeded(topToolView, position: .top)
layoutIfNeeded()
topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height)
UIView.animate(withDuration: toolViewAnimationDuration) {
topToolView.transform = .identity
}
}
if let bottomToolView = bottomToolView {
installToolViewIfNeeded(bottomToolView, position: .bottom)
layoutIfNeeded()
bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height)
UIView.animate(withDuration: toolViewAnimationDuration) {
bottomToolView.transform = .identity
}
}
collectionView.isUserInteractionEnabled = false
pageViewController.view.isUserInteractionEnabled = false
} else {
if let topToolView = topToolView {
UIView.animate(withDuration: toolViewAnimationDuration, animations: {
topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height)
}) { _ in
topToolView.removeFromSuperview()
}
}
if let bottomToolView = bottomToolView {
UIView.animate(withDuration: toolViewAnimationDuration, animations: {
bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height)
}) { _ in
bottomToolView.removeFromSuperview()
}
}
collectionView.isUserInteractionEnabled = true
pageViewController.view.isUserInteractionEnabled = true
}
}
///
func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool {
if toolView.frame.contains(point) {
return true
}
guard let hitView else { return false }
return hitView === toolView || hitView.isDescendant(of: toolView)
}
enum ToolViewPosition {
case top
case bottom
}
func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition) {
guard toolView.superview !== self else { return }
toolView.removeFromSuperview()
addSubview(toolView)
toolView.translatesAutoresizingMaskIntoConstraints = false
let heightConstraint: NSLayoutConstraint
switch position {
case .top:
topToolViewHeightConstraint?.isActive = false
heightConstraint = toolView.heightAnchor.constraint(equalToConstant: resolvedToolViewHeight(for: .top))
topToolViewHeightConstraint = heightConstraint
NSLayoutConstraint.activate([
toolView.leadingAnchor.constraint(equalTo: leadingAnchor),
toolView.trailingAnchor.constraint(equalTo: trailingAnchor),
toolView.topAnchor.constraint(equalTo: topAnchor),
heightConstraint
])
case .bottom:
bottomToolViewHeightConstraint?.isActive = false
heightConstraint = toolView.heightAnchor.constraint(equalToConstant: resolvedToolViewHeight(for: .bottom))
bottomToolViewHeightConstraint = heightConstraint
NSLayoutConstraint.activate([
toolView.leadingAnchor.constraint(equalTo: leadingAnchor),
toolView.trailingAnchor.constraint(equalTo: trailingAnchor),
toolView.bottomAnchor.constraint(equalTo: bottomAnchor),
heightConstraint
])
}
}
func updateToolViewHeightConstraintsIfNeeded() {
topToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .top)
bottomToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .bottom)
}
private func resolvedToolViewHeight(for position: ToolViewPosition) -> CGFloat {
let contentHeight: CGFloat = 52
switch position {
case .top:
return safeAreaInsets.top + contentHeight
case .bottom:
return safeAreaInsets.bottom + contentHeight
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,90 @@
import UIKit
///
/// RDReaderView
/// RDReaderView
@objc public protocol RDReaderDataSource: NSObjectProtocol {
///
func pageCountOfReaderView(readerView: RDReaderView) -> Int
///
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
/// UICollectionViewCell
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
/// /
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
/// /
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
}
///
/// EPUB ``RDReaderDataSource``便 PDF
@objc public protocol RDReaderPageProvider: NSObjectProtocol {
func numberOfPages(in readerView: RDReaderView) -> Int
func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
@objc optional func pageIdentifier(in readerView: RDReaderView, index: Int) -> String?
@objc optional func readerViewTopChrome(_ readerView: RDReaderView) -> UIView?
@objc optional func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView?
}
///
///
@objc public protocol RDReaderDelegate: NSObjectProtocol {
///
func pageNum(readerView: RDReaderView, pageNum: Int)
///
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
}
public protocol RDReaderPageNavigating: AnyObject {
var currentPage: Int { get }
func reloadPages()
func transition(to page: Int, animated: Bool)
}
// MARK: -
extension RDReaderView {
/// RDReaderView 使
public enum DisplayType {
/// 仿使 UIPageViewController
case pageCurl
/// 使 UICollectionView +
case horizontalScroll
/// 使 UICollectionView
case verticalScroll
}
///
public enum PageDirection {
/// /
case leftToRight
///
case rightToLeft
}
}
// MARK: - Legacy
final class RDReaderLegacyDataSourceAdapter: NSObject, RDReaderPageProvider {
weak var dataSource: RDReaderDataSource?
func numberOfPages(in readerView: RDReaderView) -> Int {
dataSource?.pageCountOfReaderView(readerView: readerView) ?? 0
}
func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
dataSource?.pageContentView(readerView: readerView, pageNum: index, containerView: reusableView) ?? UIView()
}
func pageIdentifier(in readerView: RDReaderView, index: Int) -> String? {
dataSource?.pageIdentifier(readerView: readerView, pageNum: index)
}
func readerViewTopChrome(_ readerView: RDReaderView) -> UIView? {
dataSource?.topToolView?(readerView: readerView)
}
func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView? {
dataSource?.bottomToolView?(readerView: readerView)
}
}