refactor: 添加中文注释 + 优化模块结构

- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级)
- 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行)
- 根目录翻页容器文件移入 ReaderView/ 目录
- Resources/ 移入 EPUBCore/Resources/(与使用者归属一致)
- RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖)
- RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层)
- 更新 podspec 资源路径
This commit is contained in:
shen 2026-05-25 10:19:14 +08:00
parent 5af90c1148
commit 54798ba578
88 changed files with 2492 additions and 4469 deletions

View File

@ -12,7 +12,7 @@ Pod::Spec.new do |s|
s.license = "MIT"
s.source_files = 'Sources/RDReaderView/**/*.{swift}'
s.resource_bundles = {
'RDReaderViewAssets' => ['Sources/RDReaderView/Resources/**/*']
'RDReaderViewAssets' => ['Sources/RDReaderView/EPUBCore/Resources/**/*']
}
s.dependency 'ZIPFoundation', '~> 0.9'
s.dependency 'DTCoreText'

View File

@ -1,9 +1,18 @@
// RDEPUBAssetRepository.swift
// EPUBCore
// EPUB JS HTML
// {{token}}
import Foundation
/// EPUB
enum RDEPUBAsset: String {
/// JS epub-bridge.js WebView -JS
case bridgeScript = "epub-bridge"
/// HTML epub-fixed-layout.html pre-paginated EPUB
case fixedLayoutTemplate = "epub-fixed-layout"
///
var fileExtension: String {
switch self {
case .bridgeScript:
@ -14,7 +23,13 @@ enum RDEPUBAsset: String {
}
}
///
enum RDEPUBAssetRepository {
/// {{key}}
/// - Parameters:
/// - asset:
/// - replacements: key value
/// - Returns:
static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String {
guard let url = resourceBundle.url(forResource: asset.rawValue, withExtension: asset.fileExtension),
var content = try? String(contentsOf: url, encoding: .utf8) else {
@ -28,6 +43,7 @@ enum RDEPUBAssetRepository {
return content
}
/// Bundle使 bundle宿 bundle
private static var resourceBundle: Bundle {
if let bundle = resolvedBundle {
return bundle
@ -35,6 +51,7 @@ enum RDEPUBAssetRepository {
return Bundle(for: RDEPUBAssetBundleToken.self)
}
/// RDReaderViewAssets.bundle 宿 bundle framework
private static var resolvedBundle: Bundle? = {
let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles
for hostBundle in hostBundles {
@ -47,4 +64,5 @@ enum RDEPUBAssetRepository {
}()
}
/// Bundle
private final class RDEPUBAssetBundleToken {}

View File

@ -1,12 +1,25 @@
// RDEPUBFixedLayoutTemplate.swift
// Fixed LayoutHTML
// Publication iframe HTML
// HTML
import Foundation
/// spine iframe HTML
enum RDEPUBFixedLayoutTemplate {
/// Publication HTML
/// - Parameters:
/// - request: spread
/// - publication: EPUB URL
/// - Returns: HTML
static func html(for request: RDEPUBFixedRenderRequest, publication: RDEPUBPublication) -> String {
// spread iframe pane HTML
let panes = request.spread.resources.enumerated().compactMap { index, resource -> String? in
guard let url = publication.resourceResolver.resourceURL(forRelativePath: resource.href)?.absoluteString else {
return nil
}
// spread ///
let pageType: String
if request.spread.resources.count == 1 {
pageType = "single"
@ -23,6 +36,7 @@ enum RDEPUBFixedLayoutTemplate {
"""
}.joined()
//
let background = request.backgroundColorCSS ?? "#FFFFFF"
let viewportWidth = max(1, Int((request.viewportSize.width - request.contentInset.left - request.contentInset.right).rounded(.down)))
let viewportHeight = max(1, Int((request.viewportSize.height - request.contentInset.top - request.contentInset.bottom).rounded(.down)))
@ -30,6 +44,7 @@ enum RDEPUBFixedLayoutTemplate {
let insetRight = Int(request.contentInset.right.rounded(.down))
let insetBottom = Int(request.contentInset.bottom.rounded(.down))
let insetLeft = Int(request.contentInset.left.rounded(.down))
// HTML
return RDEPUBAssetRepository.string(
for: .fixedLayoutTemplate,
replacements: [

View File

@ -1,19 +1,35 @@
// RDEPUBJavaScriptBridge.swift
// WebView JavaScript
// JS progression/selection/link/error
// WebView
import Foundation
/// JS WebView
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
///
case progressionChanged = "ssReaderProgressionChanged"
///
case selectionChanged = "ssReaderSelectionChanged"
/// ss-reader://
case internalLink = "ssReaderInternalLink"
/// http/https/mailto/tel
case externalLink = "ssReaderExternalLink"
/// JavaScript
case javaScriptError = "ssReaderJSError"
///
case fixedLayoutReady = "ssReaderFixedLayoutReady"
}
/// JavaScript
enum RDEPUBJavaScriptBridge {
/// JS WKUserContentController
static var messageNames: [String] {
RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue)
}
/// WebView document end
/// 使 JS window.webkit.messageHandlers
static var userScript: String {
RDEPUBAssetRepository.string(
for: .bridgeScript,
@ -28,6 +44,10 @@ enum RDEPUBJavaScriptBridge {
)
}
///
/// CSS
/// - Parameter request:
/// - Returns: evaluateJavaScript
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String {
let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation))
let pageStride = max(1, request.presentation.viewportSize.width)
@ -52,6 +72,11 @@ enum RDEPUBJavaScriptBridge {
"""
}
///
/// iframe span
/// 使
/// - Parameter presentation:
/// - Returns:
static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String {
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
return """
@ -213,12 +238,14 @@ enum RDEPUBJavaScriptBridge {
"""
}
/// JS
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
string
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "`", with: "\\`")
}
/// JS
private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] {
highlights.compactMap { highlight in
guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil }
@ -231,6 +258,7 @@ enum RDEPUBJavaScriptBridge {
}
}
/// JS progressionfragment
private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? {
guard let location else { return nil }
return [
@ -240,6 +268,7 @@ enum RDEPUBJavaScriptBridge {
]
}
/// JS
private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? {
guard let presentation else { return nil }
return [
@ -254,6 +283,7 @@ enum RDEPUBJavaScriptBridge {
]
}
/// JSON fallback
private static func jsonString(from object: Any?, fallback: String) -> String {
guard let object else { return fallback }
guard JSONSerialization.isValidJSONObject(object),

View File

@ -1,36 +1,71 @@
//
// RDEPUBModels.swift
// RDReaderView
//
// EPUBCore
// EPUB
// Spine
// RDEPUBParser RDEPUBPublication
//
import Foundation
/// EPUB
/// - reflowable: EPUB
/// - fixed: EPUB
public enum RDEPUBLayout: String, Codable {
case reflowable
case fixed
}
/// 使
/// - webInteractive: + 使 WKWebView
/// - webFixedLayout: EPUB使 WKWebView
/// - textReflowable: EPUB使 DTCoreText
public enum RDEPUBReadingProfile: String, Codable {
case webInteractive
case webFixedLayout
case textReflowable
}
///
/// - ltr:
/// - rtl:
/// - auto:
public enum RDEPUBReadingProgression: String, Codable {
case ltr
case rtl
case auto
}
/// spread
/// - left:
/// - right:
/// - center:
public enum RDEPUBPageSpread: String, Codable {
case left
case right
case center
}
/// EPUB OPF <metadata>
///
public struct RDEPUBMetadata: Codable, Equatable {
/// <dc:identifier>
public var identifier: String?
/// <dc:title>
public var title: String
/// <dc:creator>
public var author: String?
/// <dc:language> "zh""en"
public var language: String?
/// EPUB "2.0""3.0"
public var version: String?
/// reflowable fixed
public var layout: RDEPUBLayout
/// spread <meta property="rendition:spread">
public var spread: String?
/// ltr / rtl / auto
public var readingProgression: RDEPUBReadingProgression
public init(
@ -54,13 +89,22 @@ public struct RDEPUBMetadata: Codable, Equatable {
}
}
/// EPUB OPF <manifest> <item>
/// IDMIME
public struct RDEPUBManifestItem: Codable, Equatable {
/// <item id="...">
public var id: String
/// OPF
public var href: String
/// MIME "application/xhtml+xml""image/jpeg""text/css"
public var mediaType: String
/// "nav" "scripted" "cover-image"
public var properties: [String]
/// 退 ID <item fallback="...">
public var fallback: String?
/// IDEPUB 3
public var mediaOverlay: String?
///
public var title: String?
public init(
@ -81,23 +125,37 @@ public struct RDEPUBManifestItem: Codable, Equatable {
self.title = title
}
/// EPUB 3 Nav Document
/// properties "nav"
public var isNavigationDocument: Bool {
properties.contains("nav")
}
/// NCX EPUB 2
/// MIME "application/x-dtbncx+xml"
public var isNCX: Bool {
mediaType == "application/x-dtbncx+xml"
}
}
/// EPUB Spine OPF <spine> <itemref>
/// Spine 线
public struct RDEPUBSpineItem: Codable, Equatable {
/// manifest item id
public var idref: String
/// OPF
public var href: String
/// MIME
public var mediaType: String
/// manifest
public var title: String
/// 线"no" 线
public var linear: Bool
/// rendition:layout-*
public var properties: [String]
/// spread //
public var pageSpread: RDEPUBPageSpread?
/// layout
public var layout: RDEPUBLayout?
public init(
@ -121,9 +179,14 @@ public struct RDEPUBSpineItem: Codable, Equatable {
}
}
/// EPUB
/// NCX EPUB 2 Nav DocumentEPUB 3
public struct EPUBTableOfContentsItem: Codable, Equatable {
///
public var title: String
/// fragment
public var href: String
///
public var children: [EPUBTableOfContentsItem]
public init(title: String, href: String, children: [EPUBTableOfContentsItem] = []) {
@ -133,13 +196,21 @@ public struct EPUBTableOfContentsItem: Codable, Equatable {
}
}
/// EPUB ZIP OPF
public enum RDEPUBParserError: LocalizedError {
/// ZIP
case archiveOpenFailed(URL)
/// META-INF/container.xml EPUB
case missingContainerXML
/// container.xml <rootfile>
case missingRootFile
/// OPF
case invalidRootFilePath(String)
/// XML
case invalidXML(URL)
/// spine manifest idref
case missingManifestItem(idref: String)
/// spine
case emptySpine
public var errorDescription: String? {

View File

@ -1,10 +1,22 @@
// RDEPUBNavigatorLayoutContext.swift
//
//
// Paginator 使
import UIKit
///
/// Paginator WebView
public struct RDEPUBNavigatorLayoutContext: Equatable {
///
public var containerSize: CGSize
/// 2
public var pagesPerScreen: Int
/// Home Indicator
public var safeAreaInsets: UIEdgeInsets
/// phone/pad
public var userInterfaceIdiom: UIUserInterfaceIdiom
///
public var reflowableContentInsets: UIEdgeInsets
public init(
@ -21,6 +33,7 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
self.reflowableContentInsets = reflowableContentInsets
}
/// 2
public var viewportSize: CGSize {
let width: CGFloat
if pagesPerScreen > 1 {
@ -31,6 +44,7 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
return CGSize(width: width, height: containerSize.height)
}
/// 使Pad
public var fixedContentInset: UIEdgeInsets {
var insets = safeAreaInsets
if userInterfaceIdiom != .phone {

View File

@ -1,13 +1,33 @@
//
// RDEPUBNavigatorState.swift
// RDReaderView
//
// EPUBCore
//
// initializing loading idle jumping/moving/repaginating
// RDEPUBReadingSession
//
import Foundation
///
/// initializing loading idle jumping/moving/repaginating
public enum RDEPUBNavigatorState: String, Codable {
///
case initializing
///
case loading
///
case idle
/// /
case jumping
///
case moving
/// /
case repaginating
///
/// idle staged snapshot
public var isStableForSnapshotApplication: Bool {
self == .idle
}

View File

@ -1,25 +1,45 @@
// RDEPUBPaginator.swift
// EPUB
// 使 WKWebView spine JS scrollWidth
// 0ms/80ms/180ms
// Fixed Layout 1
import UIKit
import WebKit
/// EPUB WebView spine
public final class RDEPUBPaginator: NSObject {
///
private var parser: RDEPUBParser?
/// WebView
private weak var hostingView: UIView?
///
private var presentation = RDEPUBPresentationStyle(
viewportSize: .zero,
contentInsets: .zero,
fontSize: 16,
lineHeightMultiple: 1.5
)
/// spine
private var completion: (([Int]) -> Void)?
/// spine
private var singlePageCountCompletion: ((Int) -> Void)?
/// spine
private var pageCounts: [Int] = []
/// spine
private var measurementIndices: [Int] = []
/// measurementIndices
private var currentMeasurementOffset = 0
/// spine
private var pendingMeasurementValue = 1
/// 0/1/2 0ms/80ms/180ms
private var measurementPass = 0
/// ID
private var activeSessionID = UUID()
///
private let debugScope = "PaginatorWebView"
/// WebView
private lazy var webView: WKWebView = {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
@ -52,6 +72,12 @@ public final class RDEPUBPaginator: NSObject {
webView.removeFromSuperview()
}
/// spine
/// - Parameters:
/// - parser: EPUB
/// - hostingView: WebView
/// - presentation:
/// - completion: spine
public func calculate(
parser: RDEPUBParser,
hostingView: UIView,
@ -82,6 +108,7 @@ public final class RDEPUBPaginator: NSObject {
measureNextSpineItem()
}
/// 便使 spine
public func calculate(
parser: RDEPUBParser,
hostingView: UIView,
@ -104,6 +131,13 @@ public final class RDEPUBPaginator: NSObject {
)
}
/// spine
/// - Parameters:
/// - parser: EPUB
/// - spineIndex: spine
/// - hostingView: WebView
/// - presentation:
/// - completion: spine
public func calculateSingleSpinePageCount(
parser: RDEPUBParser,
spineIndex: Int,
@ -135,6 +169,7 @@ public final class RDEPUBPaginator: NSObject {
measureNextSpineItem()
}
/// 便使 spine
public func calculateSingleSpinePageCount(
parser: RDEPUBParser,
spineIndex: Int,
@ -159,6 +194,7 @@ public final class RDEPUBPaginator: NSObject {
)
}
/// CSS HTML
public static func injectPaginationCSS(
into html: String,
viewportSize: CGSize,
@ -181,6 +217,7 @@ public final class RDEPUBPaginator: NSObject {
)
}
/// spine WebView
private func measureNextSpineItem() {
guard let parser else {
finishIfNeeded()
@ -218,10 +255,12 @@ public final class RDEPUBPaginator: NSObject {
webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL)
}
/// spine
private func currentSpineIndexForMeasurement() -> Int? {
measurementIndices[safe: currentMeasurementOffset]
}
/// spine
private func finishIfNeeded() {
if let singlePageCountCompletion {
let measuredSpineIndex = measurementIndices.first ?? 0
@ -236,6 +275,7 @@ public final class RDEPUBPaginator: NSObject {
cleanupMeasurementState()
}
/// WebView
private func cleanupMeasurementState() {
parser = nil
hostingView = nil
@ -251,11 +291,13 @@ public final class RDEPUBPaginator: NSObject {
webView.removeFromSuperview()
}
/// spine HTML/XHTML/XML
private func isRenderablePage(item: RDEPUBSpineItem) -> Bool {
let mediaType = item.mediaType.lowercased()
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
}
/// 0ms/80ms/180ms
private func scheduleMeasurementPass() {
let sessionID = activeSessionID
let delays: [TimeInterval] = [0.0, 0.08, 0.18]
@ -278,6 +320,7 @@ public final class RDEPUBPaginator: NSObject {
}
}
/// JS scrollWidth
private func measureCurrentDocument(sessionID: UUID) {
let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation)
RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)")
@ -332,6 +375,7 @@ extension RDEPUBPaginator: WKNavigationDelegate {
}
}
/// Array nil
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil

View File

@ -1,7 +1,15 @@
// RDEPUBParser+Archive.swift
// EPUB container.xml
// EPUB ZIP ~/Library/Caches/ssreaderview-epub/
// META-INF/container.xml OPF
import Foundation
import ZIPFoundation
extension RDEPUBParser {
/// container.xml OPFPackage Document
/// - Parameter containerURL: container.xml
/// - Returns: OPF "OEBPS/content.opf"
func parseContainerRootFile(at containerURL: URL) throws -> String {
guard let parser = XMLParser(contentsOf: containerURL) else {
throw RDEPUBParserError.invalidXML(containerURL)
@ -22,6 +30,10 @@ extension RDEPUBParser {
return rootFilePath
}
/// EPUB ZIP
/// slug + +
/// - Parameter epubURL: EPUB
/// - Returns: URL
func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
let fileManager = FileManager.default
let extractionURL = temporaryExtractionDirectory(for: epubURL)
@ -52,6 +64,8 @@ extension RDEPUBParser {
return extractionURL
}
/// EPUB
/// ~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
@ -65,12 +79,14 @@ extension RDEPUBParser {
return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true)
}
///
func reset() {
extractionRootURL = nil
opfURL = nil
resetPublicationState()
}
/// metadatamanifestspine
func resetPublicationState() {
metadata = RDEPUBMetadata()
manifest = [:]
@ -79,7 +95,9 @@ extension RDEPUBParser {
}
}
/// container.xml SAX <rootfile> full-path
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
/// OPF
private(set) var rootFilePath: String?
func parser(

View File

@ -1,6 +1,18 @@
// RDEPUBParser+Package.swift
// OPFPackage Document Spine
// OPFPackageParserDelegateSAX metadata/manifest/spine
// RDEPUBSpineItem rendition:layoutpage-spread
// XMLNameXMLSectionXMLParserContext XML
import Foundation
extension RDEPUBParser {
/// OPF spine
/// manifest hreffixed/reflowablepage-spread
/// - Parameters:
/// - packageDocument: OPF metadatamanifestspineReferences
/// - opfURL: OPF
/// - Returns: spine
func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] {
let opfDirectoryURL = opfURL.deletingLastPathComponent()
let publicationLayout = packageDocument.metadata.layout
@ -50,22 +62,33 @@ extension RDEPUBParser {
}
}
/// OPF metadatamanifestspine NCX/nav
struct OPFPackageDocument {
var metadata: RDEPUBMetadata
var manifest: [RDEPUBManifestItem]
var manifestByID: [String: RDEPUBManifestItem]
var spineReferences: [OPFSpineReference]
/// NCX manifest EPUB 2
var ncxItem: RDEPUBManifestItem?
/// Navigation Document manifest EPUB 3
var navigationItem: RDEPUBManifestItem?
}
/// OPF spine/itemref
struct OPFSpineReference {
/// manifest item id
var idref: String
/// 线"no"
var linear: Bool
/// rendition:layout-pre-paginatedpage-spread-left
var properties: [String]
/// //
var pageSpread: RDEPUBPageSpread?
}
/// OPF SAX
/// metadataidentifier/title/creator/language/rendition:layout
/// manifestitem spineitemref
final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
private var metadata = RDEPUBMetadata()
private var manifestItems: [RDEPUBManifestItem] = []
@ -79,6 +102,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
private var currentMetaRefinesID: String?
private var ncxID: String?
/// OPFPackageDocument
func packageDocument() -> OPFPackageDocument {
if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first {
metadata.identifier = fallbackIdentifier
@ -227,6 +251,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
xmlContext.endElement()
}
/// OPF metadata/manifest/spine/other
private var currentSection: XMLSection {
if xmlContext.containsElement(named: "manifest") {
return .manifest
@ -240,6 +265,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
return .other
}
/// <meta> rendition:layoutrendition:spreadidentifier
private func applyMetaValue(_ value: String) {
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else {
@ -264,6 +290,7 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
}
}
/// page-spread properties page-spread
private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? {
if let properties = attributes["properties"]?.lowercased() {
if properties.contains("page-spread-left") {
@ -293,11 +320,13 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
return nil
}
/// rendition:layout
private func layout(from rawValue: String) -> RDEPUBLayout {
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
}
}
/// OPF
enum XMLSection {
case metadata
case manifest
@ -305,11 +334,14 @@ enum XMLSection {
case other
}
/// XML refines ID
enum XMLName {
/// "dc:title" -> "title"
static func localName(from rawName: String) -> String {
rawName.split(separator: ":").last.map(String.init) ?? rawName
}
/// token
static func tokenize(_ rawValue: String?) -> [String] {
guard let rawValue else { return [] }
return rawValue
@ -317,6 +349,7 @@ enum XMLName {
.map(String.init)
}
/// refines ID "#"
static func refinedID(from rawValue: String) -> String? {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("#") else {
@ -326,10 +359,14 @@ enum XMLName {
}
}
/// XML
final class XMLParserContext {
///
private var elementStack: [String] = []
///
private var currentCharacters = ""
///
var trimmedCharacters: String {
currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines)
}

View File

@ -1,6 +1,13 @@
// RDEPUBParser+ReadingProfile.swift
// EPUB
// metadata layout manifest/spine
// webFixedLayoutwebInteractive/ textReflowable
import Foundation
extension RDEPUBParser {
///
/// /iframe/
public func readingProfile() -> RDEPUBReadingProfile {
if metadata.layout == .fixed {
return .webFixedLayout
@ -8,6 +15,9 @@ extension RDEPUBParser {
return hasInteractiveContent() ? .webInteractive : .textReflowable
}
///
/// manifest "scripted"
/// HTML script/iframe/video/audio/canvas/svg/form
public func hasInteractiveContent() -> Bool {
let interactiveMediaTypes = [
"application/javascript",

View File

@ -1,11 +1,18 @@
// RDEPUBParser+Resources.swift
// EPUB URL
// IDhref manifest spine href
// ss-reader:// URL HTML
import Foundation
import UIKit
extension RDEPUBParser {
/// ID manifest
public func manifestItem(for id: String) -> RDEPUBManifestItem? {
manifest[id]
}
/// href manifest fragment
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
let target = href.components(separatedBy: "#").first ?? href
return manifest.values.first { item in
@ -13,11 +20,13 @@ extension RDEPUBParser {
}
}
/// spine href
public func href(forSpineIndex index: Int) -> String? {
guard spine.indices.contains(index) else { return nil }
return spine[index].href
}
/// URL
public func fileURL(forRelativePath relativePath: String) -> URL? {
guard let opfDirectoryURL else { return nil }
let path = relativePath.components(separatedBy: "#").first ?? relativePath
@ -32,6 +41,7 @@ extension RDEPUBParser {
return resolvedURL
}
/// ss-reader://book/ URL WebView
public func resourceURL(forRelativePath relativePath: String) -> URL? {
let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard !normalizedPath.isEmpty else { return nil }
@ -45,6 +55,7 @@ extension RDEPUBParser {
return components.url
}
/// ss-reader://book/ URL URL
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme,
resourceURL.host == RDEPUBResourceURLSchemeHandler.host else {
@ -54,6 +65,7 @@ extension RDEPUBParser {
return fileURL(forRelativePath: relativePath)
}
/// "cover-image" ID "cover" manifest
public func coverImage() -> UIImage? {
let coverCandidates = manifest.values.filter { item in
item.properties.contains("cover-image") || item.id.lowercased().contains("cover")
@ -67,6 +79,7 @@ extension RDEPUBParser {
return nil
}
/// spine HTML
public func htmlString(forSpineIndex index: Int) -> String? {
guard spine.indices.contains(index) else {
return nil
@ -74,6 +87,7 @@ extension RDEPUBParser {
return htmlString(forRelativePath: spine[index].href)
}
/// HTML
public func htmlString(forRelativePath relativePath: String) -> String? {
guard let fileURL = fileURL(forRelativePath: relativePath) else {
return nil
@ -81,6 +95,7 @@ extension RDEPUBParser {
return try? String(contentsOf: fileURL)
}
/// href URL
func normalize(href: String, relativeTo directoryURL: URL) -> String {
guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else {
return href

View File

@ -1,6 +1,14 @@
// RDEPUBParser+TOC.swift
// EPUB
// NCXEPUB 2 Navigation DocumentEPUB 3
// 使 NCX Nav Document退 spine
// 使 TOCTreeBuilder
import Foundation
extension RDEPUBParser {
/// OPF
/// NCX Nav Document退 spine
func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] {
let opfDirectoryURL = opfURL.deletingLastPathComponent()
@ -23,6 +31,7 @@ extension RDEPUBParser {
return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) }
}
/// NCXEPUB 2
func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] {
guard FileManager.default.fileExists(atPath: ncxURL.path),
let parser = XMLParser(contentsOf: ncxURL) else {
@ -40,6 +49,7 @@ extension RDEPUBParser {
return delegate.items
}
/// Navigation DocumentEPUB 3
func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] {
guard FileManager.default.fileExists(atPath: navURL.path),
let parser = XMLParser(contentsOf: navURL) else {
@ -57,6 +67,7 @@ extension RDEPUBParser {
return delegate.items
}
/// href fragment
func normalizeTOCHref(_ href: String, relativeTo baseURL: URL) -> String? {
let components = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
let pathPart = components.first.map(String.init) ?? href
@ -80,11 +91,13 @@ extension RDEPUBParser {
}
}
/// href EPUBTableOfContentsItem
private final class TOCNode {
var title = ""
var href: String?
var children: [TOCNode] = []
///
func asItem() -> EPUBTableOfContentsItem? {
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard let href, !href.isEmpty else {
@ -98,10 +111,14 @@ private final class TOCNode {
}
}
/// beginNode/endNode
private final class TOCTreeBuilder {
///
private var nodeStack: [TOCNode] = []
///
private var rootNodes: [TOCNode] = []
///
var items: [EPUBTableOfContentsItem] {
rootNodes.compactMap { $0.asItem() }
}
@ -134,6 +151,7 @@ private final class TOCTreeBuilder {
}
}
/// NCX SAX <navPoint><content><text>
private final class NCXParserDelegate: NSObject, XMLParserDelegate {
private let baseURL: URL
private let hrefNormalizer: (String, URL) -> String?
@ -198,6 +216,7 @@ private final class NCXParserDelegate: NSObject, XMLParserDelegate {
}
}
/// Navigation Document SAX epub:type="toc" <nav>
private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
private let baseURL: URL
private let hrefNormalizer: (String, URL) -> String?
@ -284,6 +303,7 @@ private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
}
}
/// <nav> TOC epub:typetyperole
private func isTOCNav(attributes: [String: String]) -> Bool {
if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") {
return true

View File

@ -1,23 +1,41 @@
// RDEPUBParser.swift
// EPUB
// epubURL ZIP container.xml OPF spine/TOC
// EPUBCore Publication makePublication() RDEPUBPublication
// +Archive+Package+TOC+Resources+ReadingProfile
import Foundation
/// EPUB epub
/// epubURL extractArchiveIfNeeded container.xml OPF metadata/manifest/spine/TOC
public final class RDEPUBParser {
///
public internal(set) var metadata = RDEPUBMetadata()
/// key manifest item ID
public internal(set) var manifest: [String: RDEPUBManifestItem] = [:]
/// spine
public internal(set) var spine: [RDEPUBSpineItem] = []
/// NCX Nav Document
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = []
/// EPUB
public internal(set) var extractionRootURL: URL?
/// OPF
public internal(set) var opfURL: URL?
/// OPF URL
public var opfDirectoryURL: URL? {
opfURL?.deletingLastPathComponent()
}
public init() {}
/// RDEPUBPublication
public func makePublication() -> RDEPUBPublication {
RDEPUBPublication(parser: self)
}
/// EPUB container.xml OPF
/// - Parameter epubURL: EPUB
public func parse(epubURL: URL) throws {
reset()
@ -38,6 +56,8 @@ public final class RDEPUBParser {
try parseOPF(at: packageURL)
}
/// OPF metadata/manifest/spine/TOC
/// - Parameter opfURL: OPF
public func parseOPF(at opfURL: URL) throws {
resetPublicationState()
@ -61,10 +81,12 @@ public final class RDEPUBParser {
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
}
///
public func parseTOC() -> [EPUBTableOfContentsItem] {
tableOfContents
}
/// Navigation Document
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
}

View File

@ -1,14 +1,29 @@
// RDEPUBPreferences.swift
// EPUB
//
// 便
import UIKit
/// EPUB
public struct RDEPUBPreferences: Equatable {
/// pt
public var fontSize: CGFloat
/// 1.5 1.5
public var lineHeightMultiple: CGFloat
///
public var reflowableContentInsets: UIEdgeInsets
///
public var fixedContentInset: UIEdgeInsets
/// CSS "#FFFFFF"
public var themeBackgroundColor: String?
/// CSS
public var themeTextColor: String?
///
public var fixedBackgroundColor: String?
/// page/width/height/contain/cover
public var fixedLayoutFit: RDEPUBFixedLayoutFit
/// never/always/automatic
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
public init(
@ -33,6 +48,7 @@ public struct RDEPUBPreferences: Equatable {
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
}
///
public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle {
RDEPUBPresentationStyle(
viewportSize: viewportSize,
@ -44,6 +60,7 @@ public struct RDEPUBPreferences: Equatable {
)
}
///
public func renderRequest(
for page: EPUBPage,
publication: RDEPUBPublication,

View File

@ -1,7 +1,16 @@
// RDEPUBPublication.swift
// EPUB Facade
// RDEPUBParser metadataspineTOC 访
// spread
// EPUBCore Publication
import Foundation
/// EPUB 访
public final class RDEPUBPublication {
///
public let parser: RDEPUBParser
/// URL
public let resourceResolver: RDEPUBResourceResolver
public init(parser: RDEPUBParser) {
@ -9,38 +18,48 @@ public final class RDEPUBPublication {
self.resourceResolver = RDEPUBResourceResolver(parser: parser)
}
///
public var metadata: RDEPUBMetadata {
parser.metadata
}
///
public var manifest: [String: RDEPUBManifestItem] {
parser.manifest
}
///
public var spine: [RDEPUBSpineItem] {
parser.spine
}
///
public var tableOfContents: [EPUBTableOfContentsItem] {
parser.tableOfContents
}
/// fixed/reflowable
public var layout: RDEPUBLayout {
metadata.layout
}
/// webFixedLayout/webInteractive/textReflowable
public var readingProfile: RDEPUBReadingProfile {
parser.readingProfile()
}
/// ltr/rtl/auto
public var readingProgression: RDEPUBReadingProgression {
metadata.readingProgression
}
///
public var bookIdentifier: String? {
metadata.identifier
}
///
/// metadata.spread
public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool {
guard layout == .fixed else {
return false
@ -59,6 +78,7 @@ public final class RDEPUBPublication {
}
}
/// spread
public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] {
EPUBFixedSpread.makeSpreads(
spine: spine,

View File

@ -1,11 +1,30 @@
//
// 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(
@ -24,6 +43,8 @@ public struct RDEPUBLocation: Codable, Equatable {
self.rangeAnchor = rangeAnchor
}
/// progression lastProgression
/// /
public var navigationProgression: Double {
let end = lastProgression ?? progression
if end.isNaN || end.isInfinite {
@ -32,17 +53,24 @@ public struct RDEPUBLocation: Codable, Equatable {
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(
@ -60,10 +88,16 @@ public struct RDEPUBViewportResource: Codable, Equatable {
}
}
///
/// spine
public struct RDEPUBViewport: Codable, Equatable {
///
public var resources: [RDEPUBViewportResource]
///
public var visiblePageNumber: Int
///
public var chapterIndex: Int?
///
public var isFixedLayout: Bool
public init(
@ -79,10 +113,16 @@ public struct RDEPUBViewport: Codable, Equatable {
}
}
///
/// 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(
@ -98,11 +138,17 @@ public struct RDEPUBReadingContext: Codable, Equatable {
}
}
/// 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(
@ -119,28 +165,44 @@ public struct RDEPUBSelection: Codable, Equatable {
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
}
///
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
@ -148,16 +210,19 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
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),
@ -170,28 +235,48 @@ public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
}
}
///
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(
@ -217,15 +302,26 @@ public struct RDEPUBTextPageMetadata: Codable, Equatable {
}
}
///
/// 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(
@ -250,10 +346,12 @@ public struct RDEPUBHighlight: Codable, Equatable {
self.createdAt = createdAt
}
///
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
/// Codable /
private enum CodingKeys: String, CodingKey {
case id
case bookIdentifier
@ -266,6 +364,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
case createdAt
}
/// 使
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
@ -284,6 +383,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
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)
@ -298,12 +398,19 @@ public struct RDEPUBHighlight: Codable, Equatable {
}
}
///
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(
@ -322,14 +429,19 @@ public struct RDEPUBBookmark: Codable, Equatable {
self.createdAt = createdAt
}
///
public var hasNote: Bool {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
}
///
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) {
@ -339,12 +451,20 @@ public struct EPUBChapterInfo: Codable, Equatable {
}
}
///
/// 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(
@ -364,10 +484,15 @@ public struct EPUBPage: Codable, Equatable {
}
}
/// 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) {
@ -378,23 +503,38 @@ public struct EPUBFixedSpreadResource: Codable, Equatable {
}
}
/// 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(
@ -433,10 +573,13 @@ public struct EPUBFixedSpread: Codable, Equatable {
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
@ -453,7 +596,9 @@ public struct EPUBFixedSpread: Codable, Equatable {
}
}
/// String nil Codable
private extension String {
/// nil
var nilIfEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed

View File

@ -1,33 +1,56 @@
// RDEPUBReadingSession.swift
// EPUB
// initializing/loading/idle/jumping/moving/repaginating
//
//
import Foundation
/// EPUB
/// initializing loading idle jumping/moving/repaginating
public final class RDEPUBReadingSession {
/// +
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
///
public let publication: RDEPUBPublication
///
public private(set) var navigatorState: RDEPUBNavigatorState = .initializing
///
public private(set) var activePages: [EPUBPage] = []
///
public private(set) var activeChapters: [EPUBChapterInfo] = []
///
public private(set) var stagedPages: [EPUBPage]?
///
public private(set) var stagedChapters: [EPUBChapterInfo]?
///
public private(set) var stagedRestoreLocation: RDEPUBLocation?
/// fragment
public private(set) var pendingNavigationLocation: RDEPUBLocation?
///
public private(set) var pendingNavigationPageNum: Int?
///
public private(set) var currentViewport: RDEPUBViewport?
/// + + +
public private(set) var currentReadingContext: RDEPUBReadingContext?
public init(publication: RDEPUBPublication) {
self.publication = publication
}
/// 访
public var resourceResolver: RDEPUBResourceResolver {
publication.resourceResolver
}
///
public func transition(to state: RDEPUBNavigatorState) {
navigatorState = state
}
/// initializing
public func resetRuntimeState() {
navigatorState = .initializing
activePages = []
@ -38,17 +61,20 @@ public final class RDEPUBReadingSession {
currentReadingContext = nil
}
///
public func setActiveSnapshot(_ snapshot: PaginationSnapshot) {
activePages = snapshot.pages
activeChapters = snapshot.chapters
}
///
public func stageSnapshot(_ snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?) {
stagedPages = snapshot.pages
stagedChapters = snapshot.chapters
stagedRestoreLocation = restoreLocation
}
///
public func stagedSnapshot() -> PaginationSnapshot? {
guard let stagedPages, let stagedChapters else {
return nil
@ -56,6 +82,7 @@ public final class RDEPUBReadingSession {
return (stagedPages, stagedChapters)
}
///
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? {
guard navigatorState.isStableForSnapshotApplication,
let stagedPages,
@ -67,17 +94,20 @@ public final class RDEPUBReadingSession {
return ((stagedPages, stagedChapters), restoreLocation)
}
///
public func clearStagedSnapshot() {
stagedPages = nil
stagedChapters = nil
stagedRestoreLocation = nil
}
///
public func clearPendingNavigation() {
pendingNavigationLocation = nil
pendingNavigationPageNum = nil
}
/// spine
public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? {
guard pendingNavigationPageNum == pageNumber,
let pendingNavigationLocation else {
@ -93,6 +123,7 @@ public final class RDEPUBReadingSession {
return pendingNavigationLocation
}
/// spine spread
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
if let fixedSpread = page.fixedSpread {
return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex })
@ -100,6 +131,7 @@ public final class RDEPUBReadingSession {
return page.spineIndex == spineIndex
}
/// 退
public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? {
if let fixedSpread = page.fixedSpread {
return RDEPUBLocation(
@ -129,6 +161,7 @@ public final class RDEPUBReadingSession {
)
}
///
public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard currentPageNumber > 0,
activePages.indices.contains(currentPageNumber - 1) else {
@ -137,6 +170,7 @@ public final class RDEPUBReadingSession {
return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier)
}
/// spine
public func initialSpineIndex(for location: RDEPUBLocation?) -> Int {
guard let location,
let normalizedHref = resourceResolver.normalizedHref(location.href),
@ -146,6 +180,7 @@ public final class RDEPUBReadingSession {
return spineIndex
}
///
public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? {
guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else {
return nil
@ -186,6 +221,7 @@ public final class RDEPUBReadingSession {
return candidates[localIndex].offset
}
/// jumping
public func queueNavigation(
to location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int? = nil,
@ -205,6 +241,7 @@ public final class RDEPUBReadingSession {
return pageIndex + 1
}
/// idle
public func updateReadingContext(
pageNumber: Int,
location: RDEPUBLocation,
@ -253,10 +290,13 @@ public final class RDEPUBReadingSession {
}
}
/// 退
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier)
}
///
/// spread spine
public func makePaginationSnapshot(
pageCounts: [Int],
preferences: RDEPUBPreferences,

View File

@ -1,23 +1,49 @@
//
// RDEPUBRenderRequest.swift
// RDReaderView
//
// EPUBCore
// PresentationStyle/RenderRequest
// Fit Spread SpreadMode
// RDEPUBPreferences RDEPUBWebView
//
import UIKit
///
/// - auto:
/// - page:
/// - width:
public enum RDEPUBFixedLayoutFit: String, Codable {
case auto
case page
case width
}
/// Spread
/// - automatic:
/// - always:
/// - never:
public enum RDEPUBFixedLayoutSpreadMode: String, Codable {
case automatic
case always
case never
}
/// WebView Paginator
/// RDEPUBPreferences
public struct RDEPUBPresentationStyle: Equatable {
///
public var viewportSize: CGSize
///
public var contentInsets: UIEdgeInsets
/// pt
public var fontSize: CGFloat
/// 1.0
public var lineHeightMultiple: CGFloat
/// CSS "#FFFFFF"
public var themeBackgroundColor: String?
/// CSS "#000000"
public var themeTextColor: String?
public init(
@ -37,14 +63,23 @@ public struct RDEPUBPresentationStyle: Equatable {
}
}
/// reflowable spine
public struct RDEPUBReflowableRenderRequest: Equatable {
/// spine
public var spineIndex: Int
///
public var href: String
///
public var pageIndex: Int
///
public var totalPagesInChapter: Int
///
public var presentation: RDEPUBPresentationStyle
///
public var targetLocation: RDEPUBLocation?
///
public var highlights: [RDEPUBHighlight]
///
public var searchPresentation: RDEPUBSearchPresentation?
public init(
@ -68,12 +103,19 @@ public struct RDEPUBReflowableRenderRequest: Equatable {
}
}
/// fixed spread
public struct RDEPUBFixedRenderRequest: Equatable {
/// spread 1-2
public var spread: EPUBFixedSpread
///
public var viewportSize: CGSize
///
public var contentInset: UIEdgeInsets
/// CSS
public var backgroundColorCSS: String?
/// auto/page/width
public var fit: RDEPUBFixedLayoutFit
///
public var searchPresentation: RDEPUBSearchPresentation?
public init(
@ -93,10 +135,15 @@ public struct RDEPUBFixedRenderRequest: Equatable {
}
}
///
/// RDEPUBWebView
public enum RDEPUBRenderRequest: Equatable {
///
case reflowable(RDEPUBReflowableRenderRequest)
///
case fixed(RDEPUBFixedRenderRequest)
///
public var isFixedLayout: Bool {
if case .fixed = self {
return true
@ -104,6 +151,7 @@ public enum RDEPUBRenderRequest: Equatable {
return false
}
/// spine
public var primarySpineIndex: Int {
switch self {
case .reflowable(let request):
@ -113,6 +161,7 @@ public enum RDEPUBRenderRequest: Equatable {
}
}
///
public var primaryHref: String {
switch self {
case .reflowable(let request):

View File

@ -1,28 +1,41 @@
// RDEPUBResourceResolver.swift
// EPUB
// RDEPUBParser href
// spine Services
// RDEPUBPublication RDEPUBReadingSession 广使
import Foundation
/// EPUB URL href
public final class RDEPUBResourceResolver {
///
private let parser: RDEPUBParser
public init(parser: RDEPUBParser) {
self.parser = parser
}
/// OPF URL
public var opfDirectoryURL: URL? {
parser.opfDirectoryURL
}
/// URL
public func fileURL(forRelativePath relativePath: String) -> URL? {
parser.fileURL(forRelativePath: relativePath)
}
/// ss-reader://book/ URL
public func resourceURL(forRelativePath relativePath: String) -> URL? {
parser.resourceURL(forRelativePath: relativePath)
}
/// ss-reader://book/ URL URL
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
parser.fileURL(forResourceURL: resourceURL)
}
/// href OPF spine OPF
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? {
guard let opfDirectoryURL else {
return href.components(separatedBy: "#").first
@ -57,6 +70,7 @@ public final class RDEPUBResourceResolver {
return pathPart
}
/// href href
public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String? {
guard let opfDirectoryURL else {
return href.components(separatedBy: "#").first
@ -79,6 +93,7 @@ public final class RDEPUBResourceResolver {
return pathPart
}
/// href URL
public func fileURL(forReference href: String, relativeToHref baseHref: String) -> URL? {
guard let normalizedHref = normalizedHref(href, relativeToHref: baseHref) else {
return nil
@ -86,6 +101,7 @@ public final class RDEPUBResourceResolver {
return fileURL(forRelativePath: normalizedHref)
}
/// href progression fragment
public func normalizedLocation(
_ location: RDEPUBLocation,
relativeToSpineIndex spineIndex: Int? = nil,
@ -119,12 +135,14 @@ public final class RDEPUBResourceResolver {
)
}
/// href spine
public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? {
parser.spine.firstIndex { item in
self.normalizedHref(item.href) == normalizedHref
}
}
/// spine
public func spineIndex(for location: RDEPUBLocation) -> Int? {
guard let normalizedHref = normalizedHref(location.href) else {
return nil
@ -132,6 +150,7 @@ public final class RDEPUBResourceResolver {
return spineIndex(forNormalizedHref: normalizedHref)
}
/// spine href
public func href(forSpineIndex spineIndex: Int) -> String? {
guard parser.spine.indices.contains(spineIndex) else {
return nil
@ -139,6 +158,7 @@ public final class RDEPUBResourceResolver {
return parser.spine[spineIndex].href
}
/// spine
public func title(forSpineIndex spineIndex: Int) -> String? {
guard parser.spine.indices.contains(spineIndex) else {
return nil
@ -146,6 +166,7 @@ public final class RDEPUBResourceResolver {
return parser.spine[spineIndex].title
}
/// href manifest
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
let targetHref = normalizedHref(href) ?? href.components(separatedBy: "#").first ?? href
return parser.manifest.values.first {

View File

@ -1,13 +1,25 @@
// RDEPUBResourceURLSchemeHandler.swift
// ss-reader:// URL
// WKURLSchemeHandler ss-reader://book/<path>
// EPUB MIME
import Foundation
import WebKit
/// URL ss-reader://book/ EPUB
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
///
public static let scheme = "ss-reader"
///
public static let host = "book"
///
private weak var parser: RDEPUBParser?
///
private let fileManager = FileManager.default
/// 访
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
/// URL Scheme
private var activeTasks: [ObjectIdentifier: Bool] = [:]
public init(parser: RDEPUBParser) {
@ -15,6 +27,8 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
super.init()
}
/// URL Scheme
/// //CSS/JS 404
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
let taskID = ObjectIdentifier(urlSchemeTask as AnyObject)
syncQueue.sync {
@ -72,23 +86,27 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
clearTask(taskID)
}
/// URL Scheme
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: urlSchemeTask.request.url, event: "stop")
clearTask(ObjectIdentifier(urlSchemeTask as AnyObject))
}
///
private func clearTask(_ id: ObjectIdentifier) {
syncQueue.sync {
activeTasks[id] = nil
}
}
///
private func isTaskActive(_ id: ObjectIdentifier) -> Bool {
syncQueue.sync {
activeTasks[id] == true
}
}
/// MIME
private static func mimeType(for pathExtension: String) -> String {
switch pathExtension.lowercased() {
case "html", "htm":
@ -128,6 +146,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
/// UTF-8
private static func textEncodingName(for pathExtension: String) -> String? {
switch pathExtension.lowercased() {
case "html", "htm", "xhtml", "css", "js", "xml", "opf", "ncx", "txt":
@ -137,6 +156,7 @@ public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler
}
}
/// //CSS/JS
private static func isOptionalResource(_ url: URL) -> Bool {
switch url.pathExtension.lowercased() {
case "ttf", "otf", "woff", "woff2", "css", "js", "jpg", "jpeg", "png", "gif", "webp", "svg":

View File

@ -1,12 +1,22 @@
// RDEPUBSearchEngine.swift
// EPUB
// spine 线 HTML/XHTML
// hrefprogressionpreviewText
import Foundation
import UIKit
///
protocol RDEPUBSearchEngine {
func search(keyword: String) -> [RDEPUBSearchMatch]
}
/// HTML
/// HTML
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
/// EPUB HTML
private let parser: RDEPUBParser
/// EPUB spine href
private let publication: RDEPUBPublication
init(parser: RDEPUBParser, publication: RDEPUBPublication) {
@ -14,6 +24,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
self.publication = publication
}
/// 线 spine
func search(keyword: String) -> [RDEPUBSearchMatch] {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
@ -35,6 +46,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
return searchMatches
}
/// HTML 使 NSAttributedString
private func plainText(fromHTML html: String, baseURL: URL?) -> String {
guard let data = html.data(using: .utf8) else {
return fallbackPlainText(fromHTML: html)
@ -54,6 +66,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
return fallbackPlainText(fromHTML: html)
}
/// HTML HTML
private func fallbackPlainText(fromHTML html: String) -> String {
let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
return stripped
@ -65,6 +78,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
.replacingOccurrences(of: "&quot;", with: "\"")
}
///
private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] {
let source = text as NSString
let fullLength = source.length
@ -106,6 +120,7 @@ final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
return results
}
/// 12
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)

View File

@ -1,12 +1,25 @@
// RDEPUBSearchModels.swift
// EPUB
// RDEPUBSearchMatchRDEPUBSearchResult
// RDEPUBSearchStateRDEPUBSearchPresentation
import Foundation
///
public struct RDEPUBSearchMatch: Codable, Equatable {
/// href
public var href: String
/// 0.0 ~ 1.0
public var progression: Double
///
public var previewText: String
///
public var localMatchIndex: Int
///
public var rangeLocation: Int?
///
public var rangeLength: Int
///
public var rangeAnchor: RDEPUBTextRangeAnchor?
public init(
@ -28,10 +41,15 @@ public struct RDEPUBSearchMatch: Codable, Equatable {
}
}
///
public struct RDEPUBSearchResult: Codable, Equatable {
///
public var keyword: String
///
public var totalMatchCount: Int
/// 1
public var currentMatchIndex: Int?
///
public var currentMatch: RDEPUBSearchMatch?
public init(
@ -47,9 +65,13 @@ public struct RDEPUBSearchResult: Codable, Equatable {
}
}
///
public struct RDEPUBSearchState: Codable, Equatable {
///
public var keyword: String
///
public var matches: [RDEPUBSearchMatch]
/// 0
public var currentMatchIndex: Int?
public init(keyword: String, matches: [RDEPUBSearchMatch], currentMatchIndex: Int? = nil) {
@ -58,6 +80,7 @@ public struct RDEPUBSearchState: Codable, Equatable {
self.currentMatchIndex = currentMatchIndex
}
///
public var currentMatch: RDEPUBSearchMatch? {
guard let currentMatchIndex,
matches.indices.contains(currentMatchIndex) else {
@ -66,6 +89,7 @@ public struct RDEPUBSearchState: Codable, Equatable {
return matches[currentMatchIndex]
}
///
public var result: RDEPUBSearchResult {
RDEPUBSearchResult(
keyword: keyword,
@ -76,9 +100,13 @@ public struct RDEPUBSearchState: Codable, Equatable {
}
}
///
public struct RDEPUBSearchPresentationResource: Codable, Equatable {
/// href
public var href: String
///
public var matchCount: Int
///
public var activeLocalMatchIndex: Int?
public init(href: String, matchCount: Int, activeLocalMatchIndex: Int? = nil) {
@ -88,8 +116,11 @@ public struct RDEPUBSearchPresentationResource: Codable, Equatable {
}
}
/// JS
public struct RDEPUBSearchPresentation: Codable, Equatable {
///
public var keyword: String
///
public var resources: [RDEPUBSearchPresentationResource]
public init(keyword: String, resources: [RDEPUBSearchPresentationResource]) {

View File

@ -1,6 +1,13 @@
// RDEPUBStyleSheetBuilder.swift
// EPUB CSS
// CSS CSS
// HTML JS
import Foundation
/// CSS CSS/JS
public enum RDEPUBStyleSheetBuilder {
/// CSS HTML </head> <body
public static func injectPaginationCSS(
into html: String,
presentation: RDEPUBPresentationStyle
@ -15,6 +22,8 @@ public enum RDEPUBStyleSheetBuilder {
return styleTag + html
}
/// CSSbody#ss-reader-viewport#ss-reader-content
///
public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String {
let values = cssValues(for: presentation)
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
@ -95,6 +104,7 @@ public enum RDEPUBStyleSheetBuilder {
"""
}
/// CSS CSS transform/will-change
public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String {
let values = cssValues(for: presentation)
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
@ -139,6 +149,7 @@ public enum RDEPUBStyleSheetBuilder {
"""
}
/// JS CSS scrollWidth
public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String {
let style = measurementCSS(for: presentation)
.replacingOccurrences(of: "\\", with: "\\\\")
@ -170,6 +181,7 @@ public enum RDEPUBStyleSheetBuilder {
"""
}
/// CSS
private static func cssValues(for presentation: RDEPUBPresentationStyle) -> (
viewportWidth: String,
viewportHeight: String,

View File

@ -1,12 +1,26 @@
// RDEPUBTextAnchor.swift
// EPUB
// RDEPUBTextAnchor fileIndex/row/column/chapterOffset/fragmentID
// RDEPUBTextRangeAnchor
// Codable spineIndex/fileIndex key
import Foundation
/// EPUB
/// fragment ID
public struct RDEPUBTextAnchor: Codable, Equatable {
/// spineIndex
public let fileIndex: Int
///
public let row: Int
///
public let column: Int
///
public let chapterOffset: Int
/// fragment ID URL
public let fragmentID: String?
/// spine fileIndex
public var spineIndex: Int { fileIndex }
public init(
@ -53,8 +67,11 @@ public struct RDEPUBTextAnchor: Codable, Equatable {
}
}
///
public struct RDEPUBTextRangeAnchor: Codable, Equatable {
///
public let start: RDEPUBTextAnchor
///
public let end: RDEPUBTextAnchor
public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) {
@ -62,6 +79,7 @@ public struct RDEPUBTextRangeAnchor: Codable, Equatable {
self.end = end
}
/// NSRange chapterOffset
public var nsRange: NSRange {
NSRange(location: start.chapterOffset, length: max(end.chapterOffset - start.chapterOffset, 0))
}

View File

@ -1,7 +1,14 @@
// RDEPUBWebView+Configuration.swift
// RDEPUBWebView WKWebView
// WKWebView JS
// URL Scheme
// // WebView
import UIKit
import WebKit
extension RDEPUBWebView {
/// WKWebView Publication WebView
func configureWebViewIfNeeded(publication: RDEPUBPublication) {
let parser = publication.parser
let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString
@ -66,15 +73,18 @@ extension RDEPUBWebView {
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
}
/// //
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) {
delegate?.epubWebView(self, didRequestSelectionAction: action)
clearWebSelection()
}
/// WebView
func clearWebSelection() {
webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();")
}
/// WebView
func teardownWebView() {
cancelFixedLayoutReadyFallback()
if let webView {
@ -96,6 +106,7 @@ extension RDEPUBWebView {
}
extension UIColor {
/// UIColor CSS "#FF0000"
var ss_hexString: String {
var red: CGFloat = 0
var green: CGFloat = 0

View File

@ -1,7 +1,13 @@
// RDEPUBWebView+FixedLayout.swift
// RDEPUBWebView
// Fixed Layout HTML
// 退
import UIKit
import WebKit
extension RDEPUBWebView {
/// 便 spread
public func loadFixedSpread(
parser: RDEPUBParser,
spread: EPUBFixedSpread,
@ -22,6 +28,7 @@ extension RDEPUBWebView {
load(publication: parser.makePublication(), request: request)
}
/// HTML WebView
func handleFixedLayoutLoad(
publication: RDEPUBPublication,
request: RDEPUBFixedRenderRequest,
@ -52,6 +59,7 @@ extension RDEPUBWebView {
)
}
///
func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String {
[
publicationKey,
@ -68,6 +76,7 @@ extension RDEPUBWebView {
].joined(separator: "#")
}
/// 退1 ready
func scheduleFixedLayoutReadyFallback() {
let workItem = DispatchWorkItem { [weak self] in
RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired")
@ -79,6 +88,7 @@ extension RDEPUBWebView {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem)
}
/// 退
func cancelFixedLayoutReadyFallback() {
fixedLayoutReadyWorkItem?.cancel()
fixedLayoutReadyWorkItem = nil

View File

@ -1,7 +1,14 @@
// RDEPUBWebView+JavaScriptBridge.swift
// RDEPUBWebView JS
// WKNavigationDelegate
// WKScriptMessageHandler JS ////
//
import Foundation
import WebKit
extension RDEPUBWebView {
/// URL ss-reader://
func internalLocation(from url: URL) -> RDEPUBLocation? {
let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
let fragment = url.fragment
@ -9,6 +16,7 @@ extension RDEPUBWebView {
return RDEPUBLocation(href: href, progression: 0, fragment: fragment)
}
/// JS
func currentLocation(from body: [String: Any]) -> RDEPUBLocation {
let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0
let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue
@ -43,6 +51,7 @@ extension RDEPUBWebView: WKNavigationDelegate {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
}
/// ss-reader://http/https/mailto/tel
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "decidePolicy", url: navigationAction.request.url)
guard navigationAction.navigationType == .linkActivated,
@ -69,6 +78,7 @@ extension RDEPUBWebView: WKNavigationDelegate {
}
extension RDEPUBWebView: WKScriptMessageHandler {
/// JS
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body)
switch message.name {

View File

@ -1,7 +1,14 @@
// RDEPUBWebView+Reflowable.swift
// RDEPUBWebView
// Reflowable
// JS
//
import UIKit
import WebKit
extension RDEPUBWebView {
/// 便
public func loadPage(
parser: RDEPUBParser,
spineIndex: Int,
@ -39,6 +46,7 @@ extension RDEPUBWebView {
load(publication: parser.makePublication(), request: request)
}
///
func handleReflowableLoad(
publication: RDEPUBPublication,
request: RDEPUBReflowableRenderRequest,
@ -83,6 +91,7 @@ extension RDEPUBWebView {
webView.load(URLRequest(url: requestURL))
}
///
func reflowableLoadSignature(publicationKey: String, request: RDEPUBReflowableRenderRequest) -> String {
let targetSignature = [
request.targetLocation?.href ?? "",
@ -116,6 +125,7 @@ extension RDEPUBWebView {
].joined(separator: "#")
}
/// JS CSS
func applyPresentation() {
guard let webView, let currentRenderRequest else { return }
guard case .reflowable(let request) = currentRenderRequest else {

View File

@ -1,6 +1,11 @@
// RDEPUBWebView+Search.swift
// RDEPUBWebView
// WebView
import WebKit
extension RDEPUBWebView {
/// WebView
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
guard let webView else {
completion?()

View File

@ -1,21 +1,39 @@
// RDEPUBWebView.swift
// EPUB WebView
// WKWebView EPUB Resource View
// spineIndexpageIndex
//
// +Configuration+FixedLayout+Reflowable+JavaScriptBridge+Search
import UIKit
import WebKit
/// RDEPUBWebView WebView
public protocol RDEPUBWebViewDelegate: AnyObject {
///
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
///
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
///
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
///
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
///
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL)
/// JavaScript
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
///
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
}
///
public extension RDEPUBWebViewDelegate {
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {}
}
/// WKWebView //
final class RDEPUBAnnotationWebView: WKWebView {
///
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
override var canBecomeFirstResponder: Bool {
@ -46,33 +64,61 @@ final class RDEPUBAnnotationWebView: WKWebView {
}
}
/// EPUB WebView WKWebView EPUB
/// Resource View
public final class RDEPUBWebView: UIView {
///
public weak var delegate: RDEPUBWebViewDelegate?
/// delegate 使
public var onRendered: (() -> Void)?
///
var publication: RDEPUBPublication?
///
var currentRenderRequest: RDEPUBRenderRequest?
/// WKWebView
var webView: WKWebView?
/// URL Scheme
var schemeHandler: RDEPUBResourceURLSchemeHandler?
/// WebView
var configuredPublicationKey: String?
///
var currentLoadSignature: String?
/// spine
var currentSpineIndex = 0
/// href
var currentHref = ""
/// 0
var currentPageIndex = 0
///
var currentTotalPagesInChapter = 1
///
var viewportSize: CGSize = .zero
///
var currentPadding: UIEdgeInsets = .zero
///
var currentFontSize: CGFloat = 16
///
var currentLineHeightMultiple: CGFloat = 1.5
///
var currentThemeBackgroundColor: String?
///
var currentThemeTextColor: String?
///
var targetLocation: RDEPUBLocation?
///
var pendingHighlights: [RDEPUBHighlight] = []
/// spread
var fixedSpread: EPUBFixedSpread?
///
var isFixedLayout = false
///
var pendingProgressionRequest = false
/// 退
var fixedLayoutReadyWorkItem: DispatchWorkItem?
///
var didRenderCurrentRequest = false
///
let debugScope = "ReaderWebView"
public override init(frame: CGRect) {
@ -93,6 +139,7 @@ public final class RDEPUBWebView: UIView {
webView?.frame = bounds
}
/// WebView
public func reset() {
delegate = nil
onRendered = nil
@ -117,6 +164,7 @@ public final class RDEPUBWebView: UIView {
teardownWebView()
}
/// WebView
public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) {
configureWebViewIfNeeded(publication: publication)
guard let webView else { return }
@ -143,6 +191,7 @@ public final class RDEPUBWebView: UIView {
}
}
///
func pageLoadSignature(
publicationKey: String,
request: RDEPUBRenderRequest
@ -155,6 +204,7 @@ public final class RDEPUBWebView: UIView {
}
}
/// 100ms
func rendered() {
guard !didRenderCurrentRequest else { return }
didRenderCurrentRequest = true

View File

@ -1,7 +1,15 @@
// RDEPUBWebViewDebug.swift
// WebView
// JS
// URL Scheme
// DEBUG Release UserDefaults
import Foundation
import WebKit
/// WebView
enum RDEPUBWebViewDebug {
/// DEBUG UserDefaults "RDEPUBWebViewDebugEnabled"
static var isEnabled: Bool = {
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewDebugEnabled") as? Bool {
return configured
@ -13,16 +21,19 @@ enum RDEPUBWebViewDebug {
#endif
}()
/// WebView WebView
static func webViewID(_ webView: WKWebView?) -> String {
guard let webView else { return "nil-webview" }
return String(ObjectIdentifier(webView).hashValue, radix: 16)
}
///
static func log(_ scope: String, message: String) {
guard isEnabled else { return }
print("[RDReaderWK][\(scope)] \(message)")
}
/// WebView IDURL
static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) {
guard isEnabled else { return }
let webViewToken = webViewID(webView)
@ -34,16 +45,19 @@ enum RDEPUBWebViewDebug {
}
}
/// JavaScript
static func logJavaScript(_ scope: String, webView: WKWebView?, action: String, details: String) {
guard isEnabled else { return }
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
}
/// JS
static func logMessage(_ scope: String, webView: WKWebView?, name: String, body: Any) {
guard isEnabled else { return }
log(scope, message: "webView=\(webViewID(webView)) message=\(name) body=\(String(describing: body))")
}
/// URL Scheme URL URL
static func logSchemeTask(_ scope: String, requestURL: URL?, fileURL: URL? = nil, event: String, error: Error? = nil) {
guard isEnabled else { return }
let requestText = summarizedURL(requestURL)
@ -55,6 +69,7 @@ enum RDEPUBWebViewDebug {
}
}
/// URL ss-reader:// URL
static func summarizedURL(_ url: URL?) -> String {
guard let url else { return "nil" }
if let scheme = url.scheme, scheme == RDEPUBResourceURLSchemeHandler.scheme {

View File

@ -1,7 +1,14 @@
import UIKit
/// 访便
///
/// `RDEPUBTextChapter` `indexTable`
/// fragment API
/// `RDEPUBTextBook.chapterData(for:)` `chapterData(atChapterIndex:)`
public final class RDEPUBChapterData {
///
public let chapter: RDEPUBTextChapter
/// fileIndex/row/column
public let indexTable: RDEPUBTextIndexTable
public init(chapter: RDEPUBTextChapter, indexTable: RDEPUBTextIndexTable) {
@ -9,36 +16,61 @@ public final class RDEPUBChapterData {
self.indexTable = indexTable
}
// MARK: - 便 chapter
///
public var chapterIndex: Int { chapter.chapterIndex }
/// spine
public var spineIndex: Int { chapter.spineIndex }
/// "OEBPS/chapter1.xhtml"
public var href: String { chapter.href }
///
public var title: String { chapter.title }
///
public var attributedContent: NSAttributedString { chapter.attributedContent }
///
public var pages: [RDEPUBTextPage] { chapter.pages }
/// fragment ID
public var fragmentOffsets: [String: Int] { chapter.fragmentOffsets }
// MARK: -
///
/// - Parameter absoluteOffset:
/// - Returns: nil
public func page(containing absoluteOffset: Int) -> RDEPUBTextPage? {
chapter.pages.first { NSLocationInRange(absoluteOffset, $0.contentRange) }
}
/// 0
public func pageNumber(containing absoluteOffset: Int) -> Int? {
page(containing: absoluteOffset)?.absolutePageIndex
}
///
public func page(atAbsolutePageIndex absolutePageIndex: Int) -> RDEPUBTextPage? {
chapter.pages.first { $0.absolutePageIndex == absolutePageIndex }
}
// MARK: -
/// fileIndex/row/column
public func anchor(forAbsoluteIndex index: Int) -> RDEPUBTextAnchor {
indexTable.anchor(forAbsoluteIndex: index, in: chapter)
}
///
public func rangeAnchor(for absoluteRange: NSRange) -> RDEPUBTextRangeAnchor {
let start = anchor(forAbsoluteIndex: absoluteRange.location)
let end = anchor(forAbsoluteIndex: absoluteRange.location + absoluteRange.length)
return RDEPUBTextRangeAnchor(start: start, end: end)
}
/// /
/// - Parameters:
/// - absoluteRange:
/// - bookIdentifier:
/// - Returns: nil
public func selection(from absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBSelection? {
guard page(containing: absoluteRange.location) != nil else { return nil }
let location = self.location(for: absoluteRange, bookIdentifier: bookIdentifier)
@ -56,6 +88,7 @@ public final class RDEPUBChapterData {
)
}
/// RDEPUBLocation
public func location(for absoluteRange: NSRange, bookIdentifier: String?) -> RDEPUBLocation {
indexTable.location(
for: rangeAnchor(for: absoluteRange),
@ -64,10 +97,19 @@ public final class RDEPUBChapterData {
)
}
/// RDEPUBLocation
public func location(forPage page: RDEPUBTextPage, bookIdentifier: String?) -> RDEPUBLocation {
location(for: page.contentRange, bookIdentifier: bookIdentifier)
}
// MARK: - Location
///
///
///
/// 1. rangeAnchor
/// 2. fragment ID
/// 3. navigationProgression退
public func absoluteRange(for location: RDEPUBLocation) -> NSRange? {
if let rangeAnchor = location.rangeAnchor {
return indexTable.absoluteRange(for: rangeAnchor)
@ -83,6 +125,7 @@ public final class RDEPUBChapterData {
return NSRange(location: offset, length: 1)
}
///
public func absoluteRange(for highlight: RDEPUBHighlight) -> NSRange? {
if let rangeAnchor = highlight.location.rangeAnchor {
return indexTable.absoluteRange(for: rangeAnchor)
@ -90,6 +133,7 @@ public final class RDEPUBChapterData {
return RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange
}
///
public func absoluteRange(for searchMatch: RDEPUBSearchMatch) -> NSRange? {
if let rangeAnchor = searchMatch.rangeAnchor {
return indexTable.absoluteRange(for: rangeAnchor)
@ -100,6 +144,9 @@ public final class RDEPUBChapterData {
return nil
}
// MARK: - /
///
public func highlights(on page: RDEPUBTextPage, from allHighlights: [RDEPUBHighlight]) -> [RDEPUBHighlight] {
let pageRange = absoluteOffsetRange(for: page)
return allHighlights.filter { highlight in
@ -114,6 +161,7 @@ public final class RDEPUBChapterData {
}
}
///
public func searchMatches(on page: RDEPUBTextPage, from matches: [RDEPUBSearchMatch]) -> [RDEPUBSearchMatch] {
let pageRange = absoluteOffsetRange(for: page)
return matches.filter { match in
@ -125,6 +173,7 @@ public final class RDEPUBChapterData {
}
}
/// 1
public func pageNumber(for location: RDEPUBLocation) -> Int? {
if let range = absoluteRange(for: location) {
return pageNumber(containing: range.location).map { $0 + 1 }
@ -132,12 +181,16 @@ public final class RDEPUBChapterData {
return nil
}
// MARK: -
/// [start, end+1)
private func absoluteOffsetRange(for page: RDEPUBTextPage) -> Range<Int> {
let lowerBound = page.pageStartOffset
let upperBound = page.pageEndOffset + 1
return lowerBound..<max(upperBound, lowerBound)
}
/// 退 chapterOffset
private func absoluteOffset(for anchor: RDEPUBTextAnchor) -> Int {
indexTable.absoluteIndex(
fileIndex: anchor.fileIndex,

View File

@ -4,9 +4,18 @@ import UIKit
import DTCoreText
#endif
/// DTCoreText EPUB HTML NSAttributedString
///
/// `RDEPUBTextRenderer`
/// HTML DTCoreText NSAttributedString fragment /
///
/// DTCoreText HTML
/// `willFlushCallback` DOM
/// DTCoreText 退
public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
public init() {}
/// DTCoreText
public static var isAvailable: Bool {
#if canImport(DTCoreText)
return true
@ -15,6 +24,14 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
#endif
}
/// HTML NSAttributedString fragment
///
///
/// 1. HTML Data
/// 2. DTCoreText 退
/// 3. ${rd-sem-start/end}
/// 4. fragment
/// 5.
public func renderChapter(
request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent {
@ -42,6 +59,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
#endif
}
/// 便 HTML
public func renderChapter(
html: String,
baseURL: URL?,
@ -58,6 +76,7 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
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)
@ -71,6 +90,10 @@ 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,
@ -87,6 +110,10 @@ public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
return builder?.generatedAttributedString()
}
/// DTCoreText
///
/// - `( + ) / `
/// style.lineSpacing
private func dtOptions(request: RDEPUBTextChapterRenderRequest) -> [AnyHashable: Any] {
let style = request.style
let screenBounds = UIScreen.main.bounds.insetBy(dx: 20, dy: 28)

View File

@ -1,48 +1,80 @@
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
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
@ -55,16 +87,19 @@ public struct RDEPUBTextBook {
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)
}
/// 访
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 page(at pageNumber: Int) -> RDEPUBTextPage? {
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
return nil
@ -72,6 +107,12 @@ public struct RDEPUBTextBook {
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 chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else {
@ -98,6 +139,7 @@ public struct RDEPUBTextBook {
return chapter.pages.last.map { $0.absolutePageIndex + 1 }
}
/// RDEPUBLocation
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard let page = page(at: pageNumber),
let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else {
@ -121,14 +163,29 @@ public struct RDEPUBTextBook {
}
}
// MARK: -
/// EPUB publication
///
/// spine HTML / RDEPUBTextBook
///
///
/// - WXRead
/// - /
/// -
/// -
public final class RDEPUBTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let cache: RDEPUBTextBookCache?
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) {
@ -137,10 +194,12 @@ public final class RDEPUBTextBookBuilder {
self.sampler = RDEPUBTextPerformanceSampler()
}
/// 使 DTCoreText
public convenience init() {
self.init(renderer: RDEPUBDTCoreTextRenderer())
}
/// Phase 7
public func phase7SemanticSummary(title: String? = nil) -> String? {
guard !lastBuildPaginationDiagnostics.isEmpty else { return nil }
@ -164,6 +223,16 @@ public final class RDEPUBTextBookBuilder {
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,
@ -180,7 +249,7 @@ public final class RDEPUBTextBookBuilder {
let buildStart = CFAbsoluteTimeGetCurrent()
// Cache lookup WXRead style: load per-chapter page ranges
// 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) }
@ -191,6 +260,7 @@ public final class RDEPUBTextBookBuilder {
continue
}
//
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: item.href,
@ -201,6 +271,7 @@ public final class RDEPUBTextBookBuilder {
resourceResolver: publication.resourceResolver
)
// HTML NSAttributedString
let renderStart = CFAbsoluteTimeGetCurrent()
let rendered = try renderer.renderChapter(request: request)
let renderDuration = CFAbsoluteTimeGetCurrent() - renderStart
@ -211,6 +282,7 @@ public final class RDEPUBTextBookBuilder {
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)")
@ -221,10 +293,12 @@ public final class RDEPUBTextBookBuilder {
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),
@ -245,7 +319,7 @@ public final class RDEPUBTextBookBuilder {
]
isCacheHit = false
} else if let cached = cachedPagination?[item.href] {
// Cache hit: use cached page ranges, skip rd_paginatedFrames
// 使 CoreText
layoutFrames = cached.pageRanges.enumerated().map { idx, range in
let breakReason = idx < cached.breakReasons.count ? cached.breakReasons[idx] : .frameLimit
return RDEPUBTextLayoutFrame(
@ -263,6 +337,7 @@ public final class RDEPUBTextBookBuilder {
}
isCacheHit = true
} else {
// CoreText
layoutFrames = content.length > 0
? content.rd_paginatedFrames(size: pageSize, fragmentOffsets: rendered.fragmentOffsets)
: []
@ -270,6 +345,7 @@ public final class RDEPUBTextBookBuilder {
}
let paginateDuration = CFAbsoluteTimeGetCurrent() - paginateStart
//
let normalizedFrames = normalizeTrailingFrames(
layoutFrames,
content: content,
@ -298,6 +374,7 @@ public final class RDEPUBTextBookBuilder {
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,
@ -312,6 +389,7 @@ public final class RDEPUBTextBookBuilder {
lastBuildCacheStats.misses += 1
}
//
let chapterAttributedContent = content.copy() as! NSAttributedString
let pages = effectiveFrames.enumerated().map { localPageIndex, frame in
let range = frame.contentRange
@ -369,6 +447,7 @@ public final class RDEPUBTextBookBuilder {
sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart
//
if let cacheKey {
let paginationCache = chapters.map { chapter in
let pageRanges = chapter.pages.map(\.contentRange)
@ -390,6 +469,9 @@ public final class RDEPUBTextBookBuilder {
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
@ -400,12 +482,16 @@ public final class RDEPUBTextBookBuilder {
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
@ -422,6 +508,9 @@ public final class RDEPUBTextBookBuilder {
return false
}
// MARK: -
///
private func attachmentCount(in content: NSAttributedString) -> Int {
guard content.length > 0 else { return 0 }
var count = 0
@ -433,6 +522,7 @@ public final class RDEPUBTextBookBuilder {
return count
}
/// NSRange
private func attachmentRanges(in content: NSAttributedString) -> [NSRange] {
guard content.length > 0 else { return [] }
var ranges: [NSRange] = []
@ -444,6 +534,9 @@ public final class RDEPUBTextBookBuilder {
return ranges
}
// MARK: -
/// href cover
private func isAttachmentOnlyCoverChapter(
item: RDEPUBSpineItem,
content: NSAttributedString,
@ -455,6 +548,11 @@ public final class RDEPUBTextBookBuilder {
return attachmentCount(in: content) > 0 && trimmed.count <= 1
}
// MARK: -
///
/// 1.
/// 2. 2
private func normalizeTrailingFrames(
_ frames: [RDEPUBTextLayoutFrame],
content: NSAttributedString,
@ -464,6 +562,7 @@ public final class RDEPUBTextBookBuilder {
var normalized = frames
//
while let lastFrame = normalized.last,
shouldDropWhitespaceOnlyTrailingFrame(lastFrame, in: content) {
normalized.removeLast()
@ -476,6 +575,7 @@ public final class RDEPUBTextBookBuilder {
}
}
//
guard normalized.count > 1,
let lastFrame = normalized.last,
let previousFrame = normalized.dropLast().last,
@ -489,6 +589,7 @@ public final class RDEPUBTextBookBuilder {
return normalized
}
///
private func shouldDropWhitespaceOnlyTrailingFrame(
_ frame: RDEPUBTextLayoutFrame,
in content: NSAttributedString
@ -500,6 +601,8 @@ public final class RDEPUBTextBookBuilder {
return visibleCharacterCount(in: content, range: frame.contentRange) == 0
}
///
/// 2 8 12
private func shouldMergeShortTrailingFrame(
_ trailingFrame: RDEPUBTextLayoutFrame,
previousFrame: RDEPUBTextLayoutFrame,
@ -522,6 +625,7 @@ public final class RDEPUBTextBookBuilder {
return previousVisibleCount >= max(visibleCount * 8, 12)
}
///
private func mergeTrailingFrame(
_ previousFrame: RDEPUBTextLayoutFrame,
with trailingFrame: RDEPUBTextLayoutFrame
@ -547,6 +651,7 @@ public final class RDEPUBTextBookBuilder {
)
}
///
private func visibleCharacterCount(
in content: NSAttributedString,
range: NSRange
@ -560,6 +665,9 @@ public final class RDEPUBTextBookBuilder {
return filteredScalars.count
}
// MARK: -
///
private func uniqueValues<T: Equatable>(from values: [T]) -> [T] {
values.reduce(into: [T]()) { result, value in
if !result.contains(value) {
@ -568,6 +676,7 @@ public final class RDEPUBTextBookBuilder {
}
}
/// NSRange
private func uniqueRanges(from ranges: [NSRange]) -> [NSRange] {
ranges.reduce(into: [NSRange]()) { result, value in
if !result.contains(value) {
@ -576,6 +685,7 @@ public final class RDEPUBTextBookBuilder {
}
}
///
private func attachmentCount(in content: NSAttributedString, range: NSRange) -> Int {
guard content.length > 0, range.length > 0 else { return 0 }
var count = 0
@ -587,6 +697,9 @@ public final class RDEPUBTextBookBuilder {
return count
}
// MARK: -
/// ID SHA256
private func makeCacheKey(
bookID: String,
pageSize: CGSize,

View File

@ -1,15 +1,18 @@
import Foundation
import CryptoKit
// MARK: - Pagination Cache (WXRead-style: page ranges only, no attributedString)
// MARK: - WXRead
/// Per-chapter pagination metadata cached to disk.
/// Mirrors WRChapterPageCount's caching: stores page NSRange + break reasons,
/// NOT the full attributed string. Builder re-renders HTML on cache hit but
/// skips the rd_paginatedFrames pagination step.
///
///
/// WXRead WRChapterPageCount NSRange +
/// NSAttributedString HTML CoreText
public struct RDEPUBTextChapterPaginationCache: Equatable {
///
public var href: String
///
public var pageRanges: [NSRange]
///
public var breakReasons: [RDEPUBTextPageBreakReason]
public var semanticHints: [RDEPUBTextSemanticHint]
@ -26,8 +29,10 @@ public struct RDEPUBTextChapterPaginationCache: Equatable {
}
}
// MARK: - NSCoding Archive (simple: strings + ints only)
// MARK: - NSCoding 使
/// NSKeyedArchiver
///
final class PaginationCacheArchive: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool { true }
@ -47,14 +52,20 @@ final class PaginationCacheArchive: NSObject, NSSecureCoding {
}
}
/// NSRange location/length 便 NSSecureCoding
final class ChapterPaginationArchive: NSObject, NSSecureCoding {
static var supportsSecureCoding: Bool { true }
let href: String
/// rangeLengths
let rangeLocations: [NSNumber]
///
let rangeLengths: [NSNumber]
///
let breakReasons: [String]
///
let semanticHints: [String]
///
init(from cache: RDEPUBTextChapterPaginationCache) {
self.href = cache.href
self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) }
@ -86,6 +97,7 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
self.semanticHints = semanticHints
}
///
func toCache() -> RDEPUBTextChapterPaginationCache {
let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in
NSRange(location: loc.intValue, length: len.intValue)
@ -99,19 +111,27 @@ final class ChapterPaginationArchive: NSObject, NSSecureCoding {
}
}
// MARK: - RDEPUBTextBookCache
// MARK: -
/// Disk-persistent cache for per-chapter pagination metadata.
/// Follows WXRead's WRChapterPageCount caching pattern:
/// cache key = bookID + layout settings, cache value = page NSRange per chapter.
/// Attributed strings are NOT cached builder re-renders HTML on cache hit.
/// WXRead WRChapterPageCount
///
///
/// - = SHA256(ID + + + + + schema)
/// - = NSRange + NSKeyedArchiver
/// - HTML CoreText
/// - 线 serial DispatchQueue
public final class RDEPUBTextBookCache {
///
public var schemaVersion: Int = 1
/// 线
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
///
private let cacheDirectory: URL
///
/// - Parameter subdirectory: Caches
public init(subdirectory: String = "RDEPUBTextBookCache") {
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent(subdirectory, isDirectory: true)
@ -120,10 +140,13 @@ public final class RDEPUBTextBookCache {
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
// MARK: - Cache Key
// Mirrors WRChapterPageCount.currentCacheKeyWithBookId:
// Encodes bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
// MARK: -
// WRChapterPageCount.currentCacheKeyWithBookId
// bookID + fontSize + lineHeightMultiple + contentInsets + pageSize
/// SHA256 + ".cache"
///
///
public func cacheKey(
bookID: String,
fontSize: CGFloat,
@ -137,10 +160,10 @@ public final class RDEPUBTextBookCache {
return hex + ".cache"
}
// MARK: - Load / Save (pagination metadata only)
// MARK: - /
/// Load cached pagination metadata for all chapters.
/// Returns dictionary keyed by chapter href.
/// href
/// nil
public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? {
queue.sync {
let fileURL = cacheDirectory.appendingPathComponent(key)
@ -170,7 +193,7 @@ public final class RDEPUBTextBookCache {
}
}
/// Save pagination metadata for all chapters.
///
public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) {
queue.sync {
let fileURL = cacheDirectory.appendingPathComponent(key)
@ -186,8 +209,9 @@ public final class RDEPUBTextBookCache {
}
}
// MARK: - Invalidate
// MARK: -
///
public func invalidateAll() {
queue.sync {
let fileManager = FileManager.default

View File

@ -1,8 +1,18 @@
// RDEPUBTextIndexTable.swift
// EPUB
// href /spine
// fragment
//
import Foundation
/// -
public struct RDEPUBRowColumnIndex: Codable, Equatable {
///
public let row: Int
///
public let startOffset: Int
///
public let endOffset: Int
public init(row: Int, startOffset: Int, endOffset: Int) {
@ -11,20 +21,30 @@ public struct RDEPUBRowColumnIndex: Codable, Equatable {
self.endOffset = endOffset
}
///
public func contains(_ offset: Int) -> Bool {
offset >= startOffset && offset <= endOffset
}
}
///
public struct RDEPUBTextIndexTable {
///
public let chapterStartOffsets: [Int]
///
public let chapterLengths: [Int]
/// href
public let hrefToChapterIndex: [String: Int]
/// href spine
public let hrefToFileIndex: [String: Int]
/// href (fragment ID )
public let fragmentOffsetsByHref: [String: [String: Int]]
/// spine href
public let fileIndexToHref: [Int: String]
/// spine
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
///
public init(chapters: [RDEPUBTextChapter]) {
var offsets: [Int] = []
var lengths: [Int] = []
@ -55,6 +75,7 @@ public struct RDEPUBTextIndexTable {
self.fileRowColumnMap = rowColumnMap
}
///
public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor {
let normalizedIndex = clampedOffset(index, in: chapter)
let fragmentID = nearestFragmentID(beforeOrAt: normalizedIndex, in: chapter)
@ -69,6 +90,7 @@ public struct RDEPUBTextIndexTable {
)
}
/// 使 rangeAnchor fragment progression
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
if let anchor = location.rangeAnchor?.start {
return anchor
@ -96,6 +118,7 @@ public struct RDEPUBTextIndexTable {
)
}
///
public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
@ -105,14 +128,17 @@ public struct RDEPUBTextIndexTable {
}.map { $0 + basePageIndex }
}
/// href
public func href(for fileIndex: Int) -> String? {
fileIndexToHref[fileIndex]
}
/// href
public func chapterIndex(for href: String) -> Int? {
hrefToChapterIndex[href]
}
///
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
absoluteIndex(
fileIndex: anchor.fileIndex,
@ -121,12 +147,14 @@ public struct RDEPUBTextIndexTable {
) ?? anchor.chapterOffset
}
/// NSRange
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
let start = absoluteIndex(for: rangeAnchor.start)
let end = max(start, absoluteIndex(for: rangeAnchor.end))
return NSRange(location: start, length: max(end - start, 0))
}
/// progression
public func location(
for anchor: RDEPUBTextAnchor,
in chapter: RDEPUBTextChapter,
@ -145,6 +173,7 @@ public struct RDEPUBTextIndexTable {
)
}
/// progression
public func location(
for rangeAnchor: RDEPUBTextRangeAnchor,
in chapter: RDEPUBTextChapter,
@ -165,6 +194,7 @@ public struct RDEPUBTextIndexTable {
)
}
///
public func row(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
if let rowIndex = rows.first(where: { $0.contains(index) })?.row {
@ -173,6 +203,7 @@ public struct RDEPUBTextIndexTable {
return rows.last?.row ?? 0
}
///
public func column(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
if let rowEntry = rows.first(where: { $0.contains(index) }) {
@ -182,6 +213,7 @@ public struct RDEPUBTextIndexTable {
return max(index - lastRow.startOffset, 0)
}
///
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
let normalizedRow = min(max(row, 0), rows.count - 1)
@ -190,11 +222,13 @@ public struct RDEPUBTextIndexTable {
return rowEntry.startOffset + min(max(column, 0), maxColumn)
}
///
private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
let lastOffset = max(chapter.attributedContent.length - 1, 0)
return min(max(index, 0), lastOffset)
}
/// fragment ID
private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? {
var bestID: String?
var bestOffset = -1
@ -209,6 +243,7 @@ public struct RDEPUBTextIndexTable {
return bestID
}
///
private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
let nsText = text as NSString
let length = nsText.length

View File

@ -1,17 +1,33 @@
import Foundation
/// CoreText
///
/// `RDEPUBTextLayouter`
/// `RDEPUBTextBookBuilder` `RDEPUBTextLayoutFrame`
/// `RDEPUBTextPage` `RDEPUBTextBook`
struct RDEPUBTextLayoutFrame: Equatable {
///
var contentRange: NSRange
///
var breakReason: RDEPUBTextPageBreakReason
/// HTML
var blockRange: NSRange?
///
var attachmentRanges: [NSRange]
///
var attachmentKinds: [RDEPUBTextAttachmentKind]
/// HTML
var blockKinds: [RDEPUBTextBlockKind]
///
var semanticHints: [RDEPUBTextSemanticHint]
/// 线
var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
/// fragment ID
var trailingFragmentID: String?
///
var diagnostics: [String]
///
var metadata: RDEPUBTextPageMetadata {
RDEPUBTextPageMetadata(
breakReason: breakReason,

View File

@ -5,11 +5,29 @@ import UIKit
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
/// avoidPageBreakInside
private let config: RDEPUBTextLayoutConfig
init(attributedString: NSAttributedString, pageSize: CGSize, config: RDEPUBTextLayoutConfig = .default) {
@ -20,6 +38,8 @@ struct RDEPUBTextLayouter {
self.path = CGPath(rect: CGRect(origin: .zero, size: pageSize), transform: nil)
}
///
/// DTCoreText CoreText
func layoutFrames(fragmentOffsets: [String: Int] = [:]) -> [RDEPUBTextLayoutFrame] {
guard attributedString.length > 0, pageSize.width > 0, pageSize.height > 0 else {
return []
@ -32,6 +52,11 @@ struct RDEPUBTextLayouter {
#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 []
@ -49,7 +74,7 @@ struct RDEPUBTextLayouter {
let proposedRange = NSRange(location: location, length: visibleRange.length)
// Line-level avoidPageBreakInside (WXRead approach: scan CTFrame lines backward)
// avoidPageBreakInside WXRead
let avoidAdjusted = trimmedRangeForAvoidPageBreakInside(from: frame, proposed: proposedRange)
let lineAdjusted = trimmedRangeForKeepWithNext(from: frame, proposed: avoidAdjusted)
let lineRanges = lineRanges(from: frame)
@ -89,7 +114,10 @@ struct RDEPUBTextLayouter {
return frames
}
// MARK: - DTCoreText
#if canImport(DTCoreText)
/// 使 DTCoreTextLayouter
private func layoutFramesUsingDTCoreText(fragmentOffsets: [String: Int]) -> [RDEPUBTextLayoutFrame] {
guard let layouter = DTCoreTextLayouter(attributedString: attributedString) else {
return layoutFramesUsingCoreText(fragmentOffsets: fragmentOffsets)
@ -152,6 +180,18 @@ struct RDEPUBTextLayouter {
}
#endif
// MARK: -
/// CoreText
///
///
/// 1. chapterEnd
/// 2. pageBreakBefore/After
/// 3. pageRelate
/// 4.
/// 5. 使
///
/// 55%
private func adjustedRange(
from proposedRange: NSRange,
totalLength: Int,
@ -193,6 +233,7 @@ struct RDEPUBTextLayouter {
)
}
// 55%
let minLength = max(Int(Double(proposedRange.length) * 0.55), 1)
let minimumEnd = proposedRange.location + minLength
@ -203,6 +244,7 @@ struct RDEPUBTextLayouter {
let currentSemanticHints = proposedSemanticHints
let currentAttachmentPlacements = proposedAttachmentPlacements
// 1. pageBreakBefore/After
if let semanticBoundary = preferredSemanticBoundary(
in: proposedRange,
minimumEnd: minimumEnd
@ -230,6 +272,7 @@ struct RDEPUBTextLayouter {
)
}
// 2. pageRelate
if let pageRelateBoundary = preferredPageRelateBoundary(
after: proposedRange,
minimumEnd: minimumEnd,
@ -258,6 +301,7 @@ struct RDEPUBTextLayouter {
)
}
// 3.
if let attachmentBoundary = preferredAttachmentBoundary(
in: proposedRange,
minimumEnd: minimumEnd
@ -284,6 +328,7 @@ struct RDEPUBTextLayouter {
)
}
// 4.
return (
range: proposedRange,
breakReason: .frameLimit,
@ -305,6 +350,11 @@ struct RDEPUBTextLayouter {
)
}
// MARK: -
/// pageBreakBefore / pageBreakAfter
///
/// minimumEnd
private func preferredSemanticBoundary(
in range: NSRange,
minimumEnd: Int
@ -337,6 +387,8 @@ struct RDEPUBTextLayouter {
return boundary
}
/// .attachment .centered
///
private func preferredAttachmentBoundary(in range: NSRange, minimumEnd: Int) -> Int? {
var boundary: Int?
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, stop in
@ -346,9 +398,8 @@ struct RDEPUBTextLayouter {
let placement = attachmentPlacement(at: location)
let blockKind = blockKind(at: location)
// Mirror WXRead more closely: only block-level attachments should
// push the entire block to the next page. Inline footnote icons and
// other inline attachments must not cause a whole paragraph to move.
// WXRead
//
let isBlockLevelAttachment = blockKind == .attachment || placement == .centered
guard isBlockLevelAttachment else { return }
@ -361,6 +412,8 @@ struct RDEPUBTextLayouter {
return boundary
}
/// pageRelate pageRelate
///
private func preferredPageRelateBoundary(
after range: NSRange,
minimumEnd: Int,
@ -384,6 +437,9 @@ struct RDEPUBTextLayouter {
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)
@ -393,6 +449,7 @@ struct RDEPUBTextLayouter {
return nil
}
///
private func blockKind(at location: Int) -> RDEPUBTextBlockKind? {
guard location >= 0, location < attributedString.length else { return nil }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@ -400,6 +457,7 @@ struct RDEPUBTextLayouter {
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)
@ -407,6 +465,7 @@ struct RDEPUBTextLayouter {
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) }
@ -414,6 +473,7 @@ struct RDEPUBTextLayouter {
return source.paragraphRange(for: NSRange(location: safeLocation, length: 0))
}
///
private func attachmentRanges(in range: NSRange) -> [NSRange] {
var results: [NSRange] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, attributeRange, _ in
@ -423,6 +483,7 @@ struct RDEPUBTextLayouter {
return results
}
///
private func semanticHints(at location: Int) -> [RDEPUBTextSemanticHint] {
guard location >= 0, location < attributedString.length else { return [] }
let attributes = attributedString.attributes(at: location, effectiveRange: nil)
@ -432,6 +493,7 @@ struct RDEPUBTextLayouter {
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
}
///
private func attachmentKinds(in range: NSRange) -> [RDEPUBTextAttachmentKind] {
var kinds: [RDEPUBTextAttachmentKind] = []
attributedString.enumerateAttribute(.rdPageAttachmentKind, in: range) { value, _, _ in
@ -445,6 +507,7 @@ struct RDEPUBTextLayouter {
return kinds
}
///
private func blockKinds(in range: NSRange) -> [RDEPUBTextBlockKind] {
var kinds: [RDEPUBTextBlockKind] = []
attributedString.enumerateAttribute(.rdPageBlockKind, in: range) { value, _, _ in
@ -458,6 +521,7 @@ struct RDEPUBTextLayouter {
return kinds
}
///
private func semanticHints(in range: NSRange) -> [RDEPUBTextSemanticHint] {
var hints: [RDEPUBTextSemanticHint] = []
attributedString.enumerateAttribute(.rdPageSemanticHints, in: range) { value, _, _ in
@ -469,6 +533,7 @@ struct RDEPUBTextLayouter {
return hints
}
///
private func attachmentPlacements(in range: NSRange) -> [RDEPUBTextAttachmentPlacement] {
var placements: [RDEPUBTextAttachmentPlacement] = []
attributedString.enumerateAttribute(.rdPageAttachmentPlacement, in: range) { value, _, _ in
@ -482,6 +547,7 @@ struct RDEPUBTextLayouter {
return placements
}
/// fragment ID
private func nearestTrailingFragmentID(
endingAt location: Int,
fragmentOffsets: [String: Int]
@ -492,11 +558,11 @@ struct RDEPUBTextLayouter {
.key
}
// MARK: - Line-level avoidPageBreakInside (WXRead approach)
// MARK: - avoidPageBreakInsideWXRead
/// Scans CTFrame lines backward from the last line, removing trailing lines
/// that fall inside an avoidPageBreakInside block. Mirrors WXRead's
/// WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded.
/// CTFrame avoidPageBreakInside
/// WXRead WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded
/// 3 kMaxLinesToRemove
private func trimmedRangeForAvoidPageBreakInside(
from frame: CTFrame,
proposed: NSRange
@ -509,8 +575,8 @@ struct RDEPUBTextLayouter {
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
// Scan backward: count consecutive trailing lines inside protected blocks
// kMaxLinesToRemove = 3 (same as WXRead)
//
// kMaxLinesToRemove = 3 WXRead
let kMaxLinesToRemove = 3
var linesToRemove = 0
@ -521,8 +587,7 @@ struct RDEPUBTextLayouter {
if lineIsInAvoidPageBreakInsideBlock(lineNSRange) {
linesToRemove += 1
if linesToRemove >= kMaxLinesToRemove {
// Don't remove more than kMaxLinesToRemove; stop here
// (line at index i stays, so linesToRemove stays at kMaxLinesToRemove)
// kMaxLinesToRemove
linesToRemove = kMaxLinesToRemove
break
}
@ -535,7 +600,7 @@ struct RDEPUBTextLayouter {
let validLineCount = lines.count - linesToRemove
guard validLineCount > 0 else {
// All lines are in protected blocks; fall back to proposed
// 退
return proposed
}
@ -549,6 +614,7 @@ struct RDEPUBTextLayouter {
return NSRange(location: proposed.location, length: adjustedLength)
}
/// CoreText keepWithNext
private func trimmedRangeForKeepWithNext(
from frame: CTFrame,
proposed: NSRange
@ -561,7 +627,10 @@ struct RDEPUBTextLayouter {
return trimmedRangeForKeepWithNext(proposed: proposed, lineRanges: lineRanges)
}
// MARK: - DTCoreText
#if canImport(DTCoreText)
/// DTCoreText avoidPageBreakInside
private func trimmedRangeForAvoidPageBreakInside(
from layoutFrame: DTCoreTextLayoutFrame,
proposed: NSRange
@ -603,6 +672,7 @@ struct RDEPUBTextLayouter {
return NSRange(location: proposed.location, length: adjustedLength)
}
/// DTCoreText keepWithNext
private func trimmedRangeForKeepWithNext(
from layoutFrame: DTCoreTextLayoutFrame,
proposed: NSRange
@ -615,6 +685,8 @@ struct RDEPUBTextLayouter {
}
#endif
/// keepWithNext
/// 3
private func trimmedRangeForKeepWithNext(
proposed: NSRange,
lineRanges: [NSRange]
@ -648,7 +720,7 @@ struct RDEPUBTextLayouter {
return NSRange(location: proposed.location, length: adjustedLength)
}
/// Checks if a line's string range intersects with an avoidPageBreakInside block.
/// avoidPageBreakInside
private func lineIsInAvoidPageBreakInsideBlock(_ lineRange: NSRange) -> Bool {
var found = false
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
@ -665,6 +737,7 @@ struct RDEPUBTextLayouter {
return found
}
/// keepWithNext
private func lineIsInKeepWithNextBlock(_ lineRange: NSRange) -> Bool {
var found = false
let probeRange = NSRange(location: lineRange.location, length: max(lineRange.length, 1))
@ -681,6 +754,7 @@ struct RDEPUBTextLayouter {
return found
}
/// CTFrame
private func lineRanges(from frame: CTFrame) -> [NSRange] {
let lines = CTFrameGetLines(frame) as! [CTLine]
return lines.map {
@ -690,6 +764,7 @@ struct RDEPUBTextLayouter {
}
#if canImport(DTCoreText)
/// DTCoreTextLayoutFrame
private func lineRanges(from layoutFrame: DTCoreTextLayoutFrame) -> [NSRange] {
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else {
return []
@ -698,6 +773,9 @@ struct RDEPUBTextLayouter {
}
#endif
// MARK: -
///
private func diagnostics(
reason: RDEPUBTextPageBreakReason,
range: NSRange,

View File

@ -1,7 +1,18 @@
import CoreText
import UIKit
// MARK: - NSAttributedString
/// NSAttributedString 便 CoreText
extension NSAttributedString {
///
///
/// `RDEPUBTextBookBuilder` `RDPlainTextBookBuilder`
/// - Parameters:
/// - size:
/// - fragmentOffsets: fragment ID
/// - config: avoidPageBreakInside
/// - Returns:
func rd_paginatedFrames(
size: CGSize,
fragmentOffsets: [String: Int] = [:],
@ -11,12 +22,17 @@ extension NSAttributedString {
.layoutFrames(fragmentOffsets: fragmentOffsets)
}
/// NSRange
func ss_pageRanges(size: CGSize) -> [NSRange] {
rd_paginatedFrames(size: size).map(\.contentRange)
}
}
// MARK: - UIColor CSS
/// UIColor CSS CSS
extension UIColor {
/// UIColor CSS rgba()
var ss_cssString: String {
var red: CGFloat = 0
var green: CGFloat = 0

View File

@ -1,13 +1,20 @@
import Foundation
// MARK: - Performance Sample
// MARK: -
///
public struct RDEPUBTextPerformanceSample: Equatable {
///
public var chapterHref: String
/// HTML
public var renderDuration: TimeInterval
/// CoreText
public var paginateDuration: TimeInterval
///
public var pageCount: Int
///
public var attributedStringLength: Int
///
public var cacheHit: Bool
public init(
@ -27,19 +34,27 @@ public struct RDEPUBTextPerformanceSample: Equatable {
}
}
// MARK: - Performance Sampler
// MARK: -
///
///
/// `RDEPUBTextBookBuilder` 使
///
public final class RDEPUBTextPerformanceSampler {
///
public private(set) var samples: [RDEPUBTextPerformanceSample] = []
///
public var totalBuildDuration: TimeInterval = 0
public init() {}
///
public func record(_ sample: RDEPUBTextPerformanceSample) {
samples.append(sample)
print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")")
}
/// /
public func summary() -> String {
let totalRender = samples.reduce(0) { $0 + $1.renderDuration }
let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration }
@ -47,11 +62,13 @@ public final class RDEPUBTextPerformanceSampler {
return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)"
}
///
public func reset() {
samples.removeAll()
totalBuildDuration = 0
}
///
private func formatMS(_ duration: TimeInterval) -> String {
String(format: "%.0fms", duration * 1000)
}

View File

@ -1,43 +1,71 @@
import UIKit
// MARK: -
/// EPUBTextRendering 使 NSAttributedString
/// `RDEPUBTextRendererSupport`
/// `RDEPUBTextLayouter`
public extension NSAttributedString.Key {
/// NSString NSRange
static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange")
///
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
/// fragment ID
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
/// /
static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind")
/// ///
static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind")
///
static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints")
/// /线/
static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement")
}
// MARK: -
/// HTML
public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
case paragraph
case list
case table
case code
case blockquote
case attachment
case generic
case paragraph // <p>
case list // <ul>, <ol>, <li>
case table // <table>
case code // <pre>, <code>
case blockquote // <blockquote>
case attachment // figure
case generic // <div>, <h1>-<h6>
}
// MARK: -
/// /
public enum RDEPUBTextSemanticHint: String, Codable, Equatable, CaseIterable {
case avoidPageBreakInside
case keepWithNext
case pageBreakBefore
case pageBreakAfter
case pageRelate
case avoidPageBreakInside //
case keepWithNext //
case pageBreakBefore //
case pageBreakAfter //
case pageRelate //
}
// MARK: -
///
public enum RDEPUBTextAttachmentPlacement: String, Codable, Equatable {
case inline
case baseline
case centered
case inline //
case baseline // 线
case centered //
}
// MARK: -
///
public struct RDEPUBTextRenderStyle {
/// EPUB
public var font: UIFont
/// pt
public var lineSpacing: CGFloat
/// nil 使 EPUB
public var textColor: UIColor?
/// nil
public var backgroundColor: UIColor?
public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
@ -48,10 +76,17 @@ public struct RDEPUBTextRenderStyle {
}
}
// MARK: -
///
public struct RDEPUBTextLayoutConfig: Equatable {
///
public var avoidOrphans: Bool
///
public var avoidWidows: Bool
/// avoidPageBreakInside WXRead 退
public var avoidPageBreakInsideEnabled: Bool
///
public var imageMaxHeightRatio: CGFloat
public init(
@ -66,17 +101,22 @@ public struct RDEPUBTextLayoutConfig: Equatable {
self.imageMaxHeightRatio = imageMaxHeightRatio
}
///
public static let `default` = RDEPUBTextLayoutConfig()
}
// MARK: - CSS
/// CSS
public enum RDEPUBTextStyleSheetLayerKind: String, CaseIterable, Equatable {
case `default`
case replace
case dark
case epub
case user
case `default` // marginpadding
case replace //
case dark //
case epub // EPUB
case user //
}
/// CSS CSS
public struct RDEPUBTextStyleSheetLayer: Equatable {
public var kind: RDEPUBTextStyleSheetLayerKind
public var css: String
@ -87,6 +127,9 @@ public struct RDEPUBTextStyleSheetLayer: Equatable {
}
}
/// CSS CSS
///
/// CSS default replace dark epub user
public struct RDEPUBTextStyleSheetPackage: Equatable {
public var layers: [RDEPUBTextStyleSheetLayer]
@ -94,6 +137,7 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
self.layers = layers
}
/// CSS
public var combinedCSS: String {
layers
.filter { !$0.css.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
@ -104,11 +148,15 @@ public struct RDEPUBTextStyleSheetPackage: Equatable {
}
}
// MARK: -
///
public enum RDEPUBTextResourceReferenceKind: String, Equatable {
case stylesheet
case image
}
/// EPUB
public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
public var kind: RDEPUBTextResourceReferenceKind
public var chapterHref: String
@ -134,6 +182,9 @@ public struct RDEPUBTextResourceReferenceDiagnostic: Equatable {
}
}
// MARK: -
/// HTML
public struct RDEPUBTextChapterContext: Equatable {
public var href: String
public var title: String
@ -159,6 +210,7 @@ public struct RDEPUBTextChapterContext: Equatable {
}
}
/// `RDEPUBTextRenderer`
public struct RDEPUBTextChapterRenderRequest {
public var context: RDEPUBTextChapterContext
public var style: RDEPUBTextRenderStyle
@ -169,9 +221,15 @@ public struct RDEPUBTextChapterRenderRequest {
}
}
// MARK: -
/// fragment
public struct RDEPUBRenderedChapterContent {
///
public var attributedString: NSAttributedString
/// fragment ID
public var fragmentOffsets: [String: Int]
///
public var resourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic]
public init(
@ -185,11 +243,18 @@ public struct RDEPUBRenderedChapterContent {
}
}
// MARK: -
/// EPUB HTML NSAttributedString
///
/// `RDEPUBDTCoreTextRenderer` DTCoreText
public protocol RDEPUBTextRenderer {
///
func renderChapter(
request: RDEPUBTextChapterRenderRequest
) throws -> RDEPUBRenderedChapterContent
/// 便 HTML
func renderChapter(
html: String,
baseURL: URL?,
@ -197,6 +262,7 @@ public protocol RDEPUBTextRenderer {
) throws -> RDEPUBRenderedChapterContent
}
/// 便
public extension RDEPUBTextRenderer {
func renderChapter(
html: String,
@ -215,9 +281,12 @@ public extension RDEPUBTextRenderer {
}
}
// MARK: -
/// EPUB
public enum RDEPUBTextRenderingError: LocalizedError {
case htmlEncodingFailed
case htmlImportFailed
case htmlEncodingFailed // HTML Data
case htmlImportFailed // HTML
public var errorDescription: String? {
switch self {

View File

@ -4,13 +4,36 @@ import UIKit
import DTCoreText
#endif
/// HTML fragment /CSS
///
/// `RDEPUBTextBookBuilder`
/// `makeChapterRenderRequest()` HTML
///
///
/// - HTML
/// - Fragment /id ${id=xxx}
/// - HTML ${rd-sem-start/end} NSAttributedString
/// - CSS default/replace/dark/epub/user
/// - EPUB
/// -
enum RDEPUBTextRendererSupport {
///
private static let stylesheetLinkPattern = #"<link\b[^>]*rel\s*=\s*["'][^"']*stylesheet[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#
///
private static let imageSourcePattern = #"<img\b[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#
/// ${rd-sem-start:...} / ${rd-sem-end:...}
private static let semanticMarkerPattern = #"\$\{rd-sem-(start|end):([^}]+)\}"#
///
private static var didLogFootnoteAttachment = false
///
private static var didLogCoverAttachment = false
// MARK: - Fragment /
/// HTML id fragment
///
/// `<tag id="xxx" ...>` `${id=xxx}<tag id="xxx" ...>`
/// 使 DTCoreText fragment
static func injectFragmentMarkers(into html: String) -> String {
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
return html
@ -23,6 +46,10 @@ enum RDEPUBTextRendererSupport {
)
}
/// fragment
///
/// `${id=xxx}` fragment ID
/// [fragmentID: offset]
static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
let markerPattern = #"\$\{id=([^}]+)\}"#
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
@ -51,6 +78,10 @@ enum RDEPUBTextRendererSupport {
return fragmentOffsets
}
/// HTML ${rd-sem-start/end}
///
/// blockKindsemanticHintsattachmentPlacement
/// .rdPage*
static func applyPaginationSemantics(in attributedString: NSMutableAttributedString) {
guard let regex = try? NSRegularExpression(pattern: semanticMarkerPattern, options: []) else {
return
@ -85,6 +116,14 @@ enum RDEPUBTextRendererSupport {
}
}
///
///
///
/// 1. EPUB /
/// 2.
/// 3.
/// 4.
/// 5.
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
let fullRange = NSRange(location: 0, length: attributedString.length)
var blockIndex = 0
@ -128,6 +167,7 @@ enum RDEPUBTextRendererSupport {
}
}
/// 退 DTCoreText HTML
static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
let fallbackAttributes: [NSAttributedString.Key: Any] = [
.font: style.font,
@ -137,6 +177,16 @@ enum RDEPUBTextRendererSupport {
return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
}
/// HTML
///
///
/// 1. normalizeHTML HTML
/// 2. injectPaginationSemanticMarkers HTML
/// 3. inlineLinkedStyleSheets HTML
/// 4. CSSdefault/replace/dark/epub/user style
/// 5. injectBaseHref base
/// 6. injectFragmentMarkers fragment
/// 7. collectImageDiagnostics
static func makeChapterRenderRequest(
href: String,
title: String,
@ -197,6 +247,7 @@ enum RDEPUBTextRendererSupport {
return RDEPUBTextChapterRenderRequest(context: context, style: style)
}
/// HTML CR HTML
static func normalizeHTML(_ html: String) -> String {
var cleanedHTML = html
let replacements: [(pattern: String, template: String)] = [

View File

@ -1,5 +1,10 @@
import Foundation
/// EPUB
///
/// `RDEPUBSearchEngine`
/// 使
///
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
private let textBook: RDEPUBTextBook
private let publication: RDEPUBPublication
@ -9,6 +14,14 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
self.publication = publication
}
///
///
///
/// 1.
/// 2.
/// 3. progression
/// 4. 12
/// 5. /
func search(keyword: String) -> [RDEPUBSearchMatch] {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
@ -60,6 +73,7 @@ final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
return matches
}
/// 12
private func previewText(in text: NSString, matchRange: NSRange) -> String {
let previewRadius = 12
let start = max(matchRange.location - previewRadius, 0)

View File

@ -1,6 +1,16 @@
import UIKit
/// RDEPUBTextBook EPUB 线
/// TXT RDEPUBTextBook EPUB 线
///
/// .txt EPUB
/// 使 EPUBReader
///
///
/// 1. UTF-8 GB18030 GBK
/// 2.
/// 3. HTML
/// 4. DTCoreText
/// 5. CoreText RDEPUBTextBook
public final class RDPlainTextBookBuilder {
private let renderer: RDEPUBTextRenderer
@ -8,6 +18,13 @@ public final class RDPlainTextBookBuilder {
self.renderer = renderer
}
///
///
/// - Parameters:
/// - textFileURL: URL
/// - pageSize:
/// - style:
/// - Returns:
public func build(
textFileURL: URL,
pageSize: CGSize,
@ -86,12 +103,14 @@ public final class RDPlainTextBookBuilder {
// MARK: -
/// +
private struct ChapterSpec {
let title: String?
let content: String
}
///
/// "X///"
///
private func splitChapters(from text: String) -> [ChapterSpec] {
let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
@ -126,7 +145,7 @@ public final class RDPlainTextBookBuilder {
// MARK: - HTML
/// HTML
/// HTML `<p>`
private func wrapTextAsHTML(_ text: String) -> String {
let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n")
@ -135,6 +154,7 @@ public final class RDPlainTextBookBuilder {
// MARK: -
/// UTF-8 GB18030 GBK
private func rd_decodeTextFile(url: URL) -> String {
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
return content

View File

@ -4,6 +4,11 @@ import UIKit
import DTCoreText
#endif
// MARK: -
///
/// DTCoreText
///
final class RDEPUBPageInteractionController {
var snapshot: RDEPUBPageLayoutSnapshot?

View File

@ -4,29 +4,56 @@ import UIKit
import DTCoreText
#endif
// MARK: -
///
/// DTCoreText 线
struct RDEPUBPageLine {
/// chapterContent
let stringRange: NSRange
///
let frame: CGRect
/// 线
let baselineOrigin: CGPoint
/// 线
let ascent: CGFloat
/// 线
let descent: CGFloat
///
let leading: CGFloat
}
/// Run
///
struct RDEPUBPageRun {
/// Run
let stringRange: NSRange
/// Run
let frame: CGRect
///
let isAttachment: Bool
}
///
///
struct RDEPUBPageAttachment {
///
let stringRange: NSRange
///
let frame: CGRect
///
let displaySize: CGSize
/// /
let placement: RDEPUBTextAttachmentPlacement?
/// /
let kind: RDEPUBTextAttachmentKind?
}
// MARK: -
///
/// DTCoreText
///
struct RDEPUBPageLayoutSnapshot {
let page: RDEPUBTextPage
let lines: [RDEPUBPageLine]

View File

@ -1,12 +1,27 @@
import UIKit
// MARK: -
///
///
/// configuration
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
// MARK:
///
var onShowTableOfContents: (() -> Void)?
///
var onShowBookmarks: (() -> Void)?
///
var onShowHighlights: (() -> Void)?
///
var onAddHighlight: (() -> Void)?
///
var onShowSettings: (() -> Void)?
// MARK: UI
///
private let stackView: UIStackView = {
let view = UIStackView()
view.axis = .horizontal
@ -16,10 +31,15 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
return view
}()
///
private let chapterButton = RDEPUBReaderTintButton(type: .system)
///
private let bookmarksButton = RDEPUBReaderTintButton(type: .system)
///
private let highlightsButton = RDEPUBReaderTintButton(type: .system)
///
private let addHighlightButton = RDEPUBReaderTintButton(type: .system)
///
private let settingsButton = RDEPUBReaderTintButton(type: .system)
override init(frame: CGRect) {
@ -78,6 +98,9 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
}
}
// MARK:
///
func setAddHighlightEnabled(_ isEnabled: Bool) {
addHighlightButton.isEnabled = isEnabled
addHighlightButton.alpha = isEnabled ? 1 : 0.45

View File

@ -1,6 +1,11 @@
import UIKit
// MARK: -
/// EPUB
///
final class RDEPUBReaderChapterListController: UITableViewController {
///
var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
private let items: [RDEPUBReaderTableOfContentsItem]

View File

@ -1,24 +1,75 @@
import UIKit
// MARK: -
/// EPUB
///
public enum RDEPUBTextRenderingEngine: Equatable {
/// DTCoreText HTML/CSS NSAttributedString
case dtCoreText
}
// MARK: -
/// EPUB
/// EPUBUI
/// RDEPUBReaderController
public struct RDEPUBReaderConfiguration: Equatable {
// MARK:
/// pt 15
public var fontSize: CGFloat
/// 1.6 1.6
public var lineHeightMultiple: CGFloat
/// .pageCurl仿 .scroll
public var displayType: RDReaderView.DisplayType
///
public var landscapeDualPageEnabled: Bool
// MARK: UI
///
public var showsTableOfContents: Bool
///
public var allowsHighlights: Bool
///
public var showsSettingsPanel: Bool
// MARK:
/// reflowable 40pt 16pt
public var reflowableContentInsets: UIEdgeInsets
/// fixed layout
public var fixedContentInset: UIEdgeInsets
// MARK:
/// // .light
public var theme: RDEPUBReaderTheme
///
public var fixedLayoutFit: RDEPUBFixedLayoutFit
/// //
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
/// 使 DTCoreText
public var textRenderingEngine: RDEPUBTextRenderingEngine
// MARK:
///
/// - Parameters:
/// - fontSize: 15pt
/// - lineHeightMultiple: 1.6
/// - displayType: .pageCurl
/// - landscapeDualPageEnabled: true
/// - showsTableOfContents: true
/// - allowsHighlights: true
/// - showsSettingsPanel: true
/// - reflowableContentInsets:
/// - fixedContentInset:
/// - theme: .light
/// - fixedLayoutFit:
/// - fixedLayoutSpreadMode:
/// - textRenderingEngine:
public init(
fontSize: CGFloat = 15,
lineHeightMultiple: CGFloat = 1.6,
@ -49,10 +100,15 @@ public struct RDEPUBReaderConfiguration: Equatable {
self.textRenderingEngine = textRenderingEngine
}
/// 使
public static let `default` = RDEPUBReaderConfiguration()
}
// MARK: -
extension RDEPUBReaderConfiguration {
/// RDEPUBPreferences
/// - Returns: Core 使
func makePreferences() -> RDEPUBPreferences {
RDEPUBPreferences(
fontSize: fontSize,

View File

@ -1,6 +1,29 @@
import UIKit
// MARK: -
/// EPUB
/// EPUBUI
/// EPUB URL TextBook
///
/// **** EPUBUI Core Parser Foundation
///
/// ****
/// - EPUB
/// -
/// -
/// - UI
/// -
///
/// **** init(epubURL:) viewDidLoad RDEPUBParser.parse paginatePublication readerView.reloadData restoreLocation
// MARK: -
public final class RDEPUBReaderController: UIViewController {
// MARK: -
///
///
private struct RDEPUBViewportSignature: Equatable {
let width: CGFloat
let height: CGFloat
@ -19,6 +42,7 @@ public final class RDEPUBReaderController: UIViewController {
}
}
///
private enum RDEPUBViewportChangeReason {
case viewLayout
case orientationTransition
@ -27,8 +51,12 @@ public final class RDEPUBReaderController: UIViewController {
private typealias NativeTextSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
// MARK: -
///
public weak var delegate: RDEPUBReaderDelegate?
///
public var configuration: RDEPUBReaderConfiguration {
didSet {
persistReaderSettingsIfNeeded()
@ -54,38 +82,49 @@ public final class RDEPUBReaderController: UIViewController {
}
}
///
public var currentLocation: RDEPUBLocation? {
currentVisibleLocation()
}
/// 1 nil
public var currentPageNumber: Int? {
guard readerView.currentPage >= 0 else { return nil }
return readerView.currentPage + 1
}
/// /
public private(set) var currentSelection: RDEPUBSelection?
///
public var highlights: [RDEPUBHighlight] {
activeHighlights
}
///
public var bookmarks: [RDEPUBBookmark] {
activeBookmarks
}
///
public var tableOfContents: [EPUBTableOfContentsItem] {
publication?.tableOfContents ?? []
}
///
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] {
guard let publication else { return [] }
return flattenedTableOfContentsItems(from: publication.tableOfContents)
}
///
public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? {
resolvedCurrentTableOfContentsItem()
}
// MARK: -
/// EPUB URL
private let epubURL: URL
fileprivate let persistence: RDEPUBReaderPersistence?
private let readerView = RDReaderView()
@ -131,6 +170,14 @@ public final class RDEPUBReaderController: UIViewController {
/// TextBook URL TextBook
private var textFileURL: URL?
// MARK: -
/// 使 EPUB URL
///
/// - Parameters:
/// - epubURL: EPUB URL
/// - configuration: 使 .default
/// - persistence: 使 UserDefaults
public init(
epubURL: URL,
configuration: RDEPUBReaderConfiguration = .default,
@ -149,7 +196,15 @@ public final class RDEPUBReaderController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
/// 使 TextBook EPUB TXT
/// 使 TextBook EPUB
/// TXT TextBook
/// - Parameters:
/// - textBook: TextBook
/// - bookIdentifier:
/// - title:
/// - textFileURL: URL TextBook
/// - configuration:
/// - persistence:
public convenience init(
textBook: RDEPUBTextBook,
bookIdentifier: String,
@ -244,6 +299,9 @@ public final class RDEPUBReaderController: UIViewController {
}
}
// MARK: - API
/// EPUB
public func reloadBook() {
didStartInitialLoad = false
parser = nil
@ -263,11 +321,18 @@ public final class RDEPUBReaderController: UIViewController {
startInitialLoadIfNeeded()
}
///
/// - Parameter location:
public func go(to location: RDEPUBLocation) {
guard publication != nil else { return }
restoreReadingLocation(location)
}
///
/// - Parameters:
/// - pageNumber: 1
/// - animated:
/// - Returns:
@discardableResult
public func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
guard pageNumber > 0 else { return false }
@ -289,6 +354,7 @@ public final class RDEPUBReaderController: UIViewController {
return true
}
///
public func clearSelection() {
updateCurrentSelection(nil)
}
@ -321,6 +387,12 @@ public final class RDEPUBReaderController: UIViewController {
return parts.joined(separator: " · ")
}
///
/// - Parameters:
/// - selection: 使 currentSelection
/// - color: CSS
/// - note:
/// - Returns: nil
@discardableResult
public func addHighlight(
from selection: RDEPUBSelection? = nil,
@ -1285,6 +1357,8 @@ public final class RDEPUBReaderController: UIViewController {
}
}
// MARK: -
extension RDEPUBReaderController {
@discardableResult
public func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
@ -1456,6 +1530,10 @@ extension RDEPUBReaderController {
return trimmed.isEmpty ? nil : trimmed
}
// MARK: -
///
/// - Parameter keyword:
public func search(keyword: String) {
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKeyword.isEmpty else {
@ -1622,6 +1700,8 @@ extension RDEPUBReaderController {
}
}
// MARK: - RDReaderView
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
textBook?.pages.count ?? activePages.count
@ -1724,6 +1804,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
}
}
// MARK: - Web EPUB /Web
extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
guard readerView.currentPage >= 0,
@ -1780,6 +1862,8 @@ extension RDEPUBReaderController: RDEPUBWebContentViewDelegate {
}
}
// MARK: - Native Text
extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) {
guard let selection else {
@ -1902,6 +1986,8 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
}
}
// MARK: - NavigationController
extension RDEPUBReaderController: UIGestureRecognizerDelegate {
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
true

View File

@ -1,20 +1,86 @@
import UIKit
// MARK: -
/// EPUB
///
///
public protocol RDEPUBReaderDelegate: AnyObject {
///
/// - Parameters:
/// - reader:
/// - publication:
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
///
/// - Parameters:
/// - reader:
/// - location:
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
///
/// - Parameter reader:
func epubReaderDidReachEnd(_ reader: UIViewController)
///
/// - Parameters:
/// - reader:
/// - selection: nil
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
///
/// - Parameters:
/// - reader:
/// - highlights:
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
///
/// - Parameters:
/// - reader:
/// - bookmarks:
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
///
/// - Parameters:
/// - reader:
/// - result: nil
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
///
/// - Parameters:
/// - reader:
/// - match: nil
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
///
/// - Parameters:
/// - reader:
/// - item:
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
///
/// - Parameters:
/// - reader:
/// - url: URL
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
///
/// - Parameters:
/// - reader:
/// - error:
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
///
/// viewDidLoad
/// - Parameters:
/// - reader:
/// - topToolView:
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
}
// MARK: -
///
public extension RDEPUBReaderDelegate {
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {}
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {}

View File

@ -1,13 +1,24 @@
import UIKit
// MARK: -
/// EPUB
/// //
final class RDEPUBReaderHighlightsViewController: UITableViewController {
///
var onSelectHighlight: ((RDEPUBHighlight) -> Void)?
///
var onUpdateHighlight: ((RDEPUBHighlight) -> Void)?
///
var onDeleteHighlight: ((RDEPUBHighlight) -> Void)?
///
private var highlights: [RDEPUBHighlight]
///
private let theme: RDEPUBReaderTheme
///
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
///
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
private var filteredHighlights: [RDEPUBHighlight] {
@ -205,8 +216,14 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
}
}
// MARK: -
/// EPUB
///
final class RDEPUBReaderBookmarksViewController: UITableViewController {
///
var onSelectBookmark: ((RDEPUBBookmark) -> Void)?
///
var onDeleteBookmark: ((RDEPUBBookmark) -> Void)?
private var bookmarks: [RDEPUBBookmark]

View File

@ -1,16 +1,44 @@
import Foundation
// MARK: -
///
///
///
public protocol RDEPUBReaderPersistence: AnyObject {
///
/// - Parameter bookIdentifier:
/// - Returns: nil
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
///
/// - Parameters:
/// - location:
/// - bookIdentifier:
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
///
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
///
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
///
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
///
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
///
func loadReaderSettings() -> RDEPUBReaderSettings?
///
func saveReaderSettings(_ settings: RDEPUBReaderSettings)
}
// MARK: -
/// //
public extension RDEPUBReaderPersistence {
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
_ = bookIdentifier
@ -31,13 +59,30 @@ public extension RDEPUBReaderPersistence {
}
}
// MARK: - UserDefaults
/// UserDefaults
/// JSON
/// SQLite
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
/// UserDefaults
private let defaults: UserDefaults
/// key
private let locationPrefix: String
/// key
private let bookmarksPrefix: String
/// key
private let highlightsPrefix: String
/// key
private let settingsKey: String
/// UserDefaults
/// - Parameters:
/// - defaults: UserDefaults .standard
/// - locationPrefix: key
/// - bookmarksPrefix: key
/// - highlightsPrefix: key
/// - settingsKey: key
public init(
defaults: UserDefaults = .standard,
locationPrefix: String = "ssreader.epub.location.",

View File

@ -1,11 +1,20 @@
import UIKit
// MARK: -
///
/// RDReaderView.DisplayType
public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
/// 仿
case pageCurl
///
case horizontalScroll
///
case verticalScroll
///
case horizontalCoverScroll
/// DisplayType UI DisplayMode
init(displayType: RDReaderView.DisplayType) {
switch displayType {
case .pageCurl:
@ -17,6 +26,8 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
}
}
/// RDReaderView DisplayType
/// horizontalCoverScroll horizontalScroll
var displayType: RDReaderView.DisplayType {
switch self {
case .pageCurl:
@ -29,14 +40,25 @@ public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
}
}
// MARK: -
///
/// RDEPUBReaderTheme
public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
///
case light
///
case yellow
/// 绿
case green
///
case pink
///
case blue
/// /
case dark
///
public var theme: RDEPUBReaderTheme {
switch self {
case .light:
@ -54,6 +76,8 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
}
}
///
/// nil
public init?(theme: RDEPUBReaderTheme) {
switch theme {
case .light:
@ -74,13 +98,26 @@ public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
}
}
// MARK: -
///
///
///
public struct RDEPUBReaderSettings: Codable, Equatable {
// MARK:
/// 0.0 ~ 1.0nil 使
public var brightness: CGFloat?
/// ptnil 使
public var fontSize: CGFloat?
/// nil 使
public var lineHeightMultiple: CGFloat?
/// nil 使
public var displayMode: RDEPUBReaderDisplayMode?
/// nil 使
public var themePreset: RDEPUBReaderThemePreset?
/// nil
public init(
brightness: CGFloat? = nil,
fontSize: CGFloat? = nil,
@ -95,6 +132,10 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
self.themePreset = themePreset
}
///
/// nil
/// - Parameter configuration: RDEPUBReaderConfiguration.default
/// - Returns:
public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration {
var resolvedConfiguration = configuration
@ -114,6 +155,12 @@ public struct RDEPUBReaderSettings: Codable, Equatable {
return resolvedConfiguration
}
///
/// 0.0 ~ 1.0
/// - Parameters:
/// - configuration:
/// - brightness:
/// - Returns:
public static func capture(
configuration: RDEPUBReaderConfiguration,
brightness: CGFloat

View File

@ -1,12 +1,27 @@
import UIKit
// MARK: -
/// EPUB
///
/// iOS 13+ SF Symbols 退
final class RDEPUBReaderSettingsViewController: UIViewController {
// MARK:
/// 0.0 ~ 1.0
var onBrightnessChange: ((CGFloat) -> Void)?
/// pt
var onFontSizeChange: ((CGFloat) -> Void)?
///
var onLineHeightChange: ((CGFloat) -> Void)?
///
var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)?
///
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
// MARK:
///
private enum ThemePreset: Int, CaseIterable {
case light
case yellow
@ -60,7 +75,9 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
}()
private var themeButtons: [UIButton] = []
/// 1.3 1.6 1.9
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
///
private var currentConfiguration: RDEPUBReaderConfiguration
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {

View File

@ -1,9 +1,17 @@
import Foundation
// MARK: -
///
/// depth
public struct RDEPUBReaderTableOfContentsItem: Equatable {
///
public var title: String
/// href EPUB
public var href: String
/// 0
public var depth: Int
/// 1 nil
public var pageNumber: Int?
public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {

View File

@ -1,13 +1,37 @@
import UIKit
// MARK: -
/// EPUB
///
/// 绿
public struct RDEPUBReaderTheme: Equatable {
// MARK:
///
public var contentBackgroundColor: UIColor
///
public var contentTextColor: UIColor
// MARK:
///
public var toolBackgroundColor: UIColor
/// /
public var toolControlTextColor: UIColor
///
public var toolControlBorderUnselectColor: UIColor
/// 线
public var toolLineColor: UIColor
///
/// - Parameters:
/// - contentBackgroundColor:
/// - contentTextColor:
/// - toolBackgroundColor:
/// - toolControlTextColor:
/// - toolControlBorderUnselectColor:
/// - toolLineColor: 线
public init(
contentBackgroundColor: UIColor,
contentTextColor: UIColor,
@ -24,6 +48,9 @@ public struct RDEPUBReaderTheme: Equatable {
self.toolLineColor = toolLineColor
}
// MARK:
///
public static let light = RDEPUBReaderTheme(
contentBackgroundColor: .white,
contentTextColor: .black,
@ -33,6 +60,7 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
)
///
public static let dark = RDEPUBReaderTheme(
contentBackgroundColor: .black,
contentTextColor: .white,
@ -42,6 +70,7 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
)
///
public static let yellow = RDEPUBReaderTheme(
contentBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
contentTextColor: .black,
@ -51,6 +80,7 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
)
/// 绿绿
public static let green = RDEPUBReaderTheme(
contentBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
contentTextColor: .black,
@ -60,6 +90,7 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
)
///
public static let pink = RDEPUBReaderTheme(
contentBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
contentTextColor: .black,
@ -69,6 +100,7 @@ public struct RDEPUBReaderTheme: Equatable {
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
)
///
public static let blue = RDEPUBReaderTheme(
contentBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
contentTextColor: .black,
@ -79,11 +111,15 @@ public struct RDEPUBReaderTheme: Equatable {
)
}
// MARK: - CSS
extension RDEPUBReaderTheme {
/// CSS EPUB HTML
var themeBackgroundColorCSS: String {
contentBackgroundColor.ss_cssString
}
/// CSS EPUB HTML
var themeTextColorCSS: String {
contentTextColor.ss_cssString
}

View File

@ -1,7 +1,13 @@
import UIKit
// MARK: -
///
/// 线
open class RDEPUBReaderToolView: UIView {
/// 线
private let lineView = UIView()
/// 线
private let lineHeight: CGFloat = 0.5
override init(frame: CGRect) {
@ -19,15 +25,24 @@ open class RDEPUBReaderToolView: UIView {
lineView.frame = lineFrame(in: bounds)
}
///
/// - Parameter theme:
open func apply(theme: RDEPUBReaderTheme) {
backgroundColor = .white
lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
}
/// 线线
/// - Parameter bounds: bounds
/// - Returns: 线 frame
open func lineFrame(in bounds: CGRect) -> CGRect {
CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight)
}
}
// MARK: -
///
/// UIButton
final class RDEPUBReaderTintButton: UIButton {
}

View File

@ -1,7 +1,14 @@
import UIKit
// MARK: -
///
///
/// iOS 13+ SF Symbols 退
public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
///
var onBack: (() -> Void)?
///
var onToggleBookmark: (() -> Void)?
private let backButton = RDEPUBReaderTintButton(type: .system)

View File

@ -1,21 +1,41 @@
import UIKit
// MARK: -
///
///
struct RDEPUBTextOverlayDecoration {
///
enum Kind: String {
///
case selection
///
case highlight
/// 线
case underline
///
case search
///
case activeSearch
///
case locate
}
///
var kind: Kind
///
var absoluteRange: NSRange
///
var rects: [CGRect]
///
var color: UIColor
}
// MARK: -
///
///
/// 使 Core Graphics 线
final class RDEPUBSelectionOverlayView: UIView {
private(set) var page: RDEPUBTextPage?
private var snapshot: RDEPUBPageLayoutSnapshot?

View File

@ -5,12 +5,22 @@ import Foundation
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 {
@ -37,6 +47,10 @@ final class RDEPUBSelectableTextView: UITextView {
}
}
// MARK: - DTCoreText
/// DTCoreText Core Text
/// DTCoreText UIView UITextView
#if canImport(DTCoreText)
final class RDEPUBDirectCoreTextPageView: UIView {
var layoutFrame: DTCoreTextLayoutFrame? {
@ -73,6 +87,14 @@ final class RDEPUBDirectCoreTextPageView: UIView {
}
#endif
// MARK: -
/// EPUB
///
/// 1. DTCoreText CoreText
/// 2. 退 UITextView attributedText
///
///
final class RDEPUBTextContentView: UIView {
private var contentInsets: UIEdgeInsets = .zero
private var currentPage: RDEPUBTextPage?

View File

@ -1,14 +1,29 @@
import UIKit
// MARK: - Web
/// Web
/// WebView
protocol RDEPUBWebContentViewDelegate: AnyObject {
/// WebView
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
/// WebView
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
///
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
///
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
///
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
/// WebView JavaScript
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
}
// MARK: - Web
/// EPUB Web
/// RDEPUBWebView/
/// EPUB Native Text
final class RDEPUBWebContentView: UIView {
weak var delegate: RDEPUBWebContentViewDelegate?

View File

@ -1,10 +1,41 @@
//
// RDURLReaderController.swift
// RDReaderDemo
//
// URL
// ReadViewSDK 使
// - .epub RDEPUBReaderController
// - RDPlainTextBookBuilder RDEPUBReaderController
// - 退 UITextView
//
// RDReaderView UI
//
import UIKit
/// URL
/// URL
/// - EPUB 使 RDEPUBReaderController
/// - RDPlainTextBookBuilder 使 RDEPUBReaderController
/// - 退 UITextView
///
/// 使
/// ```swift
/// let controller = RDURLReaderController(bookURL: fileURL)
/// navigationController?.pushViewController(controller, animated: true)
/// ```
public final class RDURLReaderController: UIViewController {
/// URL
private let bookURL: URL
/// EPUB
private let epubConfiguration: RDEPUBReaderConfiguration
///
private var embeddedController: UIViewController?
///
/// - Parameters:
/// - bookURL: URL
/// - epubConfiguration: EPUB 使
public init(
bookURL: URL,
epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration()
@ -19,6 +50,7 @@ public final class RDURLReaderController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
///
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
@ -26,11 +58,18 @@ public final class RDURLReaderController: UIViewController {
embedReaderController()
}
/// Demo
/// - Parameter displayType:
public func applyDemoDisplayType(_ displayType: RDReaderView.DisplayType) {
readerController?.configuration.displayType = displayType
logDemoState(prefix: "display=\(displayType.demoArgumentValue)")
}
/// Demo
/// - Parameters:
/// - pageNumber:
/// - animated:
/// - Returns:
@discardableResult
public func goToDemoPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
let moved = readerController?.go(toPageNumber: pageNumber, animated: animated) ?? false
@ -40,6 +79,12 @@ public final class RDURLReaderController: UIViewController {
return moved
}
/// Demo
///
/// - Parameters:
/// - displayTypes:
/// - initialPageNumber:
/// - stepDelay:
public func runDemoDisplaySequence(
_ displayTypes: [RDReaderView.DisplayType],
initialPageNumber: Int? = nil,
@ -60,6 +105,8 @@ public final class RDURLReaderController: UIViewController {
}
}
///
/// Child View Controller
private func embedReaderController() {
let controller: UIViewController
if bookURL.pathExtension.lowercased() == "epub" {
@ -104,10 +151,12 @@ public final class RDURLReaderController: UIViewController {
controller.didMove(toParent: self)
}
/// EPUB
private var readerController: RDEPUBReaderController? {
embeddedController as? RDEPUBReaderController
}
/// Demo
private func logDemoState(prefix: String) {
guard let readerController else { return }
let location = readerController.currentLocation
@ -117,6 +166,8 @@ public final class RDURLReaderController: UIViewController {
print("[ReadViewDemo] automation \(prefix) -> page \(page) href \(href) progression \(progression)")
}
///
///
private func currentTextPageSize() -> CGSize {
let viewportSize = UIScreen.main.bounds.size
let insets = epubConfiguration.reflowableContentInsets
@ -126,6 +177,7 @@ public final class RDURLReaderController: UIViewController {
)
}
///
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
let font = UIFont.systemFont(ofSize: epubConfiguration.fontSize)
let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4)
@ -137,6 +189,8 @@ public final class RDURLReaderController: UIViewController {
)
}
///
/// UTF-8 GBK GB2312
private func rd_decodeTextFile(url: URL) -> String {
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
return content
@ -151,7 +205,9 @@ public final class RDURLReaderController: UIViewController {
}
}
/// Demo
private extension RDReaderView.DisplayType {
/// Demo
var demoArgumentValue: String {
switch self {
case .pageCurl:

View File

@ -1,304 +0,0 @@
import UIKit
struct EPUBTOCDisplayItem {
var title: String
var href: String
var depth: Int
var pageNumber: Int?
}
struct LegacyRDEPUBTextPage {
var absolutePageIndex: Int
var chapterIndex: Int
var spineIndex: Int
var href: String
var chapterTitle: String
var pageIndexInChapter: Int
var totalPagesInChapter: Int
var content: NSAttributedString
var contentRange: NSRange
var pageStartOffset: Int
var pageEndOffset: Int
}
struct LegacyRDEPUBTextChapter {
var chapterIndex: Int
var spineIndex: Int
var href: String
var title: String
var attributedContent: NSAttributedString
var fragmentOffsets: [String: Int]
var pages: [LegacyRDEPUBTextPage]
}
struct LegacyRDEPUBTextBook {
var chapters: [LegacyRDEPUBTextChapter]
var pages: [LegacyRDEPUBTextPage]
func page(at pageNumber: Int) -> LegacyRDEPUBTextPage? {
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
return nil
}
return pages[pageNumber - 1]
}
func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else {
return nil
}
let targetOffset: Int
if let fragment = normalizedLocation.fragment, let fragmentOffset = chapter.fragmentOffsets[fragment] {
targetOffset = fragmentOffset
} else {
let lastOffset = max(chapter.attributedContent.length - 1, 0)
targetOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * normalizedLocation.navigationProgression))))
}
if let page = chapter.pages.first(where: { targetOffset >= $0.pageStartOffset && targetOffset <= $0.pageEndOffset }) {
return page.absolutePageIndex + 1
}
return chapter.pages.last.map { $0.absolutePageIndex + 1 }
}
func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
guard let page = page(at: pageNumber),
let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else {
return nil
}
let totalLength = max(chapter.attributedContent.length - 1, 1)
return RDEPUBLocation(
bookIdentifier: bookIdentifier,
href: page.href,
progression: Double(page.pageStartOffset) / Double(totalLength),
lastProgression: Double(page.pageEndOffset) / Double(totalLength),
fragment: nil
)
}
}
final class LegacyRDEPUBTextBookBuilder {
static func build(
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
font: UIFont,
lineSpacing: CGFloat
) -> LegacyRDEPUBTextBook {
var chapters: [LegacyRDEPUBTextChapter] = []
var flatPages: [LegacyRDEPUBTextPage] = []
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 normalizedHTML = normalizeHTML(rawHTML)
let markedHTML = injectFragmentMarkers(into: normalizedHTML)
guard let attributedContent = attributedHTML(
html: markedHTML,
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
font: font,
lineSpacing: lineSpacing
) else {
continue
}
let mutableContent = NSMutableAttributedString(attributedString: attributedContent)
let fragmentOffsets = extractFragmentOffsets(from: mutableContent)
normalizeReadingAttributes(in: mutableContent, font: font, lineSpacing: lineSpacing)
let plainText = mutableContent.string.trimmingCharacters(in: .whitespacesAndNewlines)
if shouldSkipChapter(item: item, text: plainText) {
continue
}
let chapterIndex = chapters.count
let pageRanges = mutableContent.length > 0 ? mutableContent.pageRanges(size: pageSize) : []
let effectivePageRanges = pageRanges.isEmpty && mutableContent.length > 0
? [NSRange(location: 0, length: mutableContent.length)]
: pageRanges
let pages = effectivePageRanges.enumerated().map { localPageIndex, range in
LegacyRDEPUBTextPage(
absolutePageIndex: flatPages.count + localPageIndex,
chapterIndex: chapterIndex,
spineIndex: spineIndex,
href: item.href,
chapterTitle: chapterTitle,
pageIndexInChapter: localPageIndex,
totalPagesInChapter: effectivePageRanges.count,
content: mutableContent.attributedSubstring(from: range),
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + max(range.length - 1, 0)
)
}
let chapter = LegacyRDEPUBTextChapter(
chapterIndex: chapterIndex,
spineIndex: spineIndex,
href: item.href,
title: chapterTitle,
attributedContent: mutableContent.copy() as! NSAttributedString,
fragmentOffsets: fragmentOffsets,
pages: pages
)
chapters.append(chapter)
flatPages.append(contentsOf: pages)
}
return LegacyRDEPUBTextBook(chapters: chapters, pages: flatPages)
}
private static 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 static func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
items.flatMap { item in
[item] + flattenedTOCItems(from: item.children)
}
}
private static func shouldSkipChapter(item: RDEPUBSpineItem, text: String) -> Bool {
let lowercasedHref = item.href.lowercased()
if text.isEmpty && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
return true
}
return false
}
private 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
)
}
}
return cleanedHTML
}
private 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"
)
}
private static func attributedHTML(
html: String,
baseURL: URL?,
font: UIFont,
lineSpacing: CGFloat
) -> NSAttributedString? {
guard let data = html.data(using: .utf8) else {
return nil
}
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue,
NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption"): baseURL as Any
]
if let attributed = try? NSMutableAttributedString(data: data, options: options, documentAttributes: nil) {
return attributed
}
let fallbackAttributes: [NSAttributedString.Key: Any] = [
.font: font,
.paragraphStyle: paragraphStyle(lineSpacing: lineSpacing)
]
return NSAttributedString(string: html, attributes: fallbackAttributes)
}
private 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
}
private static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, font: UIFont, lineSpacing: CGFloat) {
let fullRange = NSRange(location: 0, length: attributedString.length)
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
let sourceFont = attributes[.font] as? UIFont
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: lineSpacing)
paragraph.lineSpacing = lineSpacing
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, lineSpacing / 2)
var updatedAttributes = attributes
updatedAttributes[.font] = normalizedFont(from: sourceFont, baseFont: font)
updatedAttributes[.paragraphStyle] = paragraph
attributedString.setAttributes(updatedAttributes, range: range)
}
}
private 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
}
private static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle {
let style = NSMutableParagraphStyle()
style.lineSpacing = lineSpacing
style.paragraphSpacing = max(6, lineSpacing / 2)
return style
}
}

View File

@ -1,103 +0,0 @@
//
// RDReaderBottomToolView.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/10.
//
import UIKit
import SnapKit
public class RDReaderBottomToolView: RDReaderToolView, SSEventTrigger {
enum Event {
case chapterList, highlight, settings, darkAndLight
}
private lazy var containerView: UIStackView = {
let stackView = UIStackView()
stackView.distribution = .fillEqually
stackView.axis = .horizontal
stackView.spacing = 20
return stackView
}()
lazy var chapterListButton: TintColorButton = {
let button = TintColorButton(type: .system)
button.setImage(toolbarImage(named: "read_edit_chapterlist", fallbackSystemName: "list.bullet"), for: .normal)
addSubview(button)
button.addTarget(self, action: #selector(chapterListAction), for: .touchUpInside)
return button
}()
lazy var settingsButton: TintColorButton = {
let button = TintColorButton(type: .system)
button.setImage(toolbarImage(named: "read_edit_font", fallbackSystemName: "textformat"), for: .normal)
addSubview(button)
button.addTarget(self, action: #selector(settingsAction), for: .touchUpInside)
return button
}()
lazy var highlightButton: TintColorButton = {
let button = TintColorButton(type: .system)
button.setImage(UIImage(systemName: "highlighter"), for: .normal)
addSubview(button)
button.addTarget(self, action: #selector(highlightAction), for: .touchUpInside)
return button
}()
lazy var lightModeButton: TintColorButton = {
let button = TintColorButton(type: .system)
button.setImage(toolbarImage(named: "read_edit_night", fallbackSystemName: "moon.fill"), for: .normal)
addSubview(button)
button.addTarget(self, action: #selector(lightAndDarkModeAction), for: .touchUpInside)
return button
}()
override func makeUI() {
super.makeUI()
addSubview(containerView)
containerView.addArrangedSubview(chapterListButton)
containerView.addArrangedSubview(highlightButton)
containerView.addArrangedSubview(settingsButton)
containerView.addArrangedSubview(lightModeButton)
containerView.snp.makeConstraints { make in
make.left.equalTo(16)
make.top.equalTo(0)
make.right.equalTo(-16)
make.bottom.equalTo(-RDReaderCommon.safeAreaInsets.bottom)
}
}
@objc private func chapterListAction() {
if let trigger = self.triggerEvent {
trigger(.chapterList)
}
}
@objc private func settingsAction() {
if let trigger = self.triggerEvent {
trigger(.settings)
}
}
@objc private func highlightAction() {
if let trigger = self.triggerEvent {
trigger(.highlight)
}
}
@objc private func lightAndDarkModeAction() {
if let trigger = self.triggerEvent {
trigger(.darkAndLight)
}
}
private func toolbarImage(named: String, fallbackSystemName: String) -> UIImage? {
if let image = UIImage(named: named) {
return image.withRenderingMode(.alwaysOriginal)
}
return UIImage(systemName: fallbackSystemName)
}
}

View File

@ -1,198 +0,0 @@
//
// RDReaderCommon.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/11.
//
import Foundation
import UIKit
import CoreText
struct RDReaderCommon {
static var safeAreaInsets: UIEdgeInsets = {
guard #available(iOS 11.0, *) else {
return .zero
}
return UIApplication.shared.windows[0].safeAreaInsets
}()
}
extension RDReaderCommon {
enum SpaceType: Int, RDReaderObserverType {
case min = 0
case meduim
case max
var mulitiple: CGFloat {
switch self {
case .min: return 1.6
case .meduim: return 2.0
case .max: return 3.0
}
}
var image: UIImage? {
switch self {
case .min:
return UIImage.rdToolbarImage(named: "read_edit_textSpace_3", fallbackSystemName: "text.justify.leading")
case .meduim:
return UIImage.rdToolbarImage(named: "read_edit_textSpace_2", fallbackSystemName: "text.justify")
case .max:
return UIImage.rdToolbarImage(named: "read_edit_textSpace_1", fallbackSystemName: "text.justify.right")
}
}
var cachesValue: Int {
return rawValue
}
var value: CGFloat {
return mulitiple
}
func transform(value: Any?) -> RDReaderCommon.SpaceType {
guard let value = value as? Int else {
return self
}
return RDReaderCommon.SpaceType(rawValue: value) ?? self
}
}
}
extension RDReaderCommon {
enum PageCurlType: Int, RDReaderObserverType {
case pageCurl
case horizontalScroll
case verticalScroll
case horizontalCoverScroll
var title: String {
switch self {
case .pageCurl:
return "仿真"
case .horizontalScroll:
return "左右滑动"
case .verticalScroll:
return "上下滚动"
case .horizontalCoverScroll:
return "左右覆盖"
}
}
var cachesValue: Int {
return rawValue
}
var value: RDReaderView.DisplayType {
switch self {
case .pageCurl:
return RDReaderView.DisplayType.pageCurl
case .horizontalScroll:
return RDReaderView.DisplayType.horizontalScroll
case .verticalScroll:
return RDReaderView.DisplayType.verticalScroll
case .horizontalCoverScroll:
return RDReaderView.DisplayType.horizontalScroll
}
}
func transform(value: Any?) -> RDReaderCommon.PageCurlType {
guard let value = value as? Int else {
return self
}
return RDReaderCommon.PageCurlType(rawValue: value) ?? self
}
}
}
extension RDReaderCommon {
enum Colors: Int, RDReaderObserverType{
case white, dark, pink, yellow, green, blue
var value: RDReaderTheme {
switch self {
case .white:
return whiteTheme
case .dark:
return darkTheme
case .pink:
return pinkTheme
case .yellow:
return yellowTheme
case .green:
return greenTheme
case .blue:
return blueTheme
}
}
var cachesValue: Int {
rawValue
}
var color: UIColor {
switch self {
case .white:
return UIColor(red: 0.98, green: 0.98, blue: 0.99, alpha: 1)
case .yellow:
return UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1)
case .green:
return UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1)
case .pink:
return UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1)
case .blue:
return UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1)
case .dark:
return UIColor(red: 0.09, green: 0.09, blue: 0.09, alpha: 1)
}
}
func transform(value: Any?) -> RDReaderCommon.Colors {
guard let value = value as? Int else {
return self
}
return RDReaderCommon.Colors(rawValue: value) ?? self
}
}
static let whiteTheme = WhiteTheme()
static let darkTheme = DarkTheme()
static let pinkTheme = PinkTheme()
static let yellowTheme = YellowTheme()
static let greenTheme = GreenTheme()
static let blueTheme = BlueTheme()
}
extension NSAttributedString {
func pageRanges(size: CGSize) -> [NSRange] {
var ranges = [NSRange]()
let framesetter = CTFramesetterCreateWithAttributedString(self)
let path = CGPath(rect: CGRect(origin: .zero, size: size), transform: nil)
var range = CFRangeMake(0, 0)
var loc = 0
while range.location + range.length < length {
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(loc, 0), path, nil)
range = CTFrameGetVisibleStringRange(frame)
ranges.append(NSMakeRange(loc, range.length))
loc += range.length
}
return ranges
}
}
extension String {
static func encodeTextFile(url: URL?) -> String {
var content: String? = nil
guard let url = url else {
return content ?? ""
}
content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String
if content == nil {
content = try? NSString(contentsOf: url, encoding: 0x80000632) as String
}
if content == nil {
content = try? NSString(contentsOf: url, encoding: 0x80000631) as String
}
return content ?? ""
}
}

View File

@ -1,102 +0,0 @@
//
// RDReaderContentView.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/11.
//
import UIKit
import SnapKit
class RDReaderContentView: UIView {
lazy var textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
return label
}()
lazy var pageNumLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 13)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(textLabel)
addSubview(pageNumLabel)
textLabel.text = """
10
"""
textLabel.snp.makeConstraints { make in
make.edges.equalTo(UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16))
}
pageNumLabel.snp.makeConstraints { make in
make.bottom.equalTo(-20)
make.right.equalTo(-30)
}
}
override func layoutSubviews() {
super.layoutSubviews()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
final class RDReaderEPUBTextContentView: UIView {
private(set) lazy var textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
return label
}()
private(set) lazy var pageNumLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 13)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(textLabel)
addSubview(pageNumLabel)
textLabel.snp.makeConstraints { make in
make.edges.equalTo(UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16))
}
pageNumLabel.snp.makeConstraints { make in
make.bottom.equalTo(-20)
make.right.equalTo(-30)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(page: LegacyRDEPUBTextPage, pageNumber: Int, totalPages: Int, theme: RDReaderTheme) {
backgroundColor = theme.contentBackgroudColor
pageNumLabel.textColor = theme.contentTextColor
pageNumLabel.text = "\(pageNumber) / \(totalPages)"
let displayContent = NSMutableAttributedString(attributedString: page.content)
let fullRange = NSRange(location: 0, length: displayContent.length)
displayContent.addAttribute(.foregroundColor, value: theme.contentTextColor ?? UIColor.black, range: fullRange)
textLabel.attributedText = displayContent
}
}

View File

@ -1,93 +0,0 @@
import UIKit
extension RDReaderController {
func currentEPUBTextPageSize() -> CGSize {
let pageWidth: CGFloat
if readerView.pagesPerScreen > 1 {
pageWidth = view.frame.width / CGFloat(readerView.pagesPerScreen) - 16 * 2
} else {
pageWidth = view.frame.width - 16 * 2
}
return CGSize(width: max(pageWidth, 1), height: max(view.frame.height - 40 * 2, 1))
}
func loadEPUBTextBook(
parser: RDEPUBParser,
publication: RDEPUBPublication,
restoreLocation: RDEPUBLocation?
) {
let pageSize = currentEPUBTextPageSize()
let font = UIFont.systemFont(ofSize: RDReaderManager.shared.fontValue.currentType)
let lineSpacing = RDReaderManager.shared.lineSpaceType.currentType.mulitiple * 15
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
let textBook = LegacyRDEPUBTextBookBuilder.build(
parser: parser,
publication: publication,
pageSize: pageSize,
font: font,
lineSpacing: lineSpacing
)
DispatchQueue.main.async {
self?.applyEPUBTextBook(textBook, publication: publication, restoreLocation: restoreLocation)
}
}
}
func applyEPUBTextBook(
_ textBook: LegacyRDEPUBTextBook,
publication: RDEPUBPublication,
restoreLocation: RDEPUBLocation?
) {
epubTextBook = textBook
epubTOCItems = flattenedTOCItems(from: publication.tableOfContents, textBook: textBook)
print("[EPUB] Text book ready chapters=\(textBook.chapters.count) pages=\(textBook.pages.count) toc=\(epubTOCItems.count)")
let resolvedRestorePage = restoreLocation.flatMap {
textBook.pageNumber(
for: $0,
resolver: epubResolver ?? publicationFallbackResolver(),
bookIdentifier: currentEPUBBookIdentifier
)
}
storeEPUBPaginationValidation(
mode: "textReflowable",
stage: "final",
pageCount: textBook.pages.count,
chapterCount: textBook.chapters.count,
tocCount: epubTOCItems.count,
restoreLocation: restoreLocation,
resolvedRestorePage: resolvedRestorePage
)
isReaderContentReady = true
hideLoading()
readerView.reloadData()
restoreEPUBLocation(restoreLocation)
if restoreLocation == nil {
epubSession?.transition(to: .idle)
}
}
func flattenedTOCItems(
from items: [EPUBTableOfContentsItem],
depth: Int = 0,
textBook: LegacyRDEPUBTextBook? = nil
) -> [EPUBTOCDisplayItem] {
var result: [EPUBTOCDisplayItem] = []
for item in items {
let pageNumber = textBook.flatMap { book in
book.pageNumber(
for: RDEPUBLocation(bookIdentifier: currentEPUBBookIdentifier, href: item.href, progression: 0),
resolver: epubResolver ?? publicationFallbackResolver(),
bookIdentifier: currentEPUBBookIdentifier
)
}
result.append(EPUBTOCDisplayItem(title: item.title, href: item.href, depth: depth, pageNumber: pageNumber))
result.append(contentsOf: flattenedTOCItems(from: item.children, depth: depth + 1, textBook: textBook))
}
return result
}
func publicationFallbackResolver() -> RDEPUBResourceResolver {
epubPublication?.resourceResolver ?? RDEPUBResourceResolver(parser: epubParser ?? RDEPUBParser())
}
}

View File

@ -1,47 +0,0 @@
//
// RDReaderCoverView.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/11.
//
import UIKit
import SnapKit
class RDReaderCoverView: UIView {
lazy var textLabel: UILabel = {
let textLabel = UILabel()
textLabel.numberOfLines = 0
return textLabel
}()
lazy var imageV: UIImageView = {
let imageV = UIImageView()
imageV.image = UIImage(named: "cover")
return imageV
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(imageV)
addSubview(textLabel)
imageV.snp.makeConstraints { make in
make.top.equalTo(60)
make.centerX.equalToSuperview()
make.size.equalTo(CGSize(width: 88 * 1.5, height: 120 * 1.5))
}
textLabel.snp.makeConstraints { make in
make.left.equalTo(16)
make.right.equalTo(-16)
make.top.equalTo(imageV.snp.bottom).offset(20)
make.bottom.equalTo(-30)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -1,127 +0,0 @@
import SnapKit
import UIKit
protocol RDReaderEPUBContentViewDelegate: AnyObject {
func epubContentView(_ contentView: RDReaderEPUBContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
func epubContentView(_ contentView: RDReaderEPUBContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
func epubContentView(_ contentView: RDReaderEPUBContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
func epubContentView(_ contentView: RDReaderEPUBContentView, didActivateExternalLink url: URL)
func epubContentView(_ contentView: RDReaderEPUBContentView, didLogJavaScriptError message: String)
}
final class RDReaderEPUBContentView: UIView {
weak var delegate: RDReaderEPUBContentViewDelegate?
lazy var pageNumLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 13)
return label
}()
private let epubWebView = RDEPUBWebView()
override init(frame: CGRect) {
super.init(frame: frame)
clipsToBounds = true
layer.masksToBounds = true
addSubview(epubWebView)
addSubview(pageNumLabel)
epubWebView.delegate = self
epubWebView.clipsToBounds = true
epubWebView.layer.masksToBounds = true
epubWebView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
pageNumLabel.snp.makeConstraints { make in
make.right.equalToSuperview().offset(-30)
make.bottom.equalToSuperview().offset(-20)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) {
epubWebView.load(publication: publication, request: request)
}
func loadPage(
parser: RDEPUBParser,
spineIndex: Int,
pageIndex: Int,
totalPagesInChapter: Int,
viewportSize: CGSize,
padding: UIEdgeInsets,
fontSize: CGFloat,
lineHeightMultiple: CGFloat,
themeBackgroundColor: String?,
themeTextColor: String?,
targetLocation: RDEPUBLocation?,
highlights: [RDEPUBHighlight]
) {
epubWebView.loadPage(
parser: parser,
spineIndex: spineIndex,
pageIndex: pageIndex,
totalPagesInChapter: totalPagesInChapter,
viewportSize: viewportSize,
padding: padding,
fontSize: fontSize,
lineHeightMultiple: lineHeightMultiple,
themeBackgroundColor: themeBackgroundColor,
themeTextColor: themeTextColor,
targetLocation: targetLocation,
highlights: highlights
)
}
func loadFixedSpread(
parser: RDEPUBParser,
spread: EPUBFixedSpread,
viewportSize: CGSize,
contentInset: UIEdgeInsets,
backgroundColor: UIColor?
) {
epubWebView.loadFixedSpread(
parser: parser,
spread: spread,
viewportSize: viewportSize,
contentInset: contentInset,
backgroundColor: backgroundColor
)
}
func releaseResources() {
epubWebView.reset()
epubWebView.delegate = self
}
}
extension RDReaderEPUBContentView: RDEPUBWebViewDelegate {
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
delegate?.epubContentView(self, didUpdateLocation: location, spineIndex: spineIndex)
}
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
delegate?.epubContentView(self, didChangeSelection: selection, spineIndex: spineIndex)
}
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
delegate?.epubContentView(self, didActivateInternalLink: location, fromSpineIndex: fromSpineIndex)
}
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) {
delegate?.epubContentView(self, didActivateExternalLink: url)
}
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) {
delegate?.epubContentView(self, didLogJavaScriptError: message)
}
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {
}
}

View File

@ -1,499 +0,0 @@
//
// RDReaderEditView.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/19.
//
import UIKit
import SnapKit
///
class RDReaderBrightSlider: UISlider, SSEventTrigger {
enum Event {
case valueDidChange(value: CGFloat)
}
override init(frame: CGRect) {
super.init(frame: frame)
makeUI()
}
override func setValue(_ value: Float, animated: Bool) {
super.setValue(value, animated: animated)
UIScreen.main.brightness = CGFloat(value)
if let trigger = self.triggerEvent {
trigger(.valueDidChange(value: CGFloat(value)))
}
}
func makeUI() {
let color = UIColor(red: 0.97, green: 0.97, blue: 0.97, alpha: 1)
thumbTintColor = color
let image = UIImage.rdToolbarImage(named: "read_edit_slide", fallbackSystemName: "circle.fill")
setThumbImage(image, for: .normal)
setThumbImage(image, for: .highlighted)
minimumValueImage = UIImage.rdToolbarImage(named: "read_edit_bright_min", fallbackSystemName: "sun.min.fill")
maximumValueImage = UIImage.rdToolbarImage(named: "read_edit_bright_max", fallbackSystemName: "sun.max.fill")
}
override func trackRect(forBounds bounds: CGRect) -> CGRect {
self.layer.cornerRadius = 2.5 / 2
return CGRect(x: 30, y: (bounds.height - 2.5)/2, width: bounds.width - 30 * 2, height: 2.5)
}
override func thumbRect(forBounds bounds: CGRect, trackRect rect: CGRect, value: Float) -> CGRect {
// print(value)
return CGRect(x: 30 + (bounds.width - 30 * 2 - 16) * CGFloat(value), y: (bounds.height - 16)/2, width: 16, height: 16)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
class RDReaderFontView: UIView, SSEventTrigger {
enum Event {
case fontSize(size: CGFloat)
}
var maxFontSize: CGFloat = 30
var minFontSize: CGFloat = 15
var currentFontSize: CGFloat = 15 {
didSet {
fontLabel.text = "\(Int(currentFontSize))"
if currentFontSize != oldValue {
if let trigger = self.triggerEvent {
trigger(.fontSize(size: currentFontSize))
}
}
}
}
lazy var reduceButton: UIButton = {
let button = UIButton(type: .system)
button.setImage(UIImage.rdToolbarImage(named: "read_edit_font_reduce", fallbackSystemName: "textformat.size.smaller"), for: .normal)
button.layer.cornerRadius = 37 / 2
button.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2)
return button
}()
lazy var increaseButton: UIButton = {
let button = UIButton(type: .system)
button.setImage(UIImage.rdToolbarImage(named: "read_edit_font_increase", fallbackSystemName: "textformat.size.larger"), for: .normal)
button.layer.cornerRadius = 37 / 2
button.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2)
return button
}()
lazy var fontLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 17)
label.textAlignment = .center
return label
}()
private lazy var containerView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.distribution = .fill
stackView.spacing = 0
return stackView
}()
override init(frame: CGRect) {
super.init(frame: frame)
makeUI()
}
func makeUI() {
addSubview(containerView)
containerView.addArrangedSubview(reduceButton)
containerView.addArrangedSubview(fontLabel)
containerView.addArrangedSubview(increaseButton)
containerView.snp.makeConstraints { make in
make.edges.equalTo(0)
}
reduceButton.snp.makeConstraints { make in
make.width.equalTo(increaseButton.snp.width).priority(.high)
make.height.equalTo(37)
}
increaseButton.snp.makeConstraints { make in
make.height.equalTo(37)
}
fontLabel.snp.makeConstraints { make in
make.width.equalTo(50)
}
reduceButton.addTarget(self, action: #selector(reduceAction(button:)), for: .touchUpInside)
increaseButton.addTarget(self, action: #selector(increaseAction(button:)), for: .touchUpInside)
fontLabel.text = "\(Int(currentFontSize))"
}
@objc func reduceAction(button: UIButton) {
if currentFontSize == minFontSize {
return
}
currentFontSize -= 1
}
@objc func increaseAction(button: UIButton) {
if currentFontSize == maxFontSize {
return
}
currentFontSize += 1
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
class RDReaderLineSpaceView: UIView, SSEventTrigger {
enum Event {
case chose(type: RDReaderCommon.SpaceType)
}
private lazy var containerView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.distribution = .fillEqually
stackView.spacing = 10
return stackView
}()
private var buttons = [UIButton]()
let allTypes: [RDReaderCommon.SpaceType] = [.min, .meduim, .max]
var currentType: RDReaderCommon.SpaceType = .min {
didSet {
let index = allTypes.firstIndex(of: currentType)
let button = buttons[index!]
button.sendActions(for: .touchUpInside)
}
}
override init(frame: CGRect) {
super.init(frame: frame)
makeUI()
}
func makeUI() {
addSubview(containerView)
containerView.snp.makeConstraints { make in
make.edges.equalTo(0)
}
allTypes.forEach { type in
let button = UIButton(type: .system)
button.layer.cornerRadius = 37 / 2
button.layer.borderWidth = 1
button.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
button.setImage(type.image?.withRenderingMode(.alwaysOriginal), for: .normal)
containerView.addArrangedSubview(button)
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
button.snp.makeConstraints { make in
make.height.equalTo(37)
}
buttons.append(button)
if type == .min {
button.sendActions(for: .touchUpInside)
}
}
}
@objc func buttonAction(button: UIButton) {
for btn in buttons {
if btn == button {
btn.layer.borderColor = UIColor.black.cgColor
let index = buttons.firstIndex(of: btn)
let type = allTypes[index!]
if let trigger = self.triggerEvent {
trigger(.chose(type: type))
}
} else {
btn.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
class RDReaderPageCurlTypeView: UIView, SSEventTrigger {
enum Event {
case chose(type: RDReaderCommon.PageCurlType)
}
private lazy var scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
return scrollView
}()
private lazy var containerView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.distribution = .equalSpacing
stackView.spacing = 10
return stackView
}()
var currentType: RDReaderCommon.PageCurlType = .pageCurl {
didSet {
let index = allTypes.firstIndex(of: currentType)
let button = buttons[index!]
button.sendActions(for: .touchUpInside)
}
}
private var buttons = [UIButton]()
let allTypes: [RDReaderCommon.PageCurlType] = [.pageCurl, .horizontalScroll, .verticalScroll, .horizontalCoverScroll]
override init(frame: CGRect) {
super.init(frame: frame)
makeUI()
}
func makeUI() {
addSubview(scrollView)
scrollView.addSubview(containerView)
scrollView.snp.makeConstraints { make in
make.edges.equalTo(0)
}
containerView.snp.makeConstraints { make in
make.edges.equalTo(0)
}
allTypes.forEach { type in
let button = UIButton(type: .system)
button.layer.cornerRadius = 37 / 2
button.layer.borderWidth = 1
button.titleLabel?.font = UIFont.systemFont(ofSize: 17)
button.setTitleColor(UIColor.black, for: .normal)
button.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
button.setTitle(type.title, for: .normal)
let size = button.titleLabel!.sizeThatFits(CGSize(width: CGFloat(MAXFLOAT), height: 37))
containerView.addArrangedSubview(button)
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
button.snp.makeConstraints { make in
make.height.equalTo(37)
make.width.equalTo(size.width + 30)
}
buttons.append(button)
if type == .pageCurl {
button.sendActions(for: .touchUpInside)
}
}
}
@objc func buttonAction(button: UIButton) {
for btn in buttons {
if btn == button {
btn.layer.borderColor = UIColor.black.cgColor
let index = buttons.firstIndex(of: btn)
let type = allTypes[index!]
if let trigger = self.triggerEvent {
trigger(.chose(type: type))
}
} else {
btn.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
class RDReaderThemeColorsView: UIView, SSEventTrigger {
enum Event {
case chose(color: RDReaderCommon.Colors)
}
private lazy var scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
return scrollView
}()
private lazy var containerView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.distribution = .equalSpacing
stackView.spacing = 30
return stackView
}()
var currentType: RDReaderCommon.Colors = .white {
didSet {
let index = allTypes.firstIndex(of: currentType)
let button = buttons[index!]
button.sendActions(for: .touchUpInside)
}
}
private var buttons = [UIButton]()
let allTypes: [RDReaderCommon.Colors] = [.white, .yellow, .green, .pink, .blue, .dark]
override init(frame: CGRect) {
super.init(frame: frame)
makeUI()
}
func makeUI() {
addSubview(scrollView)
scrollView.addSubview(containerView)
scrollView.snp.makeConstraints { make in
make.edges.equalTo(0)
}
containerView.snp.makeConstraints { make in
make.edges.equalTo(0)
}
allTypes.forEach { type in
let button = UIButton(type: .system)
button.layer.cornerRadius = 37 / 2
button.titleLabel?.font = UIFont.systemFont(ofSize: 17)
button.layer.borderColor = UIColor.black.cgColor
button.backgroundColor = type.color
containerView.addArrangedSubview(button)
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
button.snp.makeConstraints { make in
make.height.width.equalTo(37)
}
buttons.append(button)
if type == .white {
button.sendActions(for: .touchUpInside)
}
}
}
@objc func buttonAction(button: UIButton) {
for btn in buttons {
if btn == button {
btn.layer.borderWidth = 1
let index = buttons.firstIndex(of: btn)
let type = allTypes[index!]
if let trigger = self.triggerEvent {
trigger(.chose(color: type))
}
} else {
btn.layer.borderWidth = 0
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
let space = (frame.width - CGFloat(allTypes.count) * 37) / CGFloat(allTypes.count - 1)
containerView.spacing = space
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
class RDReaderEditView: UIView {
lazy var lineView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.lightGray
return view
}()
lazy var brightSlider: RDReaderBrightSlider = {
let slider = RDReaderBrightSlider()
return slider
}()
lazy var fontView: RDReaderFontView = {
let view = RDReaderFontView()
return view
}()
lazy var lineSpaceView: RDReaderLineSpaceView = {
let view = RDReaderLineSpaceView()
return view
}()
lazy var pageCurlTypeView: RDReaderPageCurlTypeView = {
let view = RDReaderPageCurlTypeView()
return view
}()
lazy var colorView: RDReaderThemeColorsView = {
let view = RDReaderThemeColorsView()
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
makeUI()
}
func makeUI() {
backgroundColor = .white
addSubview(lineView)
addSubview(brightSlider)
addSubview(fontView)
addSubview(lineSpaceView)
addSubview(pageCurlTypeView)
addSubview(colorView)
lineView.snp.makeConstraints { make in
make.left.right.top.equalTo(0)
make.height.equalTo(0.5)
}
brightSlider.snp.makeConstraints { make in
make.top.equalTo(20)
make.left.equalTo(16)
make.right.equalTo(-16)
make.height.equalTo(20)
}
fontView.snp.makeConstraints { make in
make.top.equalTo(brightSlider.snp.bottom).offset(20)
make.left.equalTo(16)
make.right.equalTo(-16)
make.height.equalTo(37)
}
lineSpaceView.snp.makeConstraints { make in
make.top.equalTo(fontView.snp.bottom).offset(20)
make.left.equalTo(16)
make.right.equalTo(-16)
make.height.equalTo(37)
}
pageCurlTypeView.snp.makeConstraints { make in
make.top.equalTo(lineSpaceView.snp.bottom).offset(20)
make.left.equalTo(16)
make.right.equalTo(-16)
make.height.equalTo(37)
}
colorView.snp.makeConstraints { make in
make.top.equalTo(pageCurlTypeView.snp.bottom).offset(20)
make.left.equalTo(16)
make.right.equalTo(-16)
make.height.equalTo(37)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -1,30 +0,0 @@
//
// RDReaderEndView.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/11.
//
import UIKit
import SnapKit
class RDReaderEndView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
let textLabel = UILabel()
textLabel.text = "结束页"
textLabel.backgroundColor = .red
textLabel.textAlignment = .center
addSubview(textLabel)
textLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(CGSize(width: 100, height: 100))
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -1,325 +0,0 @@
//
// RDReaderManager.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/20.
//
import UIKit
struct Book {
var desc: String?
var chapters: [Chapter]
}
struct Chapter {
///
var sort: Int
///
var title: String?
///
var pageCount: Int
///
var content: String
///
var pages: [ChapterPage]
///
var range: NSRange
}
struct ChapterPage {
///
var chapterSort: Int
///
var pageNum: Int
///
var content: String
///
var attrContent: NSAttributedString
///
var range: NSRange
}
public struct DemoBookItem {
public var title: String
public var source: BookSource
public init(title: String, source: BookSource) {
self.title = title
self.source = source
}
}
extension RDReaderManager {
static let themeTypeKey = "themeType.key"
static let brightValueKey = "brightValue.key"
static let fontValueKey = "fontValue.key"
static let lineSpaceKey = "lineSpace.key"
static let pageCurlTypeKey = "pageCurlType.key"
static let readProgressKey = "readProgress.key"
}
typealias BrightValue = CGFloat
extension BrightValue: RDReaderObserverType {
var cachesValue: CGFloat {
return self
}
var value: CGFloat {
return self
}
func transform(value: Any?) -> BrightValue {
return (value as? BrightValue) ?? self
}
}
extension Int: RDReaderObserverType {
var cachesValue: Int {
return self
}
var value: Int {
return self
}
func transform(value: Any?) -> Int {
return (value as? Int) ?? self
}
}
extension String: RDReaderObserverType {
var cachesValue: String {
return self
}
var value: String {
return self
}
func transform(value: Any?) -> String {
return (value as? String) ?? self
}
}
public class RDReaderManager: NSObject {
public static let shared = RDReaderManager()
let themeType = RDReaderObserver(key: RDReaderManager.themeTypeKey, defaultType: RDReaderCommon.Colors.white)
let brightValue = RDReaderObserver(key: RDReaderManager.brightValueKey, defaultType: UIScreen.main.brightness)
let fontValue = RDReaderObserver(key: RDReaderManager.fontValueKey, defaultType: CGFloat(15.0))
let lineSpaceType = RDReaderObserver(key: RDReaderManager.lineSpaceKey, defaultType: RDReaderCommon.SpaceType.min)
let pageCurlTypeType = RDReaderObserver(key: RDReaderManager.pageCurlTypeKey, defaultType: RDReaderCommon.PageCurlType.pageCurl)
let readProgress = RDReaderObserver(key: RDReaderManager.readProgressKey, defaultType: 0)
let epubReadLocation = RDReaderObserver(key: "epubReadLocation.key", defaultType: "")
let epubSelection = RDReaderObserver(key: "epubSelection.key", defaultType: "")
let epubHighlights = RDReaderObserver(key: "epubHighlights.key", defaultType: "")
}
protocol RDReaderObserverType {
associatedtype Value
associatedtype CacheValue
associatedtype CurrentType
var cachesValue: CacheValue { get }
var value: Value { get }
func transform(value: Any?) -> CurrentType
}
class RDReaderObserver<CurrentType> where CurrentType: RDReaderObserverType {
private var _currentType: CurrentType? = nil
private var key: String
private var defaultType: CurrentType
typealias Observer = ((CurrentType.Value) -> Void)
private var observers = [WeakObserver<Observer>]()
var currentType: CurrentType {
get {
if _currentType != nil {
return _currentType!
} else {
_currentType = (self.defaultType.transform(value: UserDefaults.standard.object(forKey: key)) as? CurrentType) ?? self.defaultType
return _currentType!
}
}
set {
_currentType = newValue
UserDefaults.standard.set(newValue.cachesValue, forKey: key)
UserDefaults.standard.synchronize()
observers.forEach { observer in
if let observer = observer.observer?.observer {
observer(_currentType!.value)
}
}
}
}
init(key: String, defaultType: CurrentType) {
self.key = key
self.defaultType = defaultType
}
func observeChange(onNext: Observer?) {
if let onNext = onNext {
onNext(currentType.value)
observers.append(WeakObserver(observer: onNext))
}
}
}
private class WeakObserver<T> {
class _Observer<T>: NSObject {
var observer: T
init(observer: T) {
self.observer = observer
super.init()
}
}
weak var observer: _Observer<T>?
private var _strongObserver: _Observer<T>?
init(observer: T) {
let t = _Observer(observer: observer)
_strongObserver = t
self.observer = _strongObserver
}
}
extension RDReaderManager {
func epubReaderConfiguration() -> RDEPUBReaderConfiguration {
let theme = themeType.currentType.value
return RDEPUBReaderConfiguration(
fontSize: fontValue.currentType,
lineHeightMultiple: lineSpaceType.currentType.mulitiple,
displayType: pageCurlTypeType.currentType.value,
landscapeDualPageEnabled: true,
reflowableContentInsets: UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16),
fixedContentInset: .zero,
theme: RDEPUBReaderTheme(
contentBackgroundColor: theme.contentBackgroudColor ?? .white,
contentTextColor: theme.contentTextColor ?? .black,
toolBackgroundColor: theme.toolBackgroudColor ?? .white,
toolControlTextColor: theme.toolControlTextColor ?? .black,
toolControlBorderUnselectColor: theme.toolControlBorderUnSelectColor ?? UIColor.lightGray.withAlphaComponent(0.5),
toolLineColor: theme.toolLineColor ?? UIColor.lightGray.withAlphaComponent(0.5)
),
fixedLayoutFit: .page,
fixedLayoutSpreadMode: .automatic,
textRenderingEngine: .dtCoreText
)
}
func epubPreferences(
theme: RDReaderTheme? = nil,
reflowableInsets: UIEdgeInsets,
fixedContentInset: UIEdgeInsets
) -> RDEPUBPreferences {
let resolvedTheme = theme ?? themeType.currentType.value
let backgroundColorCSS = resolvedTheme.contentBackgroudColor?.ss_cssString
return RDEPUBPreferences(
fontSize: fontValue.currentType,
lineHeightMultiple: lineSpaceType.currentType.mulitiple,
reflowableContentInsets: reflowableInsets,
fixedContentInset: fixedContentInset,
themeBackgroundColor: backgroundColorCSS,
themeTextColor: resolvedTheme.contentTextColor?.ss_cssString,
fixedBackgroundColor: backgroundColorCSS,
fixedLayoutFit: .page,
fixedLayoutSpreadMode: .automatic
)
}
func bundledEPUBURLs(in bundle: Bundle = .main) -> [URL] {
(bundle.urls(forResourcesWithExtension: "epub", subdirectory: nil) ?? [])
.sorted { lhs, rhs in
lhs.deletingPathExtension().lastPathComponent.localizedStandardCompare(rhs.deletingPathExtension().lastPathComponent) == .orderedAscending
}
}
public func demoBookItems(in bundle: Bundle = .main) -> [DemoBookItem] {
let textDemo = DemoBookItem(title: "宠她.txt", source: .textFile)
let epubItems = bundledEPUBURLs(in: bundle).map { url in
DemoBookItem(title: url.deletingPathExtension().lastPathComponent, source: .epubFile(url: url))
}
return [textDemo] + epubItems
}
@discardableResult
public func startReading(book item: DemoBookItem, from navigationController: UINavigationController?, animated: Bool = true) -> UIViewController? {
let readerController: UIViewController
switch item.source {
case .textFile:
guard let textURL = Bundle.main.url(forResource: "宠她", withExtension: "txt") else {
return nil
}
readerController = RDURLReaderController(bookURL: textURL)
case .epubFile(let url):
readerController = RDURLReaderController(
bookURL: url,
epubConfiguration: epubReaderConfiguration()
)
}
readerController.title = item.title
navigationController?.pushViewController(readerController, animated: animated)
return readerController
}
func encodeTextFile(font: UIFont, lineSapce: CGFloat, size: CGSize) -> Book {
let decodeString = String.encodeTextFile(url: URL(fileURLWithPath: Bundle.main.path(forResource: "宠她", ofType: ".txt")!))
let parten = "第[0-9一二三四五六七八九十百千]*[章回].*"
var chapters = [Chapter]()
var desc: String?
if let expression = try? NSRegularExpression(pattern: parten, options: .caseInsensitive) {
let results = expression.matches(in: decodeString, options: .reportCompletion, range: NSMakeRange(0, decodeString.count))
var startCount = -1
var lastTitle: String?
for result in results {
let index = results.firstIndex(of: result)
let range = result.range
if startCount != -1 && index! > 0 {
let contentRange = NSMakeRange(startCount, range.location - startCount)
let content = decodeString.substring(with: Range(contentRange, in: decodeString)!)
let pages = chapterPages(content: content, font: font, lineSapce: lineSapce, size: size, chapterSort: index!)
let chapter = Chapter(sort: index!,title: lastTitle, pageCount: pages.count, content: String(content), pages: pages, range: contentRange)
chapters.append(chapter)
if index == results.count - 1 {
let contentRange = NSMakeRange(range.location, decodeString.count - range.location)
let content = decodeString.substring(with: Range(contentRange, in: decodeString)!)
let title = decodeString.substring(with: Range(range, in: decodeString)!)
let pages = chapterPages(content: content, font: font, lineSapce: lineSapce, size: size, chapterSort: index!)
let chapter = Chapter(sort: index! + 1, title: title, pageCount: pages.count, content: String(content), pages: pages, range: contentRange)
chapters.append(chapter)
}
}
startCount = range.location
lastTitle = decodeString.substring(with: Range(range, in: decodeString)!)
if index == 0 && range.location > 0 {
desc = decodeString.substring(with: Range(NSMakeRange(0, range.location), in: decodeString)!)
}
}
}
return Book(desc: desc, chapters: chapters)
}
func chapterPages(content: String, font: UIFont, lineSapce: CGFloat, size: CGSize, chapterSort: Int) -> [ChapterPage] {
let attrString = NSMutableAttributedString(string: content)
let style = NSMutableParagraphStyle()
style.lineSpacing = lineSapce
attrString.addAttributes([NSAttributedString.Key.font : font, NSAttributedString.Key.paragraphStyle: style], range: NSMakeRange(0, content.count))
let pageRanges = attrString.pageRanges(size: size)
let pages = pageRanges.map({
pageRange -> ChapterPage in
let pageContent = content.substring(with: Range(pageRange, in: content)!)
let pageAttrContent = attrString.attributedSubstring(from: pageRange)
return ChapterPage(chapterSort: chapterSort, pageNum: pageRanges.firstIndex(of: pageRange)!, content: pageContent, attrContent: pageAttrContent, range: pageRange)
})
return pages
}
}

View File

@ -1,75 +0,0 @@
//
// RDReaderTheme.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/10.
//
import Foundation
import UIKit
public protocol RDReaderTheme {
var contentBackgroudColor: UIColor? { get }
var contentTextColor: UIColor? { get }
var toolBackgroudColor: UIColor? { get }
var toolControlTextColor: UIColor? { get }
var toolControlBorderUnSelectColor: UIColor? { get }
var toolLineColor: UIColor? { get }
}
struct WhiteTheme: RDReaderTheme {
var contentBackgroudColor: UIColor? = UIColor.white
var contentTextColor: UIColor? = UIColor.black
var toolBackgroudColor: UIColor? = UIColor.white
var toolControlTextColor: UIColor? = UIColor.black
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
}
struct DarkTheme: RDReaderTheme {
var contentBackgroudColor: UIColor? = UIColor.black
var contentTextColor: UIColor? = UIColor.white
var toolBackgroudColor: UIColor? = UIColor.black
var toolControlTextColor: UIColor? = UIColor.white
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
}
struct YellowTheme: RDReaderTheme {
var contentBackgroudColor: UIColor? = UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1)
var contentTextColor: UIColor? = UIColor.black
var toolBackgroudColor: UIColor? = UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1)
var toolControlTextColor: UIColor? = UIColor.black
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
}
struct GreenTheme: RDReaderTheme {
var contentBackgroudColor: UIColor? = UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1)
var contentTextColor: UIColor? = UIColor.black
var toolBackgroudColor: UIColor? = UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1)
var toolControlTextColor: UIColor? = UIColor.black
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
}
struct PinkTheme: RDReaderTheme {
var contentBackgroudColor: UIColor? = UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1)
var contentTextColor: UIColor? = UIColor.black
var toolBackgroudColor: UIColor? = UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1)
var toolControlTextColor: UIColor? = UIColor.black
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
}
struct BlueTheme: RDReaderTheme {
var contentBackgroudColor: UIColor? = UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1)
var contentTextColor: UIColor? = UIColor.black
var toolBackgroudColor: UIColor? = UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1)
var toolControlTextColor: UIColor? = UIColor.black
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
}

View File

@ -1,67 +0,0 @@
//
// RDReaderToolView.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/10.
//
import UIKit
public class RDReaderToolView: UIView {
lazy var lineView: UIView = {
let view = UIView()
return view
}()
var lineHeight: CGFloat = 0.5
override init(frame: CGRect) {
super.init(frame: frame)
makeUI()
}
func makeUI() {
addSubview(lineView)
}
public override func layoutSubviews() {
super.layoutSubviews()
lineView.frame = CGRect(x: 0, y: 0, width: frame.width, height: lineHeight)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
public class TintColorButton: UIButton {
public override func tintColorDidChange() {
super.tintColorDidChange()
if let image = self.currentImage {
let image = image.tintColorImage(color: tintColor)
setImage(image, for: .normal)
}
}
}
extension UIImage {
static func rdToolbarImage(named: String, fallbackSystemName: String) -> UIImage? {
if let image = UIImage(named: named) {
return image.withRenderingMode(.alwaysOriginal)
}
return UIImage(systemName: fallbackSystemName)
}
func tintColorImage(color: UIColor) -> UIImage? {
UIGraphicsBeginImageContextWithOptions(size, false, scale)
let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
color.set()
UIRectFill(rect)
draw(at: .zero, blendMode: .destinationIn, alpha: 1)
let newImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return newImage
}
}

View File

@ -1,54 +0,0 @@
//
// RDReaderTopToolView.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/10.
//
import UIKit
import SnapKit
public class RDReaderTopToolView: RDReaderToolView,SSEventTrigger {
enum Event {
case back
}
public override func layoutSubviews() {
super.layoutSubviews()
lineView.frame = CGRect(x: 0, y: frame.height - lineHeight, width: frame.width, height: lineHeight)
}
lazy var backButton: TintColorButton = {
let backButton = TintColorButton(type: .system)
backButton.setImage(toolbarImage(named: "arrow_left", fallbackSystemName: "chevron.left"), for: .normal)
return backButton
}()
override func makeUI() {
super.makeUI()
addSubview(backButton)
backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
backButton.snp.makeConstraints { make in
make.left.equalTo(0)
make.bottom.equalTo(0)
make.height.equalTo(44)
make.width.equalTo(50)
}
}
@objc private func backAction() {
if let trigger = self.triggerEvent {
trigger(.back)
}
}
private func toolbarImage(named: String, fallbackSystemName: String) -> UIImage? {
if let image = UIImage(named: named) {
return image.withRenderingMode(.alwaysOriginal)
}
return UIImage(systemName: fallbackSystemName)
}
}

View File

@ -1,50 +0,0 @@
//
// SSChapterListController.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/19.
//
import UIKit
class SSChapterListController: UITableViewController {
var selectedChapter: ((Int) -> Void)?
var chapters: [Chapter]
var currentChapterNum: Int
init(chapters:[Chapter], currentChapterNum: Int) {
self.chapters = chapters
self.currentChapterNum = currentChapterNum
super.init(style: .plain)
tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: view.frame.width, height: 0))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "UITableViewCell")
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.chapters.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell")
cell?.textLabel?.text = self.chapters[indexPath.row].title
cell?.textLabel?.textColor = indexPath.row == currentChapterNum ? UIColor.red : UIColor.black
return cell!
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let selectedChapter = selectedChapter {
selectedChapter(indexPath.row)
}
}
func selectedChapter(onTrigger: @escaping (Int) -> Void) {
self.selectedChapter = onTrigger
}
}

View File

@ -1,38 +0,0 @@
//
// EventTrigger.swift
//
//
// Created by yangsq on 2020/11/4.
//
import Foundation
protocol SSEventTrigger {
associatedtype Event
typealias TriggerEvent = (Event) -> Void
var triggerEvent: TriggerEvent? { get set }
func trigger(event: TriggerEvent?)
}
private var triggerEventKey: UInt8 = 0
extension SSEventTrigger {
private var _triggerEvent:TriggerEvent? {
get {return objc_getAssociatedObject(self, &triggerEventKey) as? Self.TriggerEvent}
set {objc_setAssociatedObject(self, &triggerEventKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)}
}
var triggerEvent: TriggerEvent? {
get{_triggerEvent}
set{
if newValue != nil {
_triggerEvent = newValue!
}
}
}
func trigger(event: TriggerEvent?) {
if event != nil {
objc_setAssociatedObject(self, &triggerEventKey, event!, .OBJC_ASSOCIATION_COPY_NONATOMIC)
}
}
}

View File

@ -1,36 +0,0 @@
//
// RDReaderContentCell.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/7.
//
import UIKit
class RDReaderContentCell: UICollectionViewCell {
private var _containerView: UIView? = nil
var containerView: UIView? {
set {
guard newValue !== _containerView else { return }
_containerView?.removeFromSuperview()
_containerView = newValue
if let containerView = _containerView {
contentView.addSubview(containerView)
setNeedsLayout()
}
}
get {
return _containerView
}
}
override func layoutSubviews() {
super.layoutSubviews()
_containerView?.frame = CGRect(x: 0, y: 0, width: contentView.frame.width, height: contentView.frame.height)
}
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,55 @@
//
// RDReaderContentCell.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/7.
//
// UICollectionViewCell 宿
// cell containerView
// RDReaderDataSource.pageContentView() containerView
//
// RDReaderView cell UICollectionView 使
//
import UIKit
/// cell 宿
/// UICollectionView
/// ``containerView`` layoutSubviews
/// frame contentView
class RDReaderContentCell: UICollectionViewCell {
///
private var _containerView: UIView? = nil
///
/// contentView
///
var containerView: UIView? {
set {
guard newValue !== _containerView else { return }
_containerView?.removeFromSuperview()
_containerView = newValue
if let containerView = _containerView {
contentView.addSubview(containerView)
setNeedsLayout()
}
}
get {
return _containerView
}
}
///
/// containerView frame contentView
override func layoutSubviews() {
super.layoutSubviews()
_containerView?.frame = CGRect(x: 0, y: 0, width: contentView.frame.width, height: contentView.frame.height)
}
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -4,26 +4,53 @@
//
// Created by yangsq on 2021/8/6.
//
// UICollectionViewFlowLayout
// item +
//
//
// RDReaderView RDReaderView
//
import UIKit
///
///
public protocol RDReaderFlowLayoutDataSoure: NSObjectProtocol {
///
/// - Parameters:
/// - flowLayout:
/// - pageIndex:
/// - Returns: nil 使collectionView.frame.height
func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat?
}
///
///
@objc public protocol RDReaderFlowLayoutDelegate: NSObjectProtocol {
///
/// - Parameters:
/// - flowLayout:
/// - pageIndex:
func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int)
}
///
/// - horizontalScroll12
/// - verticalScroll
///
/// ``displayType``
/// ``isLandscapeDualPage`` ``coverPageIndex``
public class RDReaderFlowLayout: UICollectionViewFlowLayout {
/// 使
var displayType: RDReaderView.DisplayType = .horizontalScroll {
didSet {
invalidateLayout()
}
}
///
///
var isLandscapeDualPage: Bool = false {
didSet {
if oldValue != isLandscapeDualPage {
@ -31,7 +58,9 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
}
}
}
///
///
var coverPageIndex: Int? = nil {
didSet {
if oldValue != coverPageIndex {
@ -39,9 +68,12 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
}
}
}
/// prepare bounds
private var lastPreparedBoundsSize: CGSize = .zero
///
/// +21
var pagesPerScreen: Int {
guard isLandscapeDualPage else { return 1 }
switch displayType {
@ -54,14 +86,25 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
return 1
}
}
///
weak var dataSource: RDReaderFlowLayoutDataSoure? = nil
///
weak var delegate: RDReaderFlowLayoutDelegate? = nil
///
private var hasCoverPageInDualMode: Bool {
return pagesPerScreen > 1 && coverPageIndex != nil
}
/// item frame
///
/// - Parameters:
/// - index: item
/// - screenWidth:
/// - halfWidth:
/// - height:
/// - Returns: item frame
private func coverAwareFrame(for index: Int, screenWidth: CGFloat, halfWidth: CGFloat, height: CGFloat) -> CGRect {
guard let coverIndex = coverPageIndex else {
let pairIdx = index / 2
@ -81,8 +124,12 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
let x = CGFloat(coverScreens + pairIdx) * screenWidth + CGFloat(side) * halfWidth
return CGRect(x: x, y: 0, width: halfWidth, height: height)
}
/// cover-aware screen start index calculation
/// item
/// - Parameters:
/// - offset:
/// - screenWidth:
/// - Returns: item
private func coverAwareStartIndex(for offset: CGFloat, screenWidth: CGFloat) -> Int {
let pps = pagesPerScreen
guard let coverIdx = coverPageIndex, hasCoverPageInDualMode else {
@ -99,22 +146,26 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
return coverIdx + 1 + pairIdx * 2
}
}
/// delegate
private var currentPage: Int = 0 {
didSet {
if let delegate = delegate, delegate.responds(to: #selector(RDReaderFlowLayoutDelegate.pageNum(flowLayout:pageIndex:))), currentPage != oldValue, currentPage >= 0 {
delegate.pageNum(flowLayout: self, pageIndex: currentPage)
}
}
}
///
/// - Parameter displayType:
init(displayType: RDReaderView.DisplayType) {
self.displayType = displayType
super.init()
}
/// item
public override func prepare() {
super.prepare()
guard let collectionView = self.collectionView else {
@ -123,9 +174,9 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
guard collectionView.frame.width > 0, collectionView.frame.height > 0 else {
return
}
lastPreparedBoundsSize = collectionView.bounds.size
if #available(iOS 11.0, *) {
collectionView.contentInsetAdjustmentBehavior = .never
} else {
@ -149,8 +200,11 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
default: break
}
}
/// bounds 使
/// true true
public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
if newBounds.size != lastPreparedBoundsSize {
return true
@ -164,7 +218,10 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
return false
}
}
/// collectionView
///
/// +
public override var collectionViewContentSize: CGSize {
var size = super.collectionViewContentSize
guard let collectionView = collectionView else {
@ -185,13 +242,16 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
}
return size
}
///
/// item
/// cell
public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var attributes = super.layoutAttributesForElements(in: rect)
guard let collectionView = collectionView else {
return attributes
}
if self.displayType == .verticalScroll {
let rowCount = collectionView.numberOfItems(inSection: 0)
var totalHeight: CGFloat = 0
@ -218,18 +278,18 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
attr.frame = CGRect(x: 0, y: lastTotalHeight, width: collectionView.frame.width, height: height)
})
}
if self.displayType == .horizontalScroll {
let pps = pagesPerScreen
if hasCoverPageInDualMode {
let screenWidth = collectionView.frame.width
let halfWidth = screenWidth / 2.0
let rows = collectionView.numberOfItems(inSection: 0)
self.currentPage = coverAwareStartIndex(for: collectionView.contentOffset.x, screenWidth: screenWidth)
var attrs = [UICollectionViewLayoutAttributes]()
for index in 0..<rows {
let frame = coverAwareFrame(for: index, screenWidth: screenWidth, halfWidth: halfWidth, height: collectionView.frame.height)
@ -261,10 +321,14 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
})
}
}
return attributes
}
/// contentOffset
///
/// - Parameter count:
/// - Returns: contentOffset
func currentContentOffset(count: Int) -> CGPoint {
guard let collectionView = collectionView else {
return .zero
@ -303,7 +367,7 @@ public class RDReaderFlowLayout: UICollectionViewFlowLayout {
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")

View File

@ -4,30 +4,46 @@
//
// Created by yangsq on 2021/8/10.
//
//
//
//
// RDReaderView tapGestureRecognizer
//
// RDReaderView
//
import UIKit
///
///
/// RDReaderView 使
class RDReaderGestureController: UIViewController {
///
var topToolView: UIView?
///
var bottomToolView: UIView?
///
/// - Parameters:
/// - topToolView:
/// - bottomToolView:
init(topToolView: UIView?, bottomToolView: UIView?) {
self.topToolView = topToolView
self.bottomToolView = bottomToolView
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
/*
// MARK: - Navigation

View File

@ -4,24 +4,47 @@
//
// Created by yangsq on 2021/8/9.
//
// UIPageViewController
// pageCurl 仿 RDReaderPageChildViewController
// contentContainerView
//
// RDReaderView UIPageViewController 使
//
import UIKit
/// 仿
/// UIPageViewController
/// ``contentView`` ``pageNum``
/// contentContainerView
class RDReaderPageChildViewController: UIViewController {
/// contentView
private let contentContainerView = UIView()
///
/// contentContainerView
var contentView: UIView? {
didSet {
guard isViewLoaded else { return }
installContentView()
}
}
///
var pageNum: Int = 0
///
/// - Parameters:
/// - contentView:
/// - pageNum: 0
init(contentView: UIView?, pageNum: Int = 0) {
self.contentView = contentView
self.pageNum = pageNum
super.init(nibName: nil, bundle: nil)
}
///
/// contentContainerView
override func loadView() {
view = UIView()
view.backgroundColor = .clear
@ -31,7 +54,7 @@ class RDReaderPageChildViewController: UIViewController {
view.addSubview(contentContainerView)
installContentView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@ -41,6 +64,8 @@ class RDReaderPageChildViewController: UIViewController {
// Do any additional setup after loading the view.
}
/// contentContainerView
/// contentContainerView contentView
private func installContentView() {
contentContainerView.subviews.forEach { $0.removeFromSuperview() }
guard let contentView else { return }
@ -49,7 +74,7 @@ class RDReaderPageChildViewController: UIViewController {
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentContainerView.addSubview(contentView)
}
/*
// MARK: - Navigation

View File

@ -4,32 +4,73 @@
//
// Created by yangsq on 2021/8/6.
//
// RDReaderView
// UIView RDReaderView
// 1. 仿
// 2. 1/31/31/3
// 3.
// 4. (LTR)(RTL)
// 5. DataSource/Delegate
// 6.
//
// RDReaderView
// EPUBCore EPUBTextRendering RDReaderView EPUBUI UI
//
import UIKit
///
/// RDReaderView
/// RDReaderView
@objc public protocol RDReaderDataSource: NSObjectProtocol {
///
/// - Parameter readerView:
/// - Returns:
func pageCountOfReaderView(readerView: RDReaderView) -> Int
///
/// - Parameters:
/// - readerView:
/// - pageNum: 0
/// - containerView: nil
/// - Returns:
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
/// UICollectionViewCell
/// - Parameters:
/// - readerView:
/// - pageNum:
/// - Returns: nil 使 cell
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
/// /
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
/// /
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
}
///
///
@objc public protocol RDReaderDelegate: NSObjectProtocol {
///
/// - Parameters:
/// - readerView:
/// - pageNum:
func pageNum(readerView: RDReaderView, pageNum: Int)
///
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
}
extension RDReaderView {
public enum DisplayType {
/// RDReaderView 使
public enum DisplayType {
/// 仿使 UIPageViewController
case pageCurl
/// 使 UICollectionView +
case horizontalScroll
/// 使 UICollectionView
case verticalScroll
}
///
///
public enum PageDirection {
/// /
case leftToRight
@ -38,33 +79,66 @@ extension RDReaderView {
}
}
///
/// RDReaderView
///
///
/// 使
/// 1. ``dataSource``
/// 2. ``delegate``
/// 3. ``switchReaderDisplayType(_:)``
/// 4. ``reloadData()``
///
///
/// - pageCurl / horizontalScroll / verticalScroll
/// -
/// - RTL
/// - //
/// -
public class RDReaderView: UIView {
///
///
enum TapEvent {
case none, left, center, right
///
case none
/// 1/3RTL
case left
/// 1/3/
case center
/// 1/3RTL
case right
}
/// 仿使 UIPageViewController
/// pageCurl 仿
private lazy var pageViewController: UIPageViewController = {
let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil)
pageVC.delegate = self
pageVC.dataSource = self
return pageVC
}()
///
private lazy var layout: RDReaderFlowLayout = {
let layout = RDReaderFlowLayout(displayType: .horizontalScroll)
layout.dataSource = self
layout.delegate = self
return layout
}()
/// 使 UICollectionView
/// horizontalScroll verticalScroll
private lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.backgroundColor = UIColor.clear
return collectionView
}()
///
/// delegate
/// -1
public var currentPage: Int = -1 {
didSet {
if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDReaderDelegate.pageNum(readerView:pageNum:))) {
@ -72,12 +146,16 @@ public class RDReaderView: UIView {
}
}
}
///
///
private(set) lazy var tapGestureRecognizer: UITapGestureRecognizer = {
let tap = UITapGestureRecognizer(target: self, action: #selector(tapAction(tap:)))
return tap
}()
///
///
private var tapEvent: TapEvent = .none {
didSet {
let isRTL = pageDirection == .rightToLeft
@ -97,7 +175,9 @@ public class RDReaderView: UIView {
}
}
}
///
/// 使 adjacentDualPage +1
private func goNextPage() {
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
if pagesPerScreen > 1 {
@ -108,7 +188,9 @@ public class RDReaderView: UIView {
transitionToPage(pageNum: currentPage + 1, animated: true)
}
}
///
/// 使 adjacentDualPage -1
private func goPreviousPage() {
if pagesPerScreen > 1 {
if let target = adjacentDualPage(from: currentPage, forward: false) {
@ -118,36 +200,41 @@ public class RDReaderView: UIView {
transitionToPage(pageNum: currentPage - 1, animated: true)
}
}
///
public weak var dataSource: RDReaderDataSource? = nil
///
public weak var delegate: RDReaderDelegate? = nil
/// 仿
public var currentDisplayType: RDReaderView.DisplayType = .pageCurl
/// / 0.3
public var toolViewAnimationDuration: TimeInterval = 0.3
///
public var landscapeDualPageEnabled: Bool = false
/// .leftToRight
/// /
public var pageDirection: RDReaderView.PageDirection = .leftToRight
///
/// nil
/// nil 0+1, 2+3, 4+5...
public var coverPageIndex: Int? = nil
///
private var hasCoverPage: Bool {
return coverPageIndex != nil
}
///
/// >
private var isLandscape: Bool {
return bounds.width > bounds.height
}
///
/// 12
public var pagesPerScreen: Int {
if !landscapeDualPageEnabled { return 1 }
if currentDisplayType == .verticalScroll { return 1 }
return isLandscape ? 2 : 1
}
///
/// - Parameter pageNum:
/// - Returns:
@ -155,11 +242,11 @@ public class RDReaderView: UIView {
guard landscapeDualPageEnabled, isLandscape, let coverIndex = coverPageIndex else { return false }
return pageNum == coverIndex
}
///
///
/// - Parameter pageNum:
/// - Returns: (, ())
/// - Returns: (, (nil))
private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) {
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
if let coverIndex = coverPageIndex {
@ -181,11 +268,11 @@ public class RDReaderView: UIView {
return (left, right)
}
}
/// /
/// - Parameters:
/// - pageNum:
/// - forward:
/// - forward: true=false=
/// - Returns: nil
private func adjacentDualPage(from pageNum: Int, forward: Bool) -> Int? {
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
@ -199,41 +286,65 @@ public class RDReaderView: UIView {
return dualPagePair(for: prevEnd).left
}
}
/// layoutSubviews
private var previousIsLandscape: Bool?
/// key
private var contentViews = [String : UIView.Type]()
/// pageCurl
private var willPreviousTransitionToViewController: UIViewController? = nil
/// pageCurl
private var willNextTransitionToViewController: UIViewController? = nil
/// pageCurl
private var willTransitionToViewController: UIViewController? = nil
///
private var topToolView: UIView?
///
private var bottomToolView: UIView?
///
private var isShowToolView: Bool = false
///
private var isTransitioning: Bool = false
/// UI
private var didBuildUI = false
/// 宿
private let preloadHostView = UIView()
/// key
private var preloadedPageViews: [Int: UIView] = [:]
/// pageCurl key
private var pageCurlCachedViews: [Int: UIView] = [:]
/// true=false=
private var predictedPageDirection: Bool?
/// 1
public var preloadRadius: Int = 1
///
private var cacheSignature: CacheSignature?
/// pageCurl
/// pageCurl
static let blankPageNum = Int.max
/// pageCurl
/// pageCurl
static let blankEndPageNum = Int.max - 1
///
///
private struct CacheSignature: Equatable {
///
let displayType: DisplayType
///
let isLandscape: Bool
///
let pagesPerScreen: Int
///
let boundsSize: CGSize
}
override init(frame: CGRect) {
super.init(frame: frame)
}
///
///
public override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0, bounds.height > 0 else { return }
@ -252,8 +363,11 @@ public class RDReaderView: UIView {
layout.isLandscapeDualPage = landscapeDualPageEnabled && nowLandscape
}
}
///
///
/// - pageCurl UIPageViewController spineLocation
/// -
/// - Parameter isNowLandscape:
private func orientationChanged(isNowLandscape: Bool) {
let savedPage = max(0, currentPage)
@ -262,7 +376,7 @@ public class RDReaderView: UIView {
// 2.
layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape
layout.coverPageIndex = coverPageIndex
switch currentDisplayType {
case .pageCurl:
// 仿 spineLocation PageViewController
@ -285,9 +399,10 @@ public class RDReaderView: UIView {
}
}
}
/// spine UIPageViewController
/// - Parameter isDualPage: .mid
/// - Returns: UIPageViewController
private func createPageViewController(isDualPage: Bool) -> UIPageViewController {
let options: [UIPageViewController.OptionsKey: Any]?
if isDualPage {
@ -301,8 +416,10 @@ public class RDReaderView: UIView {
pageVC.isDoubleSided = isDualPage
return pageVC
}
/// UIPageViewController spineLocation
/// UIPageViewController
/// spineLocation
/// PageViewController
private func rebuildPageViewController() {
detachPageViewControllerIfNeeded()
@ -314,6 +431,10 @@ public class RDReaderView: UIView {
// MARK: - UIPageViewController Fault Detection
/// UIPageViewController
///
/// - Parameter pageVC: UIPageViewController
/// - Returns: true
private func detectPageViewControllerFault(_ pageVC: UIPageViewController) -> Bool {
guard currentDisplayType == .pageCurl else { return false }
let expectedCount = (landscapeDualPageEnabled && isLandscape) ? 2 : 1
@ -338,6 +459,8 @@ public class RDReaderView: UIView {
|| childViewControllers[1].pageNum != expectedRightPage
}
/// UIPageViewController
/// PageViewController
private func patchPageViewControllerFault() {
guard currentPage >= 0 else { return }
DispatchQueue.main.async { [weak self] in
@ -350,6 +473,8 @@ public class RDReaderView: UIView {
// MARK: - Preload / Forecast
/// 宿
///
private func ensurePreloadHostView() {
guard preloadHostView.superview == nil else { return }
preloadHostView.isHidden = true
@ -360,6 +485,9 @@ public class RDReaderView: UIView {
insertSubview(preloadHostView, at: 0)
}
///
/// - Parameter pageNum:
/// - Returns: true
private func shouldCachePage(_ pageNum: Int) -> Bool {
pageNum >= 0
&& pageNum != RDReaderView.blankPageNum
@ -367,6 +495,7 @@ public class RDReaderView: UIView {
&& pageNum < (dataSource?.pageCountOfReaderView(readerView: self) ?? 0)
}
///
private func currentCacheSignature() -> CacheSignature {
CacheSignature(
displayType: currentDisplayType,
@ -376,6 +505,8 @@ public class RDReaderView: UIView {
)
}
///
///
private func invalidatePageCaches() {
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
@ -384,6 +515,7 @@ public class RDReaderView: UIView {
cacheSignature = currentCacheSignature()
}
///
private func refreshCacheSignatureIfNeeded() {
let signature = currentCacheSignature()
if cacheSignature != signature {
@ -393,6 +525,8 @@ public class RDReaderView: UIView {
}
}
/// ""
///
private func spreadStart(for pageNum: Int) -> Int? {
guard shouldCachePage(pageNum) else { return nil }
let isDualPage = landscapeDualPageEnabled && isLandscape && currentDisplayType != .verticalScroll
@ -400,6 +534,8 @@ public class RDReaderView: UIView {
return dualPagePair(for: pageNum).left
}
/// ""
///
private func spreadPageNumbers(startingAt pageNum: Int) -> Set<Int> {
guard shouldCachePage(pageNum) else { return [] }
let isDualPage = landscapeDualPageEnabled && isLandscape && currentDisplayType != .verticalScroll
@ -412,6 +548,7 @@ public class RDReaderView: UIView {
return pages
}
/// ""
private func adjacentSpreadStart(from pageNum: Int, forward: Bool) -> Int? {
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
guard totalPages > 0,
@ -435,10 +572,13 @@ public class RDReaderView: UIView {
return dualPagePair(for: prevEnd).left
}
///
private func visiblePageNumbers(for anchorPage: Int) -> Set<Int> {
spreadPageNumbers(startingAt: anchorPage)
}
///
///
private func pageViewForDisplay(pageNum: Int) -> UIView {
if let cached = pageCurlCachedViews[pageNum] {
return cached
@ -453,6 +593,8 @@ public class RDReaderView: UIView {
return view
}
///
///
private func trimCachedPageViews(keeping pageNumbers: Set<Int>) {
pageCurlCachedViews = pageCurlCachedViews.filter { key, value in
let keep = pageNumbers.contains(key)
@ -471,6 +613,9 @@ public class RDReaderView: UIView {
}
}
///
/// preloadRadius preloadRadius
/// preferredForward
private func forecastTargets(around pageNum: Int, preferredForward: Bool?) -> [Int] {
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
guard totalPages > 0, shouldCachePage(pageNum) else { return [] }
@ -500,6 +645,11 @@ public class RDReaderView: UIView {
return targets.sorted()
}
///
/// 1.
/// 2.
/// 3.
/// 4.
private func primePageCache(around pageNum: Int, preferredForward: Bool? = nil) {
refreshCacheSignatureIfNeeded()
let targets = forecastTargets(around: pageNum, preferredForward: preferredForward)
@ -521,7 +671,8 @@ public class RDReaderView: UIView {
contentView.frame = preloadHostView.bounds
}
}
/// UI
public override func didMoveToSuperview() {
super.didMoveToSuperview()
@ -530,6 +681,8 @@ public class RDReaderView: UIView {
}
}
/// pageViewController
///
private func attachPageViewControllerIfNeeded() {
guard let parentViewController = self.ss_superViewController else { return }
@ -555,6 +708,8 @@ public class RDReaderView: UIView {
}
}
/// pageViewController
/// willMove/didMove
private func detachPageViewControllerIfNeeded() {
if pageViewController.parent != nil {
pageViewController.willMove(toParent: nil)
@ -564,7 +719,9 @@ public class RDReaderView: UIView {
pageViewController.removeFromParent()
}
}
/// UI
/// cell
private func makeUI() {
guard !didBuildUI else {
if currentDisplayType == .pageCurl {
@ -584,13 +741,15 @@ public class RDReaderView: UIView {
}
ensurePreloadHostView()
cacheSignature = currentCacheSignature()
addGestureRecognizer(tapGestureRecognizer)
// touchUpInside
tapGestureRecognizer.cancelsTouchesInView = false
}
///
/// tapEvent
@objc private func tapAction(tap: UITapGestureRecognizer) {
let point = tap.location(in: tap.view)
if isShowToolView {
@ -609,11 +768,11 @@ public class RDReaderView: UIView {
tapEvent = .left
}
}
if centerFrame.contains(point) {
tapEvent = .center
}
if rightFrame.contains(point) {
if isShowToolView {
tapEvent = .center
@ -622,7 +781,10 @@ public class RDReaderView: UIView {
}
}
}
/// /
///
///
private func tapCenter() {
isShowToolView = !isShowToolView
if isShowToolView {
@ -680,6 +842,7 @@ public class RDReaderView: UIView {
}
}
///
private func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool {
if toolView.frame.contains(point) {
return true
@ -688,9 +851,10 @@ public class RDReaderView: UIView {
guard let hitView else { return false }
return hitView === toolView || hitView.isDescendant(of: toolView)
}
/// 仿//
/// PageViewController CollectionView
/// - Parameter displayType:
public func switchReaderDisplayType(_ displayType: RDReaderView.DisplayType) {
let previousDisplayType = currentDisplayType
self.currentDisplayType = displayType
@ -727,8 +891,11 @@ public class RDReaderView: UIView {
primePageCache(around: currentPage, preferredForward: predictedPageDirection)
}
}
///
///
/// - pageCurl UIPageViewController.setViewControllers
/// - setContentOffset
/// - Parameters:
/// - pageNum: item
/// - animated:
@ -774,14 +941,16 @@ public class RDReaderView: UIView {
primePageCache(around: pageNum, preferredForward: predictedPageDirection)
}
}
///
///
public func reloadData() {
switchReaderDisplayType(currentDisplayType)
topToolView = self.dataSource?.topToolView?(readerView: self)
bottomToolView = self.dataSource?.bottomToolView?(readerView: self)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@ -790,8 +959,12 @@ public class RDReaderView: UIView {
/// 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)
@ -799,16 +972,21 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
let contentView = pageViewForDisplay(pageNum: pageNum)
return RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum)
}
/// ""
/// ""
///
/// - Parameters:
/// - pageNum:
/// - isDualPage:
/// - Returns: nil
private func nextPageNum(after pageNum: Int, isDualPage: Bool) -> Int? {
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
//
if pageNum == RDReaderView.blankEndPageNum {
return nil
}
if isDualPage, let coverIndex = coverPageIndex {
if pageNum == coverIndex {
return RDReaderView.blankPageNum
@ -818,12 +996,12 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
return firstContent < totalPages ? firstContent : nil
}
}
let next = pageNum + 1
if next < totalPages {
return next
}
// pageNum
if isDualPage {
if let coverIndex = coverPageIndex {
@ -841,8 +1019,13 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
}
return nil
}
/// ""
/// ""
///
/// - Parameters:
/// - pageNum:
/// - isDualPage:
/// - Returns: nil
private func prevPageNum(before pageNum: Int, isDualPage: Bool) -> Int? {
//
if pageNum == RDReaderView.blankEndPageNum {
@ -860,38 +1043,44 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
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
// RTL before/after before=
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
@ -907,7 +1096,9 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
patchPageViewControllerFault()
}
}
/// UIPageViewController
///
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
willTransitionToViewController = pendingViewControllers.first
if let target = pendingViewControllers.first as? RDReaderPageChildViewController,
@ -917,15 +1108,20 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
primePageCache(around: target.pageNum, preferredForward: predictedPageDirection)
}
}
}
/// UICollectionView
///
extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, RDReaderFlowLayoutDataSoure {
/// UICollectionViewCell
/// DataSource cell
/// RTL cell 使
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if let identifer = self.dataSource?.pageIdentifier(readerView: self, pageNum: indexPath.row) {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifer, for: IndexPath(item: indexPath.row, section: 0)) as! RDReaderContentCell
let preloadedView = preloadedPageViews.removeValue(forKey: indexPath.row)
preloadedView?.removeFromSuperview()
@ -942,33 +1138,44 @@ extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate,
}
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 self.dataSource?.pageCountOfReaderView(readerView: self) ?? 0
}
///
public func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) {
currentPage = pageIndex
primePageCache(around: pageIndex, preferredForward: predictedPageDirection)
}
///
/// nil 使
public func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? {
return nil
}
}
///
/// UITableView register/dequeueReusable
extension RDReaderView {
///
/// - Parameters:
/// - contentView:
/// - identifier:
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
@ -978,7 +1185,9 @@ extension RDReaderView {
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
@ -990,7 +1199,11 @@ extension RDReaderView {
}
private var cellViewKey: Int8 = 0
/// UIView UIViewController
extension UIView {
/// 沿 UIViewController
///
var ss_superViewController: UIViewController? {
var next = self.next
while next != nil {
@ -1002,5 +1215,5 @@ extension UIView {
}
return nil
}
}