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
@@ -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 {}
@@ -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: [
@@ -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),
@@ -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? {
@@ -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 {
@@ -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
}
@@ -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
@@ -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(
@@ -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)
}
@@ -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",
@@ -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
@@ -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
@@ -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())
}
@@ -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,
@@ -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,
@@ -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
@@ -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,
@@ -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):
@@ -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 {
@@ -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":
@@ -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)
@@ -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]) {
@@ -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,
@@ -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))
}
@@ -1,249 +0,0 @@
import Foundation
public struct RDEPUBRowColumnIndex: Codable, Equatable {
public let row: Int
public let startOffset: Int
public let endOffset: Int
public init(row: Int, startOffset: Int, endOffset: Int) {
self.row = row
self.startOffset = startOffset
self.endOffset = endOffset
}
public func contains(_ offset: Int) -> Bool {
offset >= startOffset && offset <= endOffset
}
}
public struct RDEPUBTextIndexTable {
public let chapterStartOffsets: [Int]
public let chapterLengths: [Int]
public let hrefToChapterIndex: [String: Int]
public let hrefToFileIndex: [String: Int]
public let fragmentOffsetsByHref: [String: [String: Int]]
public let fileIndexToHref: [Int: String]
public let fileRowColumnMap: [Int: [RDEPUBRowColumnIndex]]
public init(chapters: [RDEPUBTextChapter]) {
var offsets: [Int] = []
var lengths: [Int] = []
var hrefMap: [String: Int] = [:]
var hrefToFileMap: [String: Int] = [:]
var fragmentMap: [String: [String: Int]] = [:]
var fileIndexMap: [Int: String] = [:]
var rowColumnMap: [Int: [RDEPUBRowColumnIndex]] = [:]
var running = 0
for (index, chapter) in chapters.enumerated() {
hrefMap[chapter.href] = index
hrefToFileMap[chapter.href] = chapter.spineIndex
offsets.append(running)
lengths.append(chapter.attributedContent.length)
running += chapter.attributedContent.length
fragmentMap[chapter.href] = chapter.fragmentOffsets
fileIndexMap[chapter.spineIndex] = chapter.href
rowColumnMap[chapter.spineIndex] = Self.makeRowColumnIndices(for: chapter.attributedContent.string)
}
self.chapterStartOffsets = offsets
self.chapterLengths = lengths
self.hrefToChapterIndex = hrefMap
self.hrefToFileIndex = hrefToFileMap
self.fragmentOffsetsByHref = fragmentMap
self.fileIndexToHref = fileIndexMap
self.fileRowColumnMap = rowColumnMap
}
public func anchor(forAbsoluteIndex index: Int, in chapter: RDEPUBTextChapter) -> RDEPUBTextAnchor {
let normalizedIndex = clampedOffset(index, in: chapter)
let fragmentID = nearestFragmentID(beforeOrAt: normalizedIndex, in: chapter)
let row = row(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
let column = column(forAbsoluteIndex: normalizedIndex, inFileIndex: chapter.spineIndex)
return RDEPUBTextAnchor(
fileIndex: chapter.spineIndex,
row: row,
column: column,
chapterOffset: normalizedIndex,
fragmentID: fragmentID
)
}
public func anchor(for location: RDEPUBLocation) -> RDEPUBTextAnchor? {
if let anchor = location.rangeAnchor?.start {
return anchor
}
guard let chapterIndex = hrefToChapterIndex[location.href],
let fileIndex = hrefToFileIndex[location.href] else { return nil }
let fragments = fragmentOffsetsByHref[location.href] ?? [:]
let chapterOffset: Int
if let fragment = location.fragment, let fragmentOffset = fragments[fragment] {
chapterOffset = fragmentOffset
} else {
let estimatedLength = max(chapterLengths.indices.contains(chapterIndex) ? chapterLengths[chapterIndex] : 0, 1)
let lastOffset = max(estimatedLength - 1, 0)
chapterOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * location.navigationProgression))))
}
return RDEPUBTextAnchor(
fileIndex: fileIndex,
row: row(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
column: column(forAbsoluteIndex: chapterOffset, inFileIndex: fileIndex),
chapterOffset: chapterOffset,
fragmentID: location.fragment
)
}
public func pageNumber(for anchor: RDEPUBTextAnchor, in book: RDEPUBTextBook) -> Int? {
guard let chapter = book.chapters.first(where: { $0.spineIndex == anchor.fileIndex }) else { return nil }
let basePageIndex = chapter.pages.first?.absolutePageIndex ?? 0
let resolvedOffset = absoluteIndex(for: anchor)
return chapter.pages.firstIndex { page in
NSLocationInRange(resolvedOffset, page.contentRange)
}.map { $0 + basePageIndex }
}
public func href(for fileIndex: Int) -> String? {
fileIndexToHref[fileIndex]
}
public func chapterIndex(for href: String) -> Int? {
hrefToChapterIndex[href]
}
public func absoluteIndex(for anchor: RDEPUBTextAnchor) -> Int {
absoluteIndex(
fileIndex: anchor.fileIndex,
row: anchor.row,
column: anchor.column
) ?? anchor.chapterOffset
}
public func absoluteRange(for rangeAnchor: RDEPUBTextRangeAnchor) -> NSRange {
let start = absoluteIndex(for: rangeAnchor.start)
let end = max(start, absoluteIndex(for: rangeAnchor.end))
return NSRange(location: start, length: max(end - start, 0))
}
public func location(
for anchor: RDEPUBTextAnchor,
in chapter: RDEPUBTextChapter,
bookIdentifier: String?
) -> RDEPUBLocation {
let absoluteOffset = absoluteIndex(for: anchor)
let totalLength = max(chapter.attributedContent.length - 1, 1)
let progression = Double(min(max(absoluteOffset, 0), totalLength)) / Double(totalLength)
return RDEPUBLocation(
bookIdentifier: bookIdentifier,
href: chapter.href,
progression: progression,
lastProgression: progression,
fragment: anchor.fragmentID,
rangeAnchor: RDEPUBTextRangeAnchor(start: anchor, end: anchor)
)
}
public func location(
for rangeAnchor: RDEPUBTextRangeAnchor,
in chapter: RDEPUBTextChapter,
bookIdentifier: String?
) -> RDEPUBLocation {
let start = absoluteIndex(for: rangeAnchor.start)
let end = max(start, absoluteIndex(for: rangeAnchor.end))
let totalLength = max(chapter.attributedContent.length - 1, 1)
let clampedStart = min(max(start, 0), totalLength)
let clampedEnd = min(max(end, clampedStart), totalLength)
return RDEPUBLocation(
bookIdentifier: bookIdentifier,
href: chapter.href,
progression: Double(clampedStart) / Double(totalLength),
lastProgression: Double(clampedEnd) / Double(totalLength),
fragment: rangeAnchor.start.fragmentID,
rangeAnchor: rangeAnchor
)
}
public func row(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
if let rowIndex = rows.first(where: { $0.contains(index) })?.row {
return rowIndex
}
return rows.last?.row ?? 0
}
public func column(forAbsoluteIndex index: Int, inFileIndex fileIndex: Int) -> Int {
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return 0 }
if let rowEntry = rows.first(where: { $0.contains(index) }) {
return max(index - rowEntry.startOffset, 0)
}
guard let lastRow = rows.last else { return 0 }
return max(index - lastRow.startOffset, 0)
}
public func absoluteIndex(fileIndex: Int, row: Int, column: Int) -> Int? {
guard let rows = fileRowColumnMap[fileIndex], !rows.isEmpty else { return nil }
let normalizedRow = min(max(row, 0), rows.count - 1)
let rowEntry = rows[normalizedRow]
let maxColumn = max(rowEntry.endOffset - rowEntry.startOffset, 0)
return rowEntry.startOffset + min(max(column, 0), maxColumn)
}
private func clampedOffset(_ index: Int, in chapter: RDEPUBTextChapter) -> Int {
let lastOffset = max(chapter.attributedContent.length - 1, 0)
return min(max(index, 0), lastOffset)
}
private func nearestFragmentID(beforeOrAt offset: Int, in chapter: RDEPUBTextChapter) -> String? {
var bestID: String?
var bestOffset = -1
for (id, fragOffset) in chapter.fragmentOffsets {
if fragOffset <= offset && fragOffset > bestOffset {
bestOffset = fragOffset
bestID = id
}
}
return bestID
}
private static func makeRowColumnIndices(for text: String) -> [RDEPUBRowColumnIndex] {
let nsText = text as NSString
let length = nsText.length
guard length > 0 else {
return [RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: 0)]
}
var rows: [RDEPUBRowColumnIndex] = []
var rowNumber = 0
var lineStart = 0
nsText.enumerateSubstrings(
in: NSRange(location: 0, length: length),
options: [.byLines, .substringNotRequired]
) { _, substringRange, enclosingRange, _ in
let startOffset = enclosingRange.location
let lineLength = max(substringRange.length, 0)
let endOffset = max(startOffset + max(lineLength - 1, 0), startOffset)
rows.append(
RDEPUBRowColumnIndex(
row: rowNumber,
startOffset: startOffset,
endOffset: min(endOffset, max(length - 1, 0))
)
)
rowNumber += 1
lineStart = enclosingRange.location + enclosingRange.length
}
if rows.isEmpty {
rows.append(RDEPUBRowColumnIndex(row: 0, startOffset: 0, endOffset: max(length - 1, 0)))
} else if lineStart == length, text.hasSuffix("\n") {
rows.append(RDEPUBRowColumnIndex(row: rowNumber, startOffset: length, endOffset: length))
}
return rows
}
}
@@ -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
@@ -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
@@ -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 {
@@ -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 {
@@ -1,6 +1,11 @@
// RDEPUBWebView+Search.swift
// RDEPUBWebView
// WebView
import WebKit
extension RDEPUBWebView {
/// WebView
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
guard let webView else {
completion?()
@@ -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
@@ -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 {
@@ -0,0 +1,297 @@
(function() {
if (window.RDReaderBridge) { return; }
window.addEventListener('error', function(event) {
try {
var message = event && (event.message || (event.error && event.error.message)) || 'Unknown JavaScript error';
window.webkit.messageHandlers.{{JAVASCRIPT_ERROR_MESSAGE}}.postMessage(message);
} catch (error) {
}
});
function nodePath(node) {
var path = [];
var current = node;
while (current && current !== document) {
var parent = current.parentNode;
if (!parent) { break; }
var index = Array.prototype.indexOf.call(parent.childNodes, current);
path.unshift(index);
current = parent;
}
return path;
}
function nodeFromPath(path) {
var current = document;
for (var i = 0; i < path.length; i += 1) {
if (!current || !current.childNodes || current.childNodes.length <= path[i]) { return null; }
current = current.childNodes[path[i]];
}
return current;
}
function serializeRange(range) {
return JSON.stringify({
kind: 'dom-range',
startPath: nodePath(range.startContainer),
startOffset: range.startOffset,
endPath: nodePath(range.endContainer),
endOffset: range.endOffset
});
}
function rangeFromInfo(rangeInfo) {
try {
var payload = typeof rangeInfo === 'string' ? JSON.parse(rangeInfo) : rangeInfo;
var startNode = nodeFromPath(payload.startPath || []);
var endNode = nodeFromPath(payload.endPath || []);
if (!startNode || !endNode) { return null; }
var range = document.createRange();
range.setStart(startNode, payload.startOffset || 0);
range.setEnd(endNode, payload.endOffset || 0);
return range;
} catch (error) {
return null;
}
}
var visiblePageIndex = 0;
var configuredPageCount = 1;
var configuredPageStride = 1;
var relayoutTimer = null;
var fixedLayoutDocument = false;
function isFixedLayoutDocument() {
if (fixedLayoutDocument) { return true; }
fixedLayoutDocument = !!document.getElementById('ss-fixed-root') || !!document.querySelector('.ss-fixed-page');
return fixedLayoutDocument;
}
function ensurePaginationStructure() {
if (isFixedLayoutDocument()) {
return null;
}
var viewport = document.getElementById('ss-reader-viewport');
var content = document.getElementById('ss-reader-content');
if (viewport && content) {
return content;
}
viewport = document.createElement('div');
viewport.id = 'ss-reader-viewport';
content = document.createElement('div');
content.id = 'ss-reader-content';
while (document.body.firstChild) {
content.appendChild(document.body.firstChild);
}
viewport.appendChild(content);
document.body.appendChild(viewport);
return content;
}
function paginationContent() {
if (isFixedLayoutDocument()) {
return null;
}
return ensurePaginationStructure();
}
function effectivePageStride() {
return Math.max(1, configuredPageStride || 1);
}
function measuredPageCount() {
if (isFixedLayoutDocument()) {
return 1;
}
var content = paginationContent();
var scrollingElement = document.scrollingElement || document.documentElement || document.body;
var scrollWidth = Math.max(
content ? content.scrollWidth : 0,
content ? content.offsetWidth : 0,
scrollingElement ? scrollingElement.scrollWidth : 0,
document.documentElement ? document.documentElement.scrollWidth : 0,
document.body ? document.body.scrollWidth : 0,
effectivePageStride()
);
return Math.max(1, Math.ceil(scrollWidth / effectivePageStride()));
}
function totalPageCount() {
return Math.max(configuredPageCount, measuredPageCount());
}
function currentPageOffset() {
return Math.round(visiblePageIndex * effectivePageStride());
}
function reportProgression(fragment) {
var pageCount = totalPageCount();
var progression = pageCount <= 1 ? 0 : visiblePageIndex / Math.max(pageCount - 1, 1);
var lastProgression = progression;
window.webkit.messageHandlers.{{PROGRESSION_CHANGED_MESSAGE}}.postMessage({
progression: progression,
lastProgression: lastProgression,
fragment: fragment || null
});
}
function setVisiblePageIndex(pageIndex) {
if (isFixedLayoutDocument()) {
visiblePageIndex = 0;
return;
}
var pageCount = totalPageCount();
var maxPageIndex = Math.max(0, pageCount - 1);
var safePageIndex = Math.max(0, Math.min(maxPageIndex, Math.round(pageIndex || 0)));
visiblePageIndex = safePageIndex;
var content = paginationContent();
if (content) {
content.style.transform = 'translate3d(' + (-currentPageOffset()) + 'px, 0, 0)';
}
}
function scheduleRelayout() {
if (isFixedLayoutDocument()) { return; }
clearTimeout(relayoutTimer);
relayoutTimer = setTimeout(function() {
setVisiblePageIndex(visiblePageIndex);
reportProgression(null);
}, 60);
}
function pageIndexForElement(target) {
if (isFixedLayoutDocument()) { return 0; }
if (!target) { return 0; }
var rect = target.getBoundingClientRect();
var absoluteLeft = Math.max(0, rect.left + currentPageOffset());
return Math.max(0, Math.floor(absoluteLeft / effectivePageStride()));
}
function unwrapHighlights() {
document.querySelectorAll('mark.ss-reader-highlight').forEach(function(mark) {
var parent = mark.parentNode;
if (!parent) { return; }
while (mark.firstChild) {
parent.insertBefore(mark.firstChild, mark);
}
parent.removeChild(mark);
});
}
function setHighlights(items) {
unwrapHighlights();
if (!Array.isArray(items)) { return; }
items.forEach(function(item) {
if (!item || !item.rangeInfo) { return; }
var range = rangeFromInfo(item.rangeInfo);
if (!range || range.collapsed) { return; }
try {
var mark = document.createElement('mark');
var style = item.style || 'highlight';
mark.className = 'ss-reader-highlight ss-reader-highlight-' + style;
mark.style.color = 'inherit';
if (style === 'underline') {
mark.style.background = 'transparent';
mark.style.textDecorationLine = 'underline';
mark.style.textDecorationStyle = 'solid';
mark.style.textDecorationThickness = '2px';
mark.style.textDecorationColor = item.color || '#F8E16C';
} else {
mark.style.background = item.color || '#F8E16C';
}
mark.dataset.highlightId = item.id || '';
var fragment = range.extractContents();
mark.appendChild(fragment);
range.insertNode(mark);
if (mark.parentNode && mark.parentNode.normalize) {
mark.parentNode.normalize();
}
} catch (error) {
}
});
}
function selectionPayload() {
var selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
return null;
}
var range = selection.getRangeAt(0);
var text = selection.toString();
if (!text || !text.trim()) { return null; }
var pageCount = totalPageCount();
var progression = pageCount <= 1 ? 0 : visiblePageIndex / Math.max(pageCount - 1, 1);
var lastProgression = progression;
return {
text: text,
rangeInfo: serializeRange(range),
progression: progression,
lastProgression: lastProgression
};
}
var selectionTimer = null;
document.addEventListener('selectionchange', function() {
clearTimeout(selectionTimer);
selectionTimer = setTimeout(function() {
var payload = selectionPayload();
window.webkit.messageHandlers.{{SELECTION_CHANGED_MESSAGE}}.postMessage(payload);
}, 120);
});
window.addEventListener('resize', function() {
if (isFixedLayoutDocument()) { return; }
setVisiblePageIndex(visiblePageIndex);
reportProgression(null);
}, { passive: true });
window.addEventListener('load', scheduleRelayout, { passive: true });
if (!isFixedLayoutDocument() && document.fonts && document.fonts.ready) {
document.fonts.ready.then(scheduleRelayout).catch(function() {});
}
if (!isFixedLayoutDocument()) {
Array.prototype.forEach.call(document.querySelectorAll('img, iframe, video'), function(node) {
node.addEventListener('load', scheduleRelayout, { passive: true });
node.addEventListener('error', scheduleRelayout, { passive: true });
});
}
document.addEventListener('click', function(event) {
var anchor = event.target.closest ? event.target.closest('a[href]') : null;
if (!anchor) { return; }
var href = anchor.getAttribute('href');
if (!href) { return; }
if (/^(https?:|mailto:|tel:)/i.test(href)) {
event.preventDefault();
window.webkit.messageHandlers.{{EXTERNAL_LINK_MESSAGE}}.postMessage({ url: href });
return;
}
event.preventDefault();
window.webkit.messageHandlers.{{INTERNAL_LINK_MESSAGE}}.postMessage({ href: href });
}, true);
window.RDReaderBridge = {
applyPagination: function(styleText) {
if (isFixedLayoutDocument()) { return; }
var style = document.getElementById('ss-reader-pagination');
if (!style) {
style = document.createElement('style');
style.id = 'ss-reader-pagination';
document.head.appendChild(style);
}
style.textContent = styleText;
ensurePaginationStructure();
},
setPageMetrics: function(pageCount, pageStride) {
if (isFixedLayoutDocument()) { return; }
configuredPageCount = Math.max(1, Math.round(pageCount || 1));
configuredPageStride = Math.max(1, Number(pageStride) || configuredPageStride || 1);
setVisiblePageIndex(visiblePageIndex);
scheduleRelayout();
},
scrollToPage: function(pageIndex) {
if (isFixedLayoutDocument()) { return; }
setVisiblePageIndex(pageIndex);
reportProgression(null);
},
scrollToLocation: function(location, fallbackPageIndex) {
if (location && location.fragment) {
var target = document.getElementById(location.fragment) || document.querySelector('[name="' + location.fragment.replace(/"/g, '\\"') + '"]');
if (target) {
setVisiblePageIndex(pageIndexForElement(target));
reportProgression(location.fragment);
return;
}
}
var pageCount = totalPageCount();
var progression = location && typeof location.progression === 'number' ? location.progression : 0;
var derivedPageIndex = progression > 0 && pageCount > 1
? Math.round(progression * Math.max(pageCount - 1, 0))
: (fallbackPageIndex || 0);
this.scrollToPage(derivedPageIndex);
},
setHighlights: setHighlights,
clearHighlights: unwrapHighlights,
reportProgression: reportProgression,
selectionPayload: selectionPayload
};
})();
@@ -0,0 +1,207 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: {{BACKGROUND}}; }
body { position: relative; }
#ss-fixed-root {
position: absolute;
top: {{INSET_TOP}}px;
right: {{INSET_RIGHT}}px;
bottom: {{INSET_BOTTOM}}px;
left: {{INSET_LEFT}}px;
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: center;
overflow: hidden;
}
.ss-fixed-viewport {
position: relative;
flex: 1 1 0;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.ss-fixed-page {
position: absolute;
top: 50%;
width: 0;
height: 0;
border: 0;
background: transparent;
transform-origin: top left;
}
.ss-fixed-page[data-page-type="single"],
.ss-fixed-page[data-page-type="center"] {
left: 50%;
transform: translate(-50%, -50%);
}
.ss-fixed-page[data-page-type="left"] {
right: 0;
transform: translateY(-50%);
transform-origin: top right;
}
.ss-fixed-page[data-page-type="right"] {
left: 0;
transform: translateY(-50%);
transform-origin: top left;
}
</style>
</head>
<body>
<div id="ss-fixed-root">{{PANES}}</div>
<script>
(function() {
var Fit = { AUTO: 'auto', PAGE: 'page', WIDTH: 'width' };
var safeAreaInsets = { top: {{INSET_TOP}}, right: {{INSET_RIGHT}}, bottom: {{INSET_BOTTOM}}, left: {{INSET_LEFT}} };
var viewportSize = { width: {{VIEWPORT_WIDTH}}, height: {{VIEWPORT_HEIGHT}} };
var fit = '{{FIT_MODE}}';
var pendingFrames = 0;
var readySent = false;
function notifyReady() {
if (readySent) { return; }
readySent = true;
try {
window.webkit.messageHandlers.{{FIXED_LAYOUT_READY_MESSAGE}}.postMessage({ loaded: true });
} catch (error) {
}
}
function parsePageSizeFromViewportMetaTag(iframe) {
try {
var viewport = iframe.contentWindow.document.querySelector('meta[name="viewport"]');
if (!viewport) { return null; }
var regex = /(\w+) *= *([^\s,]+)/g;
var properties = {};
var match;
while ((match = regex.exec(viewport.content))) {
properties[match[1]] = match[2];
}
var width = Number.parseFloat(properties.width);
var height = Number.parseFloat(properties.height);
if (!width || !height) { return null; }
return { width: width, height: height };
} catch (error) {
return null;
}
}
function parsePageSizeFromEmbeddedImage(iframe) {
try {
var img = iframe.contentWindow.document.querySelector('img');
if (!img || !img.naturalWidth || !img.naturalHeight) { return null; }
return { width: img.naturalWidth, height: img.naturalHeight };
} catch (error) {
return null;
}
}
function pageViewportSize(iframe) {
var viewport = iframe.closest('.ss-fixed-viewport');
if (!viewport) { return viewportSize; }
var width = Math.max(1, viewport.clientWidth);
var height = Math.max(1, viewport.clientHeight);
if (width <= 1 || height <= 1) {
return viewportSize;
}
return {
width: width,
height: height
};
}
function usesWidthFit(pageSize, localViewport) {
if (fit === Fit.WIDTH) {
return true;
}
if (fit !== Fit.AUTO) {
return false;
}
var widthRatio = localViewport.width / pageSize.width;
var heightRatio = localViewport.height / pageSize.height;
return widthRatio <= heightRatio;
}
function layoutPage(iframe) {
var pageSize = iframe.__ssPageSize;
if (!pageSize) { return; }
var pageType = iframe.dataset.pageType || 'single';
var localViewport = pageViewportSize(iframe);
iframe.style.width = pageSize.width + 'px';
iframe.style.height = pageSize.height + 'px';
var widthRatio = localViewport.width / pageSize.width;
var heightRatio = localViewport.height / pageSize.height;
var widthFit = usesWidthFit(pageSize, localViewport);
var scale = widthFit ? widthRatio : Math.min(widthRatio, heightRatio);
var scaledWidth = pageSize.width * scale;
var scaledHeight = pageSize.height * scale;
var offsetX, offsetY;
if (pageType === 'left') {
offsetX = localViewport.width - scaledWidth;
} else if (pageType === 'right') {
offsetX = 0;
} else {
offsetX = (localViewport.width - scaledWidth) / 2;
}
if (widthFit && scaledHeight > localViewport.height) {
offsetY = safeAreaInsets.top;
} else {
offsetY = (localViewport.height - scaledHeight) / 2;
offsetY += (safeAreaInsets.top - safeAreaInsets.bottom) / 2;
}
iframe.style.left = offsetX + 'px';
iframe.style.top = offsetY + 'px';
iframe.style.transform = 'scale(' + scale + ')';
}
function preparePage(iframe) {
pendingFrames += 1;
function finalizeLoad() {
pendingFrames = Math.max(0, pendingFrames - 1);
if (pendingFrames === 0) {
notifyReady();
}
}
function onLoad() {
iframe.__ssPageSize =
parsePageSizeFromViewportMetaTag(iframe) ||
parsePageSizeFromEmbeddedImage(iframe) ||
pageViewportSize(iframe);
layoutPage(iframe);
finalizeLoad();
}
iframe.addEventListener('load', function() {
window.setTimeout(onLoad, 100);
});
iframe.addEventListener('error', function() {
iframe.__ssPageSize = pageViewportSize(iframe);
layoutPage(iframe);
finalizeLoad();
});
}
var pages = document.querySelectorAll('.ss-fixed-page');
Array.prototype.forEach.call(pages, preparePage);
if (pages.length === 0) {
notifyReady();
}
window.addEventListener('resize', function() {
Array.prototype.forEach.call(document.querySelectorAll('.ss-fixed-page'), layoutPage);
});
})();
</script>
</body>
</html>