Epub阅读器0.0.1
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBAsset: String {
|
||||
case bridgeScript = "epub-bridge"
|
||||
case fixedLayoutTemplate = "epub-fixed-layout"
|
||||
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .bridgeScript:
|
||||
return "js"
|
||||
case .fixedLayoutTemplate:
|
||||
return "html"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RDEPUBAssetRepository {
|
||||
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 {
|
||||
assertionFailure("Missing EPUB asset: \(asset.rawValue).\(asset.fileExtension)")
|
||||
return ""
|
||||
}
|
||||
|
||||
for (token, value) in replacements {
|
||||
content = content.replacingOccurrences(of: "{{\(token)}}", with: value)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
private static var resourceBundle: Bundle {
|
||||
if let bundle = resolvedBundle {
|
||||
return bundle
|
||||
}
|
||||
return Bundle(for: RDEPUBAssetBundleToken.self)
|
||||
}
|
||||
|
||||
private static var resolvedBundle: Bundle? = {
|
||||
let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles
|
||||
for hostBundle in hostBundles {
|
||||
if let url = hostBundle.url(forResource: "RDReaderViewAssets", withExtension: "bundle"),
|
||||
let bundle = Bundle(url: url) {
|
||||
return bundle
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
}
|
||||
|
||||
private final class RDEPUBAssetBundleToken {}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBFixedLayoutTemplate {
|
||||
static func html(for request: RDEPUBFixedRenderRequest, publication: RDEPUBPublication) -> String {
|
||||
let panes = request.spread.resources.enumerated().compactMap { index, resource -> String? in
|
||||
guard let url = publication.resourceResolver.resourceURL(forRelativePath: resource.href)?.absoluteString else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let pageType: String
|
||||
if request.spread.resources.count == 1 {
|
||||
pageType = "single"
|
||||
} else if request.spread.resources.count == 2 {
|
||||
pageType = index == 0 ? "left" : "right"
|
||||
} else {
|
||||
pageType = "center"
|
||||
}
|
||||
|
||||
return """
|
||||
<div class=\"ss-fixed-viewport\" data-page-type=\"\(pageType)\">
|
||||
<iframe class=\"ss-fixed-page\" data-page-type=\"\(pageType)\" scrolling=\"no\" src=\"\(url)\"></iframe>
|
||||
</div>
|
||||
"""
|
||||
}.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)))
|
||||
let insetTop = Int(request.contentInset.top.rounded(.down))
|
||||
let insetRight = Int(request.contentInset.right.rounded(.down))
|
||||
let insetBottom = Int(request.contentInset.bottom.rounded(.down))
|
||||
let insetLeft = Int(request.contentInset.left.rounded(.down))
|
||||
return RDEPUBAssetRepository.string(
|
||||
for: .fixedLayoutTemplate,
|
||||
replacements: [
|
||||
"BACKGROUND": background,
|
||||
"INSET_TOP": String(insetTop),
|
||||
"INSET_RIGHT": String(insetRight),
|
||||
"INSET_BOTTOM": String(insetBottom),
|
||||
"INSET_LEFT": String(insetLeft),
|
||||
"VIEWPORT_WIDTH": String(viewportWidth),
|
||||
"VIEWPORT_HEIGHT": String(viewportHeight),
|
||||
"PANES": panes,
|
||||
"FIT_MODE": request.fit.rawValue,
|
||||
"FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
|
||||
case progressionChanged = "ssReaderProgressionChanged"
|
||||
case selectionChanged = "ssReaderSelectionChanged"
|
||||
case internalLink = "ssReaderInternalLink"
|
||||
case externalLink = "ssReaderExternalLink"
|
||||
case javaScriptError = "ssReaderJSError"
|
||||
case fixedLayoutReady = "ssReaderFixedLayoutReady"
|
||||
}
|
||||
|
||||
enum RDEPUBJavaScriptBridge {
|
||||
static var messageNames: [String] {
|
||||
RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue)
|
||||
}
|
||||
|
||||
static var userScript: String {
|
||||
RDEPUBAssetRepository.string(
|
||||
for: .bridgeScript,
|
||||
replacements: [
|
||||
"PROGRESSION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue,
|
||||
"SELECTION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue,
|
||||
"INTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.internalLink.rawValue,
|
||||
"EXTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.externalLink.rawValue,
|
||||
"JAVASCRIPT_ERROR_MESSAGE": RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue,
|
||||
"FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String {
|
||||
let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation))
|
||||
let pageStride = max(1, request.presentation.viewportSize.width)
|
||||
let searchScript = applySearchScript(for: request.searchPresentation)
|
||||
|
||||
return """
|
||||
(function() {
|
||||
if (!window.RDReaderBridge) { return false; }
|
||||
window.RDReaderBridge.applyPagination(`\(style)`);
|
||||
window.RDReaderBridge.setPageMetrics(\(request.totalPagesInChapter), \(pageStride));
|
||||
window.RDReaderBridge.clearHighlights();
|
||||
window.RDReaderBridge.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]")));
|
||||
if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) {
|
||||
window.RDReaderBridge.scrollToLocation(\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")), \(request.pageIndex));
|
||||
} else {
|
||||
window.RDReaderBridge.scrollToPage(\(request.pageIndex));
|
||||
}
|
||||
window.RDReaderBridge.reportProgression();
|
||||
return true;
|
||||
})();
|
||||
\(searchScript)
|
||||
"""
|
||||
}
|
||||
|
||||
static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String {
|
||||
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
|
||||
return """
|
||||
(function() {
|
||||
var payload = \(payload);
|
||||
function normalizeHref(rawHref) {
|
||||
if (!rawHref) { return ''; }
|
||||
var value = String(rawHref);
|
||||
value = value.replace(/^.*?:\\/\\/[^/]+\\//, '');
|
||||
value = value.split('#')[0];
|
||||
value = value.replace(/^\\/+/, '');
|
||||
try {
|
||||
value = decodeURIComponent(value);
|
||||
} catch (error) {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ensureStyle(doc) {
|
||||
if (!doc || !doc.head || doc.getElementById('ss-reader-search-style')) { return; }
|
||||
var style = doc.createElement('style');
|
||||
style.id = 'ss-reader-search-style';
|
||||
style.textContent = '.ss-reader-search-mark{background:rgba(248,225,108,0.55);color:inherit;border-radius:2px;padding:0;}.ss-reader-search-mark-active{background:rgba(255,159,67,0.75);color:inherit;border-radius:2px;padding:0;}';
|
||||
doc.head.appendChild(style);
|
||||
}
|
||||
|
||||
function clearMarks(doc) {
|
||||
if (!doc || !doc.body) { return; }
|
||||
var marks = doc.querySelectorAll('span.ss-reader-search-mark, span.ss-reader-search-mark-active');
|
||||
marks.forEach(function(mark) {
|
||||
var textNode = doc.createTextNode(mark.textContent || '');
|
||||
if (mark.parentNode) {
|
||||
mark.parentNode.replaceChild(textNode, mark);
|
||||
}
|
||||
});
|
||||
doc.body.normalize();
|
||||
}
|
||||
|
||||
function searchableDocuments(rootDoc) {
|
||||
var items = [];
|
||||
if (!rootDoc) { return items; }
|
||||
items.push({ doc: rootDoc, href: normalizeHref(rootDoc.location && rootDoc.location.href) });
|
||||
var frames = rootDoc.querySelectorAll ? rootDoc.querySelectorAll('iframe') : [];
|
||||
frames.forEach(function(frame) {
|
||||
try {
|
||||
if (frame.contentDocument) {
|
||||
items.push({
|
||||
doc: frame.contentDocument,
|
||||
href: normalizeHref(frame.contentWindow && frame.contentWindow.location && frame.contentWindow.location.href)
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function textNodes(doc) {
|
||||
if (!doc || !doc.body) { return []; }
|
||||
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: function(node) {
|
||||
if (!node || !node.nodeValue || !node.nodeValue.trim()) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
var parent = node.parentNode;
|
||||
if (!parent || !parent.nodeName) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
var name = parent.nodeName.toLowerCase();
|
||||
if (['script', 'style', 'noscript'].indexOf(name) >= 0) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
if (parent.classList && (parent.classList.contains('ss-reader-search-mark') || parent.classList.contains('ss-reader-search-mark-active'))) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
});
|
||||
|
||||
var nodes = [];
|
||||
while (walker.nextNode()) {
|
||||
nodes.push(walker.currentNode);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function applyToDocument(doc, resourceState, keyword) {
|
||||
clearMarks(doc);
|
||||
ensureStyle(doc);
|
||||
if (!doc || !doc.body || !resourceState || !keyword || resourceState.matchCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var lowerKeyword = String(keyword).toLowerCase();
|
||||
var activeIndex = typeof resourceState.activeLocalMatchIndex === 'number' ? resourceState.activeLocalMatchIndex : null;
|
||||
var currentLocalIndex = 0;
|
||||
var activeElement = null;
|
||||
|
||||
textNodes(doc).forEach(function(node) {
|
||||
var source = node.nodeValue || '';
|
||||
var lowerSource = source.toLowerCase();
|
||||
var cursor = 0;
|
||||
var replaced = false;
|
||||
var fragment = doc.createDocumentFragment();
|
||||
|
||||
while (cursor < source.length) {
|
||||
var foundIndex = lowerSource.indexOf(lowerKeyword, cursor);
|
||||
if (foundIndex < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
replaced = true;
|
||||
if (foundIndex > cursor) {
|
||||
fragment.appendChild(doc.createTextNode(source.slice(cursor, foundIndex)));
|
||||
}
|
||||
|
||||
var mark = doc.createElement('span');
|
||||
mark.className = currentLocalIndex === activeIndex ? 'ss-reader-search-mark-active' : 'ss-reader-search-mark';
|
||||
mark.textContent = source.slice(foundIndex, foundIndex + keyword.length);
|
||||
fragment.appendChild(mark);
|
||||
if (currentLocalIndex === activeIndex) {
|
||||
activeElement = mark;
|
||||
}
|
||||
|
||||
currentLocalIndex += 1;
|
||||
cursor = foundIndex + keyword.length;
|
||||
}
|
||||
|
||||
if (!replaced) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cursor < source.length) {
|
||||
fragment.appendChild(doc.createTextNode(source.slice(cursor)));
|
||||
}
|
||||
if (node.parentNode) {
|
||||
node.parentNode.replaceChild(fragment, node);
|
||||
}
|
||||
});
|
||||
|
||||
if (activeElement && activeElement.scrollIntoView) {
|
||||
activeElement.scrollIntoView({ block: 'center', inline: 'center', behavior: 'auto' });
|
||||
}
|
||||
}
|
||||
|
||||
var docs = searchableDocuments(document);
|
||||
var states = new Map();
|
||||
if (payload && Array.isArray(payload.resources)) {
|
||||
payload.resources.forEach(function(resource) {
|
||||
states.set(normalizeHref(resource.href), resource);
|
||||
});
|
||||
}
|
||||
|
||||
docs.forEach(function(item) {
|
||||
applyToDocument(item.doc, states.get(item.href), payload && payload.keyword ? payload.keyword : '');
|
||||
});
|
||||
return true;
|
||||
})();
|
||||
"""
|
||||
}
|
||||
|
||||
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
|
||||
string
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "`", with: "\\`")
|
||||
}
|
||||
|
||||
private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] {
|
||||
highlights.compactMap { highlight in
|
||||
guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil }
|
||||
return [
|
||||
"id": highlight.id,
|
||||
"rangeInfo": rangeInfo,
|
||||
"color": highlight.color,
|
||||
"style": highlight.style.rawValue
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? {
|
||||
guard let location else { return nil }
|
||||
return [
|
||||
"progression": location.progression,
|
||||
"lastProgression": location.lastProgression ?? location.progression,
|
||||
"fragment": location.fragment as Any
|
||||
]
|
||||
}
|
||||
|
||||
private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? {
|
||||
guard let presentation else { return nil }
|
||||
return [
|
||||
"keyword": presentation.keyword,
|
||||
"resources": presentation.resources.map { resource in
|
||||
[
|
||||
"href": resource.href,
|
||||
"matchCount": resource.matchCount,
|
||||
"activeLocalMatchIndex": resource.activeLocalMatchIndex as Any
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
private static func jsonString(from object: Any?, fallback: String) -> String {
|
||||
guard let object else { return fallback }
|
||||
guard JSONSerialization.isValidJSONObject(object),
|
||||
let data = try? JSONSerialization.data(withJSONObject: object, options: []),
|
||||
let string = String(data: data, encoding: .utf8) else {
|
||||
return fallback
|
||||
}
|
||||
return string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBLayout: String, Codable {
|
||||
case reflowable
|
||||
case fixed
|
||||
}
|
||||
|
||||
public enum RDEPUBReadingProfile: String, Codable {
|
||||
case webInteractive
|
||||
case webFixedLayout
|
||||
case textReflowable
|
||||
}
|
||||
|
||||
public enum RDEPUBReadingProgression: String, Codable {
|
||||
case ltr
|
||||
case rtl
|
||||
case auto
|
||||
}
|
||||
|
||||
public enum RDEPUBPageSpread: String, Codable {
|
||||
case left
|
||||
case right
|
||||
case center
|
||||
}
|
||||
|
||||
public struct RDEPUBMetadata: Codable, Equatable {
|
||||
public var identifier: String?
|
||||
public var title: String
|
||||
public var author: String?
|
||||
public var language: String?
|
||||
public var version: String?
|
||||
public var layout: RDEPUBLayout
|
||||
public var spread: String?
|
||||
public var readingProgression: RDEPUBReadingProgression
|
||||
|
||||
public init(
|
||||
identifier: String? = nil,
|
||||
title: String = "",
|
||||
author: String? = nil,
|
||||
language: String? = nil,
|
||||
version: String? = nil,
|
||||
layout: RDEPUBLayout = .reflowable,
|
||||
spread: String? = nil,
|
||||
readingProgression: RDEPUBReadingProgression = .auto
|
||||
) {
|
||||
self.identifier = identifier
|
||||
self.title = title
|
||||
self.author = author
|
||||
self.language = language
|
||||
self.version = version
|
||||
self.layout = layout
|
||||
self.spread = spread
|
||||
self.readingProgression = readingProgression
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBManifestItem: Codable, Equatable {
|
||||
public var id: String
|
||||
public var href: String
|
||||
public var mediaType: String
|
||||
public var properties: [String]
|
||||
public var fallback: String?
|
||||
public var mediaOverlay: String?
|
||||
public var title: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
href: String,
|
||||
mediaType: String,
|
||||
properties: [String] = [],
|
||||
fallback: String? = nil,
|
||||
mediaOverlay: String? = nil,
|
||||
title: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.href = href
|
||||
self.mediaType = mediaType
|
||||
self.properties = properties
|
||||
self.fallback = fallback
|
||||
self.mediaOverlay = mediaOverlay
|
||||
self.title = title
|
||||
}
|
||||
|
||||
public var isNavigationDocument: Bool {
|
||||
properties.contains("nav")
|
||||
}
|
||||
|
||||
public var isNCX: Bool {
|
||||
mediaType == "application/x-dtbncx+xml"
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBSpineItem: Codable, Equatable {
|
||||
public var idref: String
|
||||
public var href: String
|
||||
public var mediaType: String
|
||||
public var title: String
|
||||
public var linear: Bool
|
||||
public var properties: [String]
|
||||
public var pageSpread: RDEPUBPageSpread?
|
||||
public var layout: RDEPUBLayout?
|
||||
|
||||
public init(
|
||||
idref: String,
|
||||
href: String,
|
||||
mediaType: String,
|
||||
title: String,
|
||||
linear: Bool = true,
|
||||
properties: [String] = [],
|
||||
pageSpread: RDEPUBPageSpread? = nil,
|
||||
layout: RDEPUBLayout? = nil
|
||||
) {
|
||||
self.idref = idref
|
||||
self.href = href
|
||||
self.mediaType = mediaType
|
||||
self.title = title
|
||||
self.linear = linear
|
||||
self.properties = properties
|
||||
self.pageSpread = pageSpread
|
||||
self.layout = layout
|
||||
}
|
||||
}
|
||||
|
||||
public struct EPUBTableOfContentsItem: Codable, Equatable {
|
||||
public var title: String
|
||||
public var href: String
|
||||
public var children: [EPUBTableOfContentsItem]
|
||||
|
||||
public init(title: String, href: String, children: [EPUBTableOfContentsItem] = []) {
|
||||
self.title = title
|
||||
self.href = href
|
||||
self.children = children
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBParserError: LocalizedError {
|
||||
case archiveOpenFailed(URL)
|
||||
case missingContainerXML
|
||||
case missingRootFile
|
||||
case invalidRootFilePath(String)
|
||||
case invalidXML(URL)
|
||||
case missingManifestItem(idref: String)
|
||||
case emptySpine
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .archiveOpenFailed(let url):
|
||||
return "无法打开 EPUB 压缩包: \(url.lastPathComponent)"
|
||||
case .missingContainerXML:
|
||||
return "EPUB 缺少 META-INF/container.xml"
|
||||
case .missingRootFile:
|
||||
return "container.xml 中未找到 rootfile"
|
||||
case .invalidRootFilePath(let path):
|
||||
return "OPF 路径无效: \(path)"
|
||||
case .invalidXML(let url):
|
||||
return "XML 解析失败: \(url.lastPathComponent)"
|
||||
case .missingManifestItem(let idref):
|
||||
return "spine itemref 找不到对应 manifest 项: \(idref)"
|
||||
case .emptySpine:
|
||||
return "OPF spine 为空"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import UIKit
|
||||
|
||||
public struct RDEPUBNavigatorLayoutContext: Equatable {
|
||||
public var containerSize: CGSize
|
||||
public var pagesPerScreen: Int
|
||||
public var safeAreaInsets: UIEdgeInsets
|
||||
public var userInterfaceIdiom: UIUserInterfaceIdiom
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
|
||||
public init(
|
||||
containerSize: CGSize,
|
||||
pagesPerScreen: Int = 1,
|
||||
safeAreaInsets: UIEdgeInsets = .zero,
|
||||
userInterfaceIdiom: UIUserInterfaceIdiom = .phone,
|
||||
reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16)
|
||||
) {
|
||||
self.containerSize = containerSize
|
||||
self.pagesPerScreen = max(1, pagesPerScreen)
|
||||
self.safeAreaInsets = safeAreaInsets
|
||||
self.userInterfaceIdiom = userInterfaceIdiom
|
||||
self.reflowableContentInsets = reflowableContentInsets
|
||||
}
|
||||
|
||||
public var viewportSize: CGSize {
|
||||
let width: CGFloat
|
||||
if pagesPerScreen > 1 {
|
||||
width = containerSize.width / CGFloat(pagesPerScreen)
|
||||
} else {
|
||||
width = containerSize.width
|
||||
}
|
||||
return CGSize(width: width, height: containerSize.height)
|
||||
}
|
||||
|
||||
public var fixedContentInset: UIEdgeInsets {
|
||||
var insets = safeAreaInsets
|
||||
if userInterfaceIdiom != .phone {
|
||||
insets = .zero
|
||||
}
|
||||
|
||||
let horizontalInsets = max(insets.left, insets.right)
|
||||
insets.left = horizontalInsets
|
||||
insets.right = horizontalInsets
|
||||
return insets
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBNavigatorState: String, Codable {
|
||||
case initializing
|
||||
case loading
|
||||
case idle
|
||||
case jumping
|
||||
case moving
|
||||
case repaginating
|
||||
|
||||
public var isStableForSnapshotApplication: Bool {
|
||||
self == .idle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
public final class RDEPUBPaginator: NSObject {
|
||||
private var parser: RDEPUBParser?
|
||||
private weak var hostingView: UIView?
|
||||
private var presentation = RDEPUBPresentationStyle(
|
||||
viewportSize: .zero,
|
||||
contentInsets: .zero,
|
||||
fontSize: 16,
|
||||
lineHeightMultiple: 1.5
|
||||
)
|
||||
private var completion: (([Int]) -> Void)?
|
||||
private var singlePageCountCompletion: ((Int) -> Void)?
|
||||
private var pageCounts: [Int] = []
|
||||
private var measurementIndices: [Int] = []
|
||||
private var currentMeasurementOffset = 0
|
||||
private var pendingMeasurementValue = 1
|
||||
private var measurementPass = 0
|
||||
private var activeSessionID = UUID()
|
||||
private let debugScope = "PaginatorWebView"
|
||||
|
||||
private lazy var webView: WKWebView = {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .nonPersistent()
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView.navigationDelegate = self
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
webView.scrollView.isScrollEnabled = false
|
||||
webView.scrollView.bounces = false
|
||||
webView.scrollView.showsHorizontalScrollIndicator = false
|
||||
webView.scrollView.showsVerticalScrollIndicator = false
|
||||
webView.isUserInteractionEnabled = false
|
||||
if #available(iOS 16.4, *) {
|
||||
webView.isInspectable = true
|
||||
} else {
|
||||
// Fallback on earlier versions
|
||||
}
|
||||
RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
||||
return webView
|
||||
}()
|
||||
|
||||
public override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
deinit {
|
||||
cleanupMeasurementState()
|
||||
webView.navigationDelegate = nil
|
||||
webView.stopLoading()
|
||||
webView.removeFromSuperview()
|
||||
}
|
||||
|
||||
public func calculate(
|
||||
parser: RDEPUBParser,
|
||||
hostingView: UIView,
|
||||
presentation: RDEPUBPresentationStyle,
|
||||
completion: @escaping ([Int]) -> Void
|
||||
) {
|
||||
self.parser = parser
|
||||
self.hostingView = hostingView
|
||||
self.presentation = presentation
|
||||
self.completion = completion
|
||||
self.singlePageCountCompletion = nil
|
||||
self.pageCounts = Array(repeating: 1, count: parser.spine.count)
|
||||
self.measurementIndices = Array(parser.spine.indices)
|
||||
self.currentMeasurementOffset = 0
|
||||
self.activeSessionID = UUID()
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "calculate session=\(activeSessionID) spineCount=\(parser.spine.count)")
|
||||
|
||||
guard parser.metadata.layout != .fixed else {
|
||||
completion(self.pageCounts)
|
||||
return
|
||||
}
|
||||
|
||||
webView.navigationDelegate = self
|
||||
if webView.superview == nil {
|
||||
hostingView.addSubview(webView)
|
||||
}
|
||||
webView.frame = CGRect(origin: .zero, size: presentation.viewportSize)
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
public func calculate(
|
||||
parser: RDEPUBParser,
|
||||
hostingView: UIView,
|
||||
viewportSize: CGSize,
|
||||
padding: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
completion: @escaping ([Int]) -> Void
|
||||
) {
|
||||
calculate(
|
||||
parser: parser,
|
||||
hostingView: hostingView,
|
||||
presentation: RDEPUBPresentationStyle(
|
||||
viewportSize: viewportSize,
|
||||
contentInsets: padding,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple
|
||||
),
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
public func calculateSingleSpinePageCount(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
hostingView: UIView,
|
||||
presentation: RDEPUBPresentationStyle,
|
||||
completion: @escaping (Int) -> Void
|
||||
) {
|
||||
self.parser = parser
|
||||
self.hostingView = hostingView
|
||||
self.presentation = presentation
|
||||
self.completion = nil
|
||||
self.singlePageCountCompletion = completion
|
||||
self.pageCounts = Array(repeating: 1, count: parser.spine.count)
|
||||
self.measurementIndices = parser.spine.indices.contains(spineIndex) ? [spineIndex] : []
|
||||
self.currentMeasurementOffset = 0
|
||||
self.activeSessionID = UUID()
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "calculateSingle session=\(activeSessionID) spineIndex=\(spineIndex)")
|
||||
|
||||
guard parser.metadata.layout != .fixed, !measurementIndices.isEmpty else {
|
||||
completion(1)
|
||||
return
|
||||
}
|
||||
|
||||
webView.navigationDelegate = self
|
||||
if webView.superview == nil {
|
||||
hostingView.addSubview(webView)
|
||||
}
|
||||
webView.frame = CGRect(origin: .zero, size: presentation.viewportSize)
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
public func calculateSingleSpinePageCount(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
hostingView: UIView,
|
||||
viewportSize: CGSize,
|
||||
padding: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
completion: @escaping (Int) -> Void
|
||||
) {
|
||||
calculateSingleSpinePageCount(
|
||||
parser: parser,
|
||||
spineIndex: spineIndex,
|
||||
hostingView: hostingView,
|
||||
presentation: RDEPUBPresentationStyle(
|
||||
viewportSize: viewportSize,
|
||||
contentInsets: padding,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple
|
||||
),
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
public static func injectPaginationCSS(
|
||||
into html: String,
|
||||
viewportSize: CGSize,
|
||||
padding: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
themeBackgroundColor: String? = nil,
|
||||
themeTextColor: String? = nil
|
||||
) -> String {
|
||||
RDEPUBStyleSheetBuilder.injectPaginationCSS(
|
||||
into: html,
|
||||
presentation: RDEPUBPresentationStyle(
|
||||
viewportSize: viewportSize,
|
||||
contentInsets: padding,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
themeBackgroundColor: themeBackgroundColor,
|
||||
themeTextColor: themeTextColor
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func measureNextSpineItem() {
|
||||
guard let parser else {
|
||||
finishIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
guard currentMeasurementOffset < measurementIndices.count else {
|
||||
finishIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
let currentSpineIndex = measurementIndices[currentMeasurementOffset]
|
||||
|
||||
let item = parser.spine[currentSpineIndex]
|
||||
guard isRenderablePage(item: item),
|
||||
let opfDirectoryURL = parser.opfDirectoryURL else {
|
||||
pageCounts[currentSpineIndex] = 1
|
||||
currentMeasurementOffset += 1
|
||||
measureNextSpineItem()
|
||||
return
|
||||
}
|
||||
|
||||
let fileURL = opfDirectoryURL.appendingPathComponent(item.href)
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
pageCounts[currentSpineIndex] = 1
|
||||
currentMeasurementOffset += 1
|
||||
measureNextSpineItem()
|
||||
return
|
||||
}
|
||||
|
||||
pendingMeasurementValue = 1
|
||||
measurementPass = 0
|
||||
let readAccessURL = parser.extractionRootURL ?? opfDirectoryURL
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "measure-load", url: fileURL)
|
||||
webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL)
|
||||
}
|
||||
|
||||
private func currentSpineIndexForMeasurement() -> Int? {
|
||||
measurementIndices[safe: currentMeasurementOffset]
|
||||
}
|
||||
|
||||
private func finishIfNeeded() {
|
||||
if let singlePageCountCompletion {
|
||||
let measuredSpineIndex = measurementIndices.first ?? 0
|
||||
singlePageCountCompletion(max(1, pageCounts[safe: measuredSpineIndex] ?? 1))
|
||||
self.singlePageCountCompletion = nil
|
||||
cleanupMeasurementState()
|
||||
return
|
||||
}
|
||||
guard let completion else { return }
|
||||
completion(pageCounts)
|
||||
self.completion = nil
|
||||
cleanupMeasurementState()
|
||||
}
|
||||
|
||||
private func cleanupMeasurementState() {
|
||||
parser = nil
|
||||
hostingView = nil
|
||||
completion = nil
|
||||
singlePageCountCompletion = nil
|
||||
measurementIndices = []
|
||||
currentMeasurementOffset = 0
|
||||
pendingMeasurementValue = 1
|
||||
measurementPass = 0
|
||||
activeSessionID = UUID()
|
||||
webView.stopLoading()
|
||||
webView.navigationDelegate = nil
|
||||
webView.removeFromSuperview()
|
||||
}
|
||||
|
||||
private func isRenderablePage(item: RDEPUBSpineItem) -> Bool {
|
||||
let mediaType = item.mediaType.lowercased()
|
||||
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
|
||||
}
|
||||
|
||||
private func scheduleMeasurementPass() {
|
||||
let sessionID = activeSessionID
|
||||
let delays: [TimeInterval] = [0.0, 0.08, 0.18]
|
||||
guard measurementPass < delays.count else {
|
||||
guard sessionID == activeSessionID,
|
||||
let currentSpineIndex = currentSpineIndexForMeasurement() else {
|
||||
return
|
||||
}
|
||||
pageCounts[currentSpineIndex] = max(1, pendingMeasurementValue)
|
||||
currentMeasurementOffset += 1
|
||||
measureNextSpineItem()
|
||||
return
|
||||
}
|
||||
|
||||
let delay = delays[measurementPass]
|
||||
measurementPass += 1
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self, self.activeSessionID == sessionID else { return }
|
||||
self.measureCurrentDocument(sessionID: sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
private func measureCurrentDocument(sessionID: UUID) {
|
||||
let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation)
|
||||
RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)")
|
||||
|
||||
webView.evaluateJavaScript(script) { [weak self] value, _ in
|
||||
guard let self else { return }
|
||||
guard self.activeSessionID == sessionID,
|
||||
self.currentSpineIndexForMeasurement() != nil else {
|
||||
return
|
||||
}
|
||||
if let number = value as? NSNumber {
|
||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue)
|
||||
} else if let intValue = value as? Int {
|
||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue)
|
||||
}
|
||||
RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)")
|
||||
self.scheduleMeasurementPass()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension RDEPUBPaginator: WKNavigationDelegate {
|
||||
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url)
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
|
||||
guard currentSpineIndexForMeasurement() != nil else { return }
|
||||
scheduleMeasurementPass()
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error)
|
||||
let currentSpineIndex = measurementIndices[safe: currentMeasurementOffset] ?? 0
|
||||
pageCounts[currentSpineIndex] = 1
|
||||
currentMeasurementOffset += 1
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
|
||||
let currentSpineIndex = measurementIndices[safe: currentMeasurementOffset] ?? 0
|
||||
pageCounts[currentSpineIndex] = 1
|
||||
currentMeasurementOffset += 1
|
||||
measureNextSpineItem()
|
||||
}
|
||||
|
||||
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import ZIPFoundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
func parseContainerRootFile(at containerURL: URL) throws -> String {
|
||||
guard let parser = XMLParser(contentsOf: containerURL) else {
|
||||
throw RDEPUBParserError.invalidXML(containerURL)
|
||||
}
|
||||
|
||||
let delegate = ContainerXMLParserDelegate()
|
||||
parser.shouldProcessNamespaces = false
|
||||
parser.delegate = delegate
|
||||
|
||||
guard parser.parse() else {
|
||||
throw parser.parserError ?? RDEPUBParserError.invalidXML(containerURL)
|
||||
}
|
||||
|
||||
guard let rootFilePath = delegate.rootFilePath, !rootFilePath.isEmpty else {
|
||||
throw RDEPUBParserError.missingRootFile
|
||||
}
|
||||
|
||||
return rootFilePath
|
||||
}
|
||||
|
||||
func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let extractionURL = temporaryExtractionDirectory(for: epubURL)
|
||||
|
||||
if fileManager.fileExists(atPath: extractionURL.path) {
|
||||
return extractionURL
|
||||
}
|
||||
|
||||
guard let archive = Archive(url: epubURL, accessMode: .read) else {
|
||||
throw RDEPUBParserError.archiveOpenFailed(epubURL)
|
||||
}
|
||||
|
||||
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
|
||||
|
||||
for entry in archive {
|
||||
let destinationURL = extractionURL.appendingPathComponent(entry.path)
|
||||
switch entry.type {
|
||||
case .directory:
|
||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||
case .file:
|
||||
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
_ = try archive.extract(entry, to: destinationURL)
|
||||
case .symlink:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return extractionURL
|
||||
}
|
||||
|
||||
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
||||
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
||||
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path)
|
||||
let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0"
|
||||
let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
|
||||
let slug = epubURL.deletingPathExtension().lastPathComponent
|
||||
.replacingOccurrences(of: " ", with: "-")
|
||||
let signature = String(format: "%.0f", modifiedAt)
|
||||
return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
extractionRootURL = nil
|
||||
opfURL = nil
|
||||
resetPublicationState()
|
||||
}
|
||||
|
||||
func resetPublicationState() {
|
||||
metadata = RDEPUBMetadata()
|
||||
manifest = [:]
|
||||
spine = []
|
||||
tableOfContents = []
|
||||
}
|
||||
}
|
||||
|
||||
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
|
||||
private(set) var rootFilePath: String?
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didStartElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?,
|
||||
attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
guard name == "rootfile", rootFilePath == nil else {
|
||||
return
|
||||
}
|
||||
rootFilePath = attributeDict["full-path"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] {
|
||||
let opfDirectoryURL = opfURL.deletingLastPathComponent()
|
||||
let publicationLayout = packageDocument.metadata.layout
|
||||
|
||||
let spineItems = try packageDocument.spineReferences.map { reference in
|
||||
guard let manifestItem = packageDocument.manifestByID[reference.idref] else {
|
||||
throw RDEPUBParserError.missingManifestItem(idref: reference.idref)
|
||||
}
|
||||
|
||||
let normalizedHref = normalize(href: manifestItem.href, relativeTo: opfDirectoryURL)
|
||||
let title = manifestItem.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallbackTitle = normalizedHref
|
||||
.split(separator: "/")
|
||||
.last
|
||||
.map(String.init)
|
||||
.map { $0.replacingOccurrences(of: ".xhtml", with: "") }
|
||||
.map { $0.replacingOccurrences(of: ".html", with: "") }
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
?? reference.idref
|
||||
|
||||
let effectiveLayout: RDEPUBLayout?
|
||||
if reference.properties.contains("rendition:layout-pre-paginated") || manifestItem.properties.contains("rendition:layout-pre-paginated") {
|
||||
effectiveLayout = .fixed
|
||||
} else if reference.properties.contains("rendition:layout-reflowable") || manifestItem.properties.contains("rendition:layout-reflowable") {
|
||||
effectiveLayout = .reflowable
|
||||
} else {
|
||||
effectiveLayout = publicationLayout == .fixed ? .fixed : nil
|
||||
}
|
||||
|
||||
return RDEPUBSpineItem(
|
||||
idref: reference.idref,
|
||||
href: normalizedHref,
|
||||
mediaType: manifestItem.mediaType,
|
||||
title: title?.isEmpty == false ? title! : fallbackTitle,
|
||||
linear: reference.linear,
|
||||
properties: reference.properties,
|
||||
pageSpread: reference.pageSpread,
|
||||
layout: effectiveLayout
|
||||
)
|
||||
}
|
||||
|
||||
guard !spineItems.isEmpty else {
|
||||
throw RDEPUBParserError.emptySpine
|
||||
}
|
||||
|
||||
return spineItems
|
||||
}
|
||||
}
|
||||
|
||||
struct OPFPackageDocument {
|
||||
var metadata: RDEPUBMetadata
|
||||
var manifest: [RDEPUBManifestItem]
|
||||
var manifestByID: [String: RDEPUBManifestItem]
|
||||
var spineReferences: [OPFSpineReference]
|
||||
var ncxItem: RDEPUBManifestItem?
|
||||
var navigationItem: RDEPUBManifestItem?
|
||||
}
|
||||
|
||||
struct OPFSpineReference {
|
||||
var idref: String
|
||||
var linear: Bool
|
||||
var properties: [String]
|
||||
var pageSpread: RDEPUBPageSpread?
|
||||
}
|
||||
|
||||
final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
||||
private var metadata = RDEPUBMetadata()
|
||||
private var manifestItems: [RDEPUBManifestItem] = []
|
||||
private var spineReferences: [OPFSpineReference] = []
|
||||
private let xmlContext = XMLParserContext()
|
||||
private var uniqueIdentifierID: String?
|
||||
private var currentIdentifierElementID: String?
|
||||
private var currentMetaProperty: String?
|
||||
private var identifierByID: [String: String] = [:]
|
||||
private var manifestTitleByID: [String: String] = [:]
|
||||
private var currentMetaRefinesID: String?
|
||||
private var ncxID: String?
|
||||
|
||||
func packageDocument() -> OPFPackageDocument {
|
||||
if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first {
|
||||
metadata.identifier = fallbackIdentifier
|
||||
}
|
||||
metadata.title = metadata.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
metadata.author = metadata.author?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
metadata.language = metadata.language?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let manifest = manifestItems.map { item in
|
||||
var updatedItem = item
|
||||
updatedItem.title = manifestTitleByID[item.id]
|
||||
return updatedItem
|
||||
}
|
||||
|
||||
return OPFPackageDocument(
|
||||
metadata: metadata,
|
||||
manifest: manifest,
|
||||
manifestByID: Dictionary(uniqueKeysWithValues: manifest.map { ($0.id, $0) }),
|
||||
spineReferences: spineReferences,
|
||||
ncxItem: manifest.first(where: { $0.id == ncxID || $0.isNCX }),
|
||||
navigationItem: manifest.first(where: { $0.isNavigationDocument })
|
||||
)
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didStartElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?,
|
||||
attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
let name = xmlContext.startElement(named: qName ?? elementName)
|
||||
|
||||
switch name {
|
||||
case "package":
|
||||
metadata.version = attributeDict["version"]
|
||||
uniqueIdentifierID = attributeDict["unique-identifier"]
|
||||
case "identifier":
|
||||
if currentSection == .metadata {
|
||||
currentIdentifierElementID = attributeDict["id"]
|
||||
}
|
||||
case "item":
|
||||
guard currentSection == .manifest,
|
||||
let id = attributeDict["id"],
|
||||
let href = attributeDict["href"],
|
||||
let mediaType = attributeDict["media-type"] else {
|
||||
return
|
||||
}
|
||||
manifestItems.append(
|
||||
RDEPUBManifestItem(
|
||||
id: id,
|
||||
href: href,
|
||||
mediaType: mediaType,
|
||||
properties: XMLName.tokenize(attributeDict["properties"]),
|
||||
fallback: attributeDict["fallback"],
|
||||
mediaOverlay: attributeDict["media-overlay"]
|
||||
)
|
||||
)
|
||||
case "itemref":
|
||||
guard currentSection == .spine, let idref = attributeDict["idref"] else {
|
||||
return
|
||||
}
|
||||
spineReferences.append(
|
||||
OPFSpineReference(
|
||||
idref: idref,
|
||||
linear: (attributeDict["linear"]?.lowercased() ?? "yes") != "no",
|
||||
properties: XMLName.tokenize(attributeDict["properties"]),
|
||||
pageSpread: pageSpread(from: attributeDict)
|
||||
)
|
||||
)
|
||||
case "meta":
|
||||
currentMetaProperty = attributeDict["property"] ?? attributeDict["name"]
|
||||
currentMetaRefinesID = attributeDict["refines"].flatMap(XMLName.refinedID(from:))
|
||||
if let property = currentMetaProperty?.lowercased(),
|
||||
property == "rendition:layout",
|
||||
let value = attributeDict["content"] {
|
||||
metadata.layout = layout(from: value)
|
||||
}
|
||||
if let property = currentMetaProperty?.lowercased(),
|
||||
property == "rendition:spread" {
|
||||
metadata.spread = attributeDict["content"]
|
||||
}
|
||||
if let property = currentMetaProperty?.lowercased(),
|
||||
property == "title",
|
||||
let refinesID = currentMetaRefinesID,
|
||||
let value = attributeDict["content"],
|
||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
manifestTitleByID[refinesID] = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if name == "spine" {
|
||||
if let direction = attributeDict["page-progression-direction"]?.lowercased() {
|
||||
metadata.readingProgression = direction == "ltr" ? .ltr : (direction == "rtl" ? .rtl : .auto)
|
||||
}
|
||||
ncxID = attributeDict["toc"]
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
xmlContext.appendCharacters(string)
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didEndElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
let text = xmlContext.trimmedCharacters
|
||||
|
||||
switch (currentSection, name) {
|
||||
case (.metadata, "identifier"):
|
||||
if let identifierID = currentIdentifierElementID, !text.isEmpty {
|
||||
identifierByID[identifierID] = text
|
||||
if identifierID == uniqueIdentifierID {
|
||||
metadata.identifier = text
|
||||
}
|
||||
} else if metadata.identifier == nil, !text.isEmpty {
|
||||
metadata.identifier = text
|
||||
}
|
||||
currentIdentifierElementID = nil
|
||||
case (.metadata, "title"):
|
||||
if metadata.title.isEmpty, !text.isEmpty {
|
||||
metadata.title = text
|
||||
}
|
||||
case (.metadata, "creator"):
|
||||
if metadata.author == nil, !text.isEmpty {
|
||||
metadata.author = text
|
||||
}
|
||||
case (.metadata, "language"):
|
||||
if metadata.language == nil, !text.isEmpty {
|
||||
metadata.language = text
|
||||
}
|
||||
case (.metadata, "meta"):
|
||||
applyMetaValue(text)
|
||||
currentMetaProperty = nil
|
||||
currentMetaRefinesID = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
xmlContext.endElement()
|
||||
}
|
||||
|
||||
private var currentSection: XMLSection {
|
||||
if xmlContext.containsElement(named: "manifest") {
|
||||
return .manifest
|
||||
}
|
||||
if xmlContext.containsElement(named: "spine") {
|
||||
return .spine
|
||||
}
|
||||
if xmlContext.containsElement(named: "metadata") {
|
||||
return .metadata
|
||||
}
|
||||
return .other
|
||||
}
|
||||
|
||||
private func applyMetaValue(_ value: String) {
|
||||
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else {
|
||||
return
|
||||
}
|
||||
|
||||
switch property {
|
||||
case "rendition:layout":
|
||||
metadata.layout = layout(from: normalizedValue)
|
||||
case "rendition:spread":
|
||||
metadata.spread = normalizedValue
|
||||
case "dcterms:identifier", "identifier":
|
||||
if metadata.identifier == nil {
|
||||
metadata.identifier = normalizedValue
|
||||
}
|
||||
case "title":
|
||||
if let refinesID = currentMetaRefinesID {
|
||||
manifestTitleByID[refinesID] = normalizedValue
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? {
|
||||
if let properties = attributes["properties"]?.lowercased() {
|
||||
if properties.contains("page-spread-left") {
|
||||
return .left
|
||||
}
|
||||
if properties.contains("page-spread-right") {
|
||||
return .right
|
||||
}
|
||||
if properties.contains("page-spread-center") {
|
||||
return .center
|
||||
}
|
||||
}
|
||||
|
||||
if let legacySpread = attributes["page-spread"]?.lowercased() {
|
||||
switch legacySpread {
|
||||
case "left":
|
||||
return .left
|
||||
case "right":
|
||||
return .right
|
||||
case "center":
|
||||
return .center
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func layout(from rawValue: String) -> RDEPUBLayout {
|
||||
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
|
||||
}
|
||||
}
|
||||
|
||||
enum XMLSection {
|
||||
case metadata
|
||||
case manifest
|
||||
case spine
|
||||
case other
|
||||
}
|
||||
|
||||
enum XMLName {
|
||||
static func localName(from rawName: String) -> String {
|
||||
rawName.split(separator: ":").last.map(String.init) ?? rawName
|
||||
}
|
||||
|
||||
static func tokenize(_ rawValue: String?) -> [String] {
|
||||
guard let rawValue else { return [] }
|
||||
return rawValue
|
||||
.split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" || $0 == "\r" })
|
||||
.map(String.init)
|
||||
}
|
||||
|
||||
static func refinedID(from rawValue: String) -> String? {
|
||||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("#") else {
|
||||
return nil
|
||||
}
|
||||
return String(trimmed.dropFirst())
|
||||
}
|
||||
}
|
||||
|
||||
final class XMLParserContext {
|
||||
private var elementStack: [String] = []
|
||||
private var currentCharacters = ""
|
||||
|
||||
var trimmedCharacters: String {
|
||||
currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func startElement(named rawName: String) -> String {
|
||||
let name = XMLName.localName(from: rawName)
|
||||
elementStack.append(name)
|
||||
currentCharacters = ""
|
||||
return name
|
||||
}
|
||||
|
||||
func appendCharacters(_ string: String) {
|
||||
currentCharacters += string
|
||||
}
|
||||
|
||||
func endElement() {
|
||||
if !elementStack.isEmpty {
|
||||
_ = elementStack.removeLast()
|
||||
}
|
||||
currentCharacters = ""
|
||||
}
|
||||
|
||||
func containsElement(named name: String) -> Bool {
|
||||
elementStack.contains(name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
public func readingProfile() -> RDEPUBReadingProfile {
|
||||
if metadata.layout == .fixed {
|
||||
return .webFixedLayout
|
||||
}
|
||||
return hasInteractiveContent() ? .webInteractive : .textReflowable
|
||||
}
|
||||
|
||||
public func hasInteractiveContent() -> Bool {
|
||||
let interactiveMediaTypes = [
|
||||
"application/javascript",
|
||||
"text/javascript",
|
||||
"application/ecmascript"
|
||||
]
|
||||
if manifest.values.contains(where: { item in
|
||||
interactiveMediaTypes.contains(item.mediaType.lowercased()) || item.properties.contains("scripted")
|
||||
}) {
|
||||
return true
|
||||
}
|
||||
|
||||
let interactivePattern = #"<(script|iframe|video|audio|canvas|svg|form)\b|\bon(load|click|touchstart|touchend|mouseover)=|hype_generated_script|swiper|webview"#
|
||||
for item in spine where item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) {
|
||||
guard let html = htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension RDEPUBParser {
|
||||
public func manifestItem(for id: String) -> RDEPUBManifestItem? {
|
||||
manifest[id]
|
||||
}
|
||||
|
||||
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
|
||||
let target = href.components(separatedBy: "#").first ?? href
|
||||
return manifest.values.first { item in
|
||||
normalize(href: item.href, relativeTo: opfDirectoryURL ?? URL(fileURLWithPath: "/")) == target
|
||||
}
|
||||
}
|
||||
|
||||
public func href(forSpineIndex index: Int) -> String? {
|
||||
guard spine.indices.contains(index) else { return nil }
|
||||
return spine[index].href
|
||||
}
|
||||
|
||||
public func fileURL(forRelativePath relativePath: String) -> URL? {
|
||||
guard let opfDirectoryURL else { return nil }
|
||||
let path = relativePath.components(separatedBy: "#").first ?? relativePath
|
||||
guard !path.isEmpty else { return nil }
|
||||
let resolvedURL = URL(fileURLWithPath: path, relativeTo: opfDirectoryURL).standardizedFileURL
|
||||
guard let rootURL = extractionRootURL?.standardizedFileURL else {
|
||||
return resolvedURL
|
||||
}
|
||||
guard resolvedURL.path.hasPrefix(rootURL.path) else {
|
||||
return nil
|
||||
}
|
||||
return resolvedURL
|
||||
}
|
||||
|
||||
public func resourceURL(forRelativePath relativePath: String) -> URL? {
|
||||
let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard !normalizedPath.isEmpty else { return nil }
|
||||
guard let encodedPath = normalizedPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
return nil
|
||||
}
|
||||
var components = URLComponents()
|
||||
components.scheme = RDEPUBResourceURLSchemeHandler.scheme
|
||||
components.host = RDEPUBResourceURLSchemeHandler.host
|
||||
components.percentEncodedPath = "/" + encodedPath
|
||||
return components.url
|
||||
}
|
||||
|
||||
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
|
||||
guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme,
|
||||
resourceURL.host == RDEPUBResourceURLSchemeHandler.host else {
|
||||
return nil
|
||||
}
|
||||
let relativePath = resourceURL.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
|
||||
return fileURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
public func coverImage() -> UIImage? {
|
||||
let coverCandidates = manifest.values.filter { item in
|
||||
item.properties.contains("cover-image") || item.id.lowercased().contains("cover")
|
||||
}
|
||||
for item in coverCandidates {
|
||||
if let fileURL = fileURL(forRelativePath: item.href),
|
||||
let image = UIImage(contentsOfFile: fileURL.path) {
|
||||
return image
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func htmlString(forSpineIndex index: Int) -> String? {
|
||||
guard spine.indices.contains(index) else {
|
||||
return nil
|
||||
}
|
||||
return htmlString(forRelativePath: spine[index].href)
|
||||
}
|
||||
|
||||
public func htmlString(forRelativePath relativePath: String) -> String? {
|
||||
guard let fileURL = fileURL(forRelativePath: relativePath) else {
|
||||
return nil
|
||||
}
|
||||
return try? String(contentsOf: fileURL)
|
||||
}
|
||||
|
||||
func normalize(href: String, relativeTo directoryURL: URL) -> String {
|
||||
guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else {
|
||||
return href
|
||||
}
|
||||
let prefix = directoryURL.standardizedFileURL.path + "/"
|
||||
if resolvedURL.path.hasPrefix(prefix) {
|
||||
return String(resolvedURL.path.dropFirst(prefix.count))
|
||||
}
|
||||
return href
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import Foundation
|
||||
|
||||
extension RDEPUBParser {
|
||||
func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
let opfDirectoryURL = opfURL.deletingLastPathComponent()
|
||||
|
||||
if let ncxItem = packageDocument.ncxItem ?? packageDocument.manifest.first(where: { $0.isNCX }) {
|
||||
let ncxURL = opfDirectoryURL.appendingPathComponent(ncxItem.href)
|
||||
let items = parseNCXDocumentItems(at: ncxURL)
|
||||
if !items.isEmpty {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
if let navItem = packageDocument.navigationItem {
|
||||
let navURL = opfDirectoryURL.appendingPathComponent(navItem.href)
|
||||
let items = parseNavDocumentItems(at: navURL, baseURL: navURL.deletingLastPathComponent())
|
||||
if !items.isEmpty {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) }
|
||||
}
|
||||
|
||||
func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
guard FileManager.default.fileExists(atPath: ncxURL.path),
|
||||
let parser = XMLParser(contentsOf: ncxURL) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let delegate = NCXParserDelegate(baseURL: ncxURL.deletingLastPathComponent()) { [weak self] href, baseURL in
|
||||
self?.normalizeTOCHref(href, relativeTo: baseURL)
|
||||
}
|
||||
parser.shouldProcessNamespaces = false
|
||||
parser.delegate = delegate
|
||||
guard parser.parse() else {
|
||||
return []
|
||||
}
|
||||
return delegate.items
|
||||
}
|
||||
|
||||
func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] {
|
||||
guard FileManager.default.fileExists(atPath: navURL.path),
|
||||
let parser = XMLParser(contentsOf: navURL) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let delegate = NavDocumentParserDelegate(baseURL: baseURL) { [weak self] href, currentBaseURL in
|
||||
self?.normalizeTOCHref(href, relativeTo: currentBaseURL)
|
||||
}
|
||||
parser.shouldProcessNamespaces = false
|
||||
parser.delegate = delegate
|
||||
guard parser.parse() else {
|
||||
return []
|
||||
}
|
||||
return delegate.items
|
||||
}
|
||||
|
||||
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
|
||||
let fragment = components.count > 1 ? String(components[1]) : nil
|
||||
|
||||
let normalizedPath: String
|
||||
if pathPart.isEmpty {
|
||||
normalizedPath = ""
|
||||
} else {
|
||||
normalizedPath = normalize(href: pathPart, relativeTo: baseURL)
|
||||
}
|
||||
|
||||
if let fragment, !fragment.isEmpty {
|
||||
if normalizedPath.isEmpty {
|
||||
return "#\(fragment)"
|
||||
}
|
||||
return "\(normalizedPath)#\(fragment)"
|
||||
}
|
||||
|
||||
return normalizedPath.isEmpty ? nil : normalizedPath
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
return EPUBTableOfContentsItem(
|
||||
title: trimmedTitle.isEmpty ? href : trimmedTitle,
|
||||
href: href,
|
||||
children: children.compactMap { $0.asItem() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class TOCTreeBuilder {
|
||||
private var nodeStack: [TOCNode] = []
|
||||
private var rootNodes: [TOCNode] = []
|
||||
|
||||
var items: [EPUBTableOfContentsItem] {
|
||||
rootNodes.compactMap { $0.asItem() }
|
||||
}
|
||||
|
||||
func beginNode() {
|
||||
nodeStack.append(TOCNode())
|
||||
}
|
||||
|
||||
func updateCurrentHref(_ href: String?) {
|
||||
nodeStack.last?.href = href
|
||||
}
|
||||
|
||||
func appendCurrentTitle(_ text: String) {
|
||||
nodeStack.last?.title += text
|
||||
}
|
||||
|
||||
func endNode() {
|
||||
guard let node = nodeStack.popLast() else {
|
||||
return
|
||||
}
|
||||
if let parent = nodeStack.last {
|
||||
parent.children.append(node)
|
||||
} else {
|
||||
rootNodes.append(node)
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
nodeStack.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private final class NCXParserDelegate: NSObject, XMLParserDelegate {
|
||||
private let baseURL: URL
|
||||
private let hrefNormalizer: (String, URL) -> String?
|
||||
private let treeBuilder = TOCTreeBuilder()
|
||||
private var readingLabelText = false
|
||||
|
||||
init(baseURL: URL, hrefNormalizer: @escaping (String, URL) -> String?) {
|
||||
self.baseURL = baseURL
|
||||
self.hrefNormalizer = hrefNormalizer
|
||||
}
|
||||
|
||||
var items: [EPUBTableOfContentsItem] {
|
||||
treeBuilder.items
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didStartElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?,
|
||||
attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
|
||||
switch name {
|
||||
case "navPoint":
|
||||
treeBuilder.beginNode()
|
||||
case "content":
|
||||
guard let src = attributeDict["src"] else {
|
||||
return
|
||||
}
|
||||
treeBuilder.updateCurrentHref(hrefNormalizer(src, baseURL))
|
||||
case "text":
|
||||
readingLabelText = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
if readingLabelText {
|
||||
treeBuilder.appendCurrentTitle(string)
|
||||
}
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didEndElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
|
||||
switch name {
|
||||
case "text":
|
||||
readingLabelText = false
|
||||
case "navPoint":
|
||||
treeBuilder.endNode()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
|
||||
private let baseURL: URL
|
||||
private let hrefNormalizer: (String, URL) -> String?
|
||||
private var insideTOCNav = false
|
||||
private var listDepth = 0
|
||||
private let treeBuilder = TOCTreeBuilder()
|
||||
private var collectingAnchorText = false
|
||||
|
||||
init(baseURL: URL, hrefNormalizer: @escaping (String, URL) -> String?) {
|
||||
self.baseURL = baseURL
|
||||
self.hrefNormalizer = hrefNormalizer
|
||||
}
|
||||
|
||||
var items: [EPUBTableOfContentsItem] {
|
||||
treeBuilder.items
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didStartElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?,
|
||||
attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
|
||||
switch name {
|
||||
case "nav":
|
||||
insideTOCNav = isTOCNav(attributes: attributeDict)
|
||||
case "ol":
|
||||
if insideTOCNav {
|
||||
listDepth += 1
|
||||
}
|
||||
case "li":
|
||||
if insideTOCNav, listDepth > 0 {
|
||||
treeBuilder.beginNode()
|
||||
}
|
||||
case "a":
|
||||
guard insideTOCNav else {
|
||||
return
|
||||
}
|
||||
collectingAnchorText = true
|
||||
if let href = attributeDict["href"] {
|
||||
treeBuilder.updateCurrentHref(hrefNormalizer(href, baseURL))
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
if collectingAnchorText {
|
||||
treeBuilder.appendCurrentTitle(string)
|
||||
}
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didEndElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName qName: String?
|
||||
) {
|
||||
let name = XMLName.localName(from: qName ?? elementName)
|
||||
|
||||
switch name {
|
||||
case "a":
|
||||
collectingAnchorText = false
|
||||
case "li":
|
||||
guard insideTOCNav else {
|
||||
break
|
||||
}
|
||||
treeBuilder.endNode()
|
||||
case "ol":
|
||||
if insideTOCNav {
|
||||
listDepth = max(0, listDepth - 1)
|
||||
}
|
||||
case "nav":
|
||||
insideTOCNav = false
|
||||
listDepth = 0
|
||||
treeBuilder.reset()
|
||||
collectingAnchorText = false
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func isTOCNav(attributes: [String: String]) -> Bool {
|
||||
if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") {
|
||||
return true
|
||||
}
|
||||
if let type = attributes["type"]?.lowercased(), type.contains("toc") {
|
||||
return true
|
||||
}
|
||||
if let role = attributes["role"]?.lowercased(), role.contains("doc-toc") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
public final class RDEPUBParser {
|
||||
public internal(set) var metadata = RDEPUBMetadata()
|
||||
public internal(set) var manifest: [String: RDEPUBManifestItem] = [:]
|
||||
public internal(set) var spine: [RDEPUBSpineItem] = []
|
||||
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = []
|
||||
public internal(set) var extractionRootURL: URL?
|
||||
public internal(set) var opfURL: URL?
|
||||
|
||||
public var opfDirectoryURL: URL? {
|
||||
opfURL?.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
public func makePublication() -> RDEPUBPublication {
|
||||
RDEPUBPublication(parser: self)
|
||||
}
|
||||
|
||||
public func parse(epubURL: URL) throws {
|
||||
reset()
|
||||
|
||||
let extractionURL = try extractArchiveIfNeeded(epubURL: epubURL)
|
||||
extractionRootURL = extractionURL
|
||||
|
||||
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
|
||||
guard FileManager.default.fileExists(atPath: containerURL.path) else {
|
||||
throw RDEPUBParserError.missingContainerXML
|
||||
}
|
||||
|
||||
let rootFilePath = try parseContainerRootFile(at: containerURL)
|
||||
let packageURL = extractionURL.appendingPathComponent(rootFilePath)
|
||||
guard FileManager.default.fileExists(atPath: packageURL.path) else {
|
||||
throw RDEPUBParserError.invalidRootFilePath(rootFilePath)
|
||||
}
|
||||
|
||||
try parseOPF(at: packageURL)
|
||||
}
|
||||
|
||||
public func parseOPF(at opfURL: URL) throws {
|
||||
resetPublicationState()
|
||||
|
||||
guard let parser = XMLParser(contentsOf: opfURL) else {
|
||||
throw RDEPUBParserError.invalidXML(opfURL)
|
||||
}
|
||||
|
||||
let delegate = OPFPackageParserDelegate()
|
||||
parser.shouldProcessNamespaces = false
|
||||
parser.delegate = delegate
|
||||
|
||||
guard parser.parse() else {
|
||||
throw parser.parserError ?? RDEPUBParserError.invalidXML(opfURL)
|
||||
}
|
||||
|
||||
let packageDocument = delegate.packageDocument()
|
||||
self.opfURL = opfURL
|
||||
self.metadata = packageDocument.metadata
|
||||
self.manifest = Dictionary(uniqueKeysWithValues: packageDocument.manifest.map { ($0.id, $0) })
|
||||
self.spine = try buildSpine(from: packageDocument, opfURL: opfURL)
|
||||
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
|
||||
}
|
||||
|
||||
public func parseTOC() -> [EPUBTableOfContentsItem] {
|
||||
tableOfContents
|
||||
}
|
||||
|
||||
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
|
||||
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import UIKit
|
||||
|
||||
public struct RDEPUBPreferences: Equatable {
|
||||
public var fontSize: CGFloat
|
||||
public var lineHeightMultiple: CGFloat
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
public var fixedContentInset: UIEdgeInsets
|
||||
public var themeBackgroundColor: String?
|
||||
public var themeTextColor: String?
|
||||
public var fixedBackgroundColor: String?
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
|
||||
public init(
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
reflowableContentInsets: UIEdgeInsets,
|
||||
fixedContentInset: UIEdgeInsets,
|
||||
themeBackgroundColor: String? = nil,
|
||||
themeTextColor: String? = nil,
|
||||
fixedBackgroundColor: String? = nil,
|
||||
fixedLayoutFit: RDEPUBFixedLayoutFit = .page,
|
||||
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic
|
||||
) {
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.reflowableContentInsets = reflowableContentInsets
|
||||
self.fixedContentInset = fixedContentInset
|
||||
self.themeBackgroundColor = themeBackgroundColor
|
||||
self.themeTextColor = themeTextColor
|
||||
self.fixedBackgroundColor = fixedBackgroundColor
|
||||
self.fixedLayoutFit = fixedLayoutFit
|
||||
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
|
||||
}
|
||||
|
||||
public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle {
|
||||
RDEPUBPresentationStyle(
|
||||
viewportSize: viewportSize,
|
||||
contentInsets: reflowableContentInsets,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
themeBackgroundColor: themeBackgroundColor,
|
||||
themeTextColor: themeTextColor
|
||||
)
|
||||
}
|
||||
|
||||
public func renderRequest(
|
||||
for page: EPUBPage,
|
||||
publication: RDEPUBPublication,
|
||||
viewportSize: CGSize,
|
||||
targetLocation: RDEPUBLocation? = nil,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||
) -> RDEPUBRenderRequest? {
|
||||
if publication.layout == .fixed, let fixedSpread = page.fixedSpread {
|
||||
return .fixed(
|
||||
RDEPUBFixedRenderRequest(
|
||||
spread: fixedSpread,
|
||||
viewportSize: viewportSize,
|
||||
contentInset: fixedContentInset,
|
||||
backgroundColorCSS: fixedBackgroundColor,
|
||||
fit: fixedLayoutFit,
|
||||
searchPresentation: searchPresentation
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
guard publication.spine.indices.contains(page.spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return .reflowable(
|
||||
RDEPUBReflowableRenderRequest(
|
||||
spineIndex: page.spineIndex,
|
||||
href: publication.spine[page.spineIndex].href,
|
||||
pageIndex: page.pageIndexInChapter,
|
||||
totalPagesInChapter: page.totalPagesInChapter,
|
||||
presentation: presentationStyle(viewportSize: viewportSize),
|
||||
targetLocation: targetLocation,
|
||||
highlights: highlights,
|
||||
searchPresentation: searchPresentation
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
public final class RDEPUBPublication {
|
||||
public let parser: RDEPUBParser
|
||||
public let resourceResolver: RDEPUBResourceResolver
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
self.parser = parser
|
||||
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
|
||||
}
|
||||
|
||||
public var layout: RDEPUBLayout {
|
||||
metadata.layout
|
||||
}
|
||||
|
||||
public var readingProfile: RDEPUBReadingProfile {
|
||||
parser.readingProfile()
|
||||
}
|
||||
|
||||
public var readingProgression: RDEPUBReadingProgression {
|
||||
metadata.readingProgression
|
||||
}
|
||||
|
||||
public var bookIdentifier: String? {
|
||||
metadata.identifier
|
||||
}
|
||||
|
||||
public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool {
|
||||
guard layout == .fixed else {
|
||||
return false
|
||||
}
|
||||
guard metadata.spread?.lowercased() != "none" else {
|
||||
return false
|
||||
}
|
||||
|
||||
switch preferences.fixedLayoutSpreadMode {
|
||||
case .never:
|
||||
return false
|
||||
case .always:
|
||||
return spine.count > 1
|
||||
case .automatic:
|
||||
return viewportSize.width > viewportSize.height
|
||||
}
|
||||
}
|
||||
|
||||
public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] {
|
||||
EPUBFixedSpread.makeSpreads(
|
||||
spine: spine,
|
||||
spreadEnabled: fixedLayoutSpreadEnabled(for: preferences, viewportSize: viewportSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBLocation: Codable, Equatable {
|
||||
public var bookIdentifier: String?
|
||||
public var href: String
|
||||
public var progression: Double
|
||||
public var lastProgression: Double?
|
||||
public var fragment: String?
|
||||
|
||||
public init(
|
||||
bookIdentifier: String? = nil,
|
||||
href: String,
|
||||
progression: Double,
|
||||
lastProgression: Double? = nil,
|
||||
fragment: String? = nil
|
||||
) {
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.href = href
|
||||
self.progression = Self.clamp(progression)
|
||||
self.lastProgression = lastProgression.map(Self.clamp)
|
||||
self.fragment = fragment?.nilIfEmpty
|
||||
}
|
||||
|
||||
public var navigationProgression: Double {
|
||||
let end = lastProgression ?? progression
|
||||
if end.isNaN || end.isInfinite {
|
||||
return progression
|
||||
}
|
||||
return Self.clamp((progression + end) / 2.0)
|
||||
}
|
||||
|
||||
private static func clamp(_ value: Double) -> Double {
|
||||
guard value.isFinite else { return 0 }
|
||||
return min(1, max(0, value))
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBViewportResource: Codable, Equatable {
|
||||
public var href: String
|
||||
public var spineIndex: Int
|
||||
public var progression: Double
|
||||
public var lastProgression: Double?
|
||||
public var fragment: String?
|
||||
|
||||
public init(
|
||||
href: String,
|
||||
spineIndex: Int,
|
||||
progression: Double,
|
||||
lastProgression: Double? = nil,
|
||||
fragment: String? = nil
|
||||
) {
|
||||
self.href = href
|
||||
self.spineIndex = spineIndex
|
||||
self.progression = progression
|
||||
self.lastProgression = lastProgression
|
||||
self.fragment = fragment
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBViewport: Codable, Equatable {
|
||||
public var resources: [RDEPUBViewportResource]
|
||||
public var visiblePageNumber: Int
|
||||
public var chapterIndex: Int?
|
||||
public var isFixedLayout: Bool
|
||||
|
||||
public init(
|
||||
resources: [RDEPUBViewportResource],
|
||||
visiblePageNumber: Int,
|
||||
chapterIndex: Int? = nil,
|
||||
isFixedLayout: Bool
|
||||
) {
|
||||
self.resources = resources
|
||||
self.visiblePageNumber = visiblePageNumber
|
||||
self.chapterIndex = chapterIndex
|
||||
self.isFixedLayout = isFixedLayout
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBReadingContext: Codable, Equatable {
|
||||
public var location: RDEPUBLocation
|
||||
public var viewport: RDEPUBViewport
|
||||
public var pageNumber: Int
|
||||
public var chapterIndex: Int?
|
||||
|
||||
public init(
|
||||
location: RDEPUBLocation,
|
||||
viewport: RDEPUBViewport,
|
||||
pageNumber: Int,
|
||||
chapterIndex: Int? = nil
|
||||
) {
|
||||
self.location = location
|
||||
self.viewport = viewport
|
||||
self.pageNumber = pageNumber
|
||||
self.chapterIndex = chapterIndex
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBSelection: Codable, Equatable {
|
||||
public var bookIdentifier: String?
|
||||
public var location: RDEPUBLocation
|
||||
public var text: String
|
||||
public var rangeInfo: String?
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
bookIdentifier: String? = nil,
|
||||
location: RDEPUBLocation,
|
||||
text: String,
|
||||
rangeInfo: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.location = location
|
||||
self.text = text
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
public var isEmpty: Bool {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rangeInfo?.isEmpty != false
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBHighlightStyle: String, Codable {
|
||||
case highlight
|
||||
case underline
|
||||
}
|
||||
|
||||
public enum RDEPUBAnnotationMenuAction: Equatable {
|
||||
case copy
|
||||
case highlight
|
||||
case annotate
|
||||
}
|
||||
|
||||
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
||||
public var kind: String
|
||||
public var href: String
|
||||
public var start: Int
|
||||
public var end: Int
|
||||
|
||||
public init(href: String, start: Int, end: Int) {
|
||||
self.kind = "text-offset"
|
||||
self.href = href
|
||||
self.start = start
|
||||
self.end = end
|
||||
}
|
||||
|
||||
public var nsRange: NSRange? {
|
||||
guard end > start else { return nil }
|
||||
return NSRange(location: start, length: end - start)
|
||||
}
|
||||
|
||||
public func jsonString() -> String? {
|
||||
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? {
|
||||
guard let string,
|
||||
let data = string.data(using: .utf8),
|
||||
let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data),
|
||||
rangeInfo.kind == "text-offset",
|
||||
rangeInfo.end > rangeInfo.start else {
|
||||
return nil
|
||||
}
|
||||
return rangeInfo
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBHighlight: Codable, Equatable {
|
||||
public var id: String
|
||||
public var bookIdentifier: String?
|
||||
public var location: RDEPUBLocation
|
||||
public var text: String
|
||||
public var rangeInfo: String?
|
||||
public var style: RDEPUBHighlightStyle
|
||||
public var color: String
|
||||
public var note: String?
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
bookIdentifier: String? = nil,
|
||||
location: RDEPUBLocation,
|
||||
text: String,
|
||||
rangeInfo: String? = nil,
|
||||
style: RDEPUBHighlightStyle = .highlight,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.location = location
|
||||
self.text = text
|
||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
||||
self.style = style
|
||||
self.color = color
|
||||
self.note = note?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case bookIdentifier
|
||||
case location
|
||||
case text
|
||||
case rangeInfo
|
||||
case style
|
||||
case color
|
||||
case note
|
||||
case createdAt
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier)
|
||||
location = try container.decode(RDEPUBLocation.self, forKey: .location)
|
||||
text = try container.decode(String.self, forKey: .text)
|
||||
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
|
||||
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
|
||||
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
|
||||
style = decodedStyle
|
||||
} else {
|
||||
style = .highlight
|
||||
}
|
||||
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
|
||||
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
|
||||
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier)
|
||||
try container.encode(location, forKey: .location)
|
||||
try container.encode(text, forKey: .text)
|
||||
try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo)
|
||||
try container.encode(style, forKey: .style)
|
||||
try container.encode(color, forKey: .color)
|
||||
try container.encodeIfPresent(note, forKey: .note)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBBookmark: Codable, Equatable {
|
||||
public var id: String
|
||||
public var bookIdentifier: String?
|
||||
public var location: RDEPUBLocation
|
||||
public var chapterTitle: String?
|
||||
public var note: String?
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
bookIdentifier: String? = nil,
|
||||
location: RDEPUBLocation,
|
||||
chapterTitle: String? = nil,
|
||||
note: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.bookIdentifier = bookIdentifier
|
||||
self.location = location
|
||||
self.chapterTitle = chapterTitle?.nilIfEmpty
|
||||
self.note = note?.nilIfEmpty
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
public var hasNote: Bool {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
}
|
||||
|
||||
public struct EPUBChapterInfo: Codable, Equatable {
|
||||
public var spineIndex: Int
|
||||
public var title: String
|
||||
public var pageCount: Int
|
||||
|
||||
public init(spineIndex: Int, title: String, pageCount: Int) {
|
||||
self.spineIndex = spineIndex
|
||||
self.title = title
|
||||
self.pageCount = pageCount
|
||||
}
|
||||
}
|
||||
|
||||
public struct EPUBPage: Codable, Equatable {
|
||||
public var spineIndex: Int
|
||||
public var chapterIndex: Int
|
||||
public var pageIndexInChapter: Int
|
||||
public var totalPagesInChapter: Int
|
||||
public var chapterTitle: String
|
||||
public var fixedSpread: EPUBFixedSpread?
|
||||
|
||||
public init(
|
||||
spineIndex: Int,
|
||||
chapterIndex: Int,
|
||||
pageIndexInChapter: Int,
|
||||
totalPagesInChapter: Int,
|
||||
chapterTitle: String,
|
||||
fixedSpread: EPUBFixedSpread?
|
||||
) {
|
||||
self.spineIndex = spineIndex
|
||||
self.chapterIndex = chapterIndex
|
||||
self.pageIndexInChapter = pageIndexInChapter
|
||||
self.totalPagesInChapter = totalPagesInChapter
|
||||
self.chapterTitle = chapterTitle
|
||||
self.fixedSpread = fixedSpread
|
||||
}
|
||||
}
|
||||
|
||||
public struct EPUBFixedSpreadResource: Codable, Equatable {
|
||||
public var spineIndex: Int
|
||||
public var href: String
|
||||
public var title: String
|
||||
public var pageSpread: RDEPUBPageSpread?
|
||||
|
||||
public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) {
|
||||
self.spineIndex = spineIndex
|
||||
self.href = href
|
||||
self.title = title
|
||||
self.pageSpread = pageSpread
|
||||
}
|
||||
}
|
||||
|
||||
public struct EPUBFixedSpread: Codable, Equatable {
|
||||
public var resources: [EPUBFixedSpreadResource]
|
||||
|
||||
public init(resources: [EPUBFixedSpreadResource]) {
|
||||
self.resources = resources
|
||||
}
|
||||
|
||||
public var primaryResource: EPUBFixedSpreadResource {
|
||||
resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "")
|
||||
}
|
||||
|
||||
public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool {
|
||||
resources.contains { resource in
|
||||
normalizer(resource.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
|
||||
public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] {
|
||||
let resources = spine.enumerated().map { index, item in
|
||||
EPUBFixedSpreadResource(
|
||||
spineIndex: index,
|
||||
href: item.href,
|
||||
title: item.title,
|
||||
pageSpread: item.pageSpread
|
||||
)
|
||||
}
|
||||
|
||||
guard spreadEnabled, resources.count > 1 else {
|
||||
return resources.map { EPUBFixedSpread(resources: [$0]) }
|
||||
}
|
||||
|
||||
var spreads: [EPUBFixedSpread] = []
|
||||
var cursor = 0
|
||||
|
||||
while cursor < resources.count {
|
||||
let current = resources[cursor]
|
||||
|
||||
guard cursor + 1 < resources.count else {
|
||||
spreads.append(EPUBFixedSpread(resources: [current]))
|
||||
break
|
||||
}
|
||||
|
||||
let next = resources[cursor + 1]
|
||||
if shouldPair(current: current, next: next) {
|
||||
spreads.append(EPUBFixedSpread(resources: [current, next]))
|
||||
cursor += 2
|
||||
} else {
|
||||
spreads.append(EPUBFixedSpread(resources: [current]))
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
|
||||
return spreads
|
||||
}
|
||||
|
||||
public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] {
|
||||
makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled)
|
||||
}
|
||||
|
||||
private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool {
|
||||
if current.pageSpread == .center || next.pageSpread == .center {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (current.pageSpread, next.pageSpread) {
|
||||
case (.left, .right), (.right, .left):
|
||||
return true
|
||||
case (.left, .left), (.right, .right):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import Foundation
|
||||
|
||||
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?
|
||||
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
|
||||
}
|
||||
|
||||
public func resetRuntimeState() {
|
||||
navigatorState = .initializing
|
||||
activePages = []
|
||||
activeChapters = []
|
||||
clearPendingNavigation()
|
||||
clearStagedSnapshot()
|
||||
currentViewport = nil
|
||||
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
|
||||
}
|
||||
return (stagedPages, stagedChapters)
|
||||
}
|
||||
|
||||
public func consumeStagedSnapshotIfAllowed() -> (snapshot: PaginationSnapshot, restoreLocation: RDEPUBLocation?)? {
|
||||
guard navigatorState.isStableForSnapshotApplication,
|
||||
let stagedPages,
|
||||
let stagedChapters else {
|
||||
return nil
|
||||
}
|
||||
let restoreLocation = stagedRestoreLocation
|
||||
clearStagedSnapshot()
|
||||
return ((stagedPages, stagedChapters), restoreLocation)
|
||||
}
|
||||
|
||||
public func clearStagedSnapshot() {
|
||||
stagedPages = nil
|
||||
stagedChapters = nil
|
||||
stagedRestoreLocation = nil
|
||||
}
|
||||
|
||||
public func clearPendingNavigation() {
|
||||
pendingNavigationLocation = nil
|
||||
pendingNavigationPageNum = nil
|
||||
}
|
||||
|
||||
public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? {
|
||||
guard pendingNavigationPageNum == pageNumber,
|
||||
let pendingNavigationLocation else {
|
||||
return nil
|
||||
}
|
||||
guard let spineIndex else {
|
||||
return pendingNavigationLocation
|
||||
}
|
||||
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
|
||||
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
|
||||
return nil
|
||||
}
|
||||
return pendingNavigationLocation
|
||||
}
|
||||
|
||||
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex })
|
||||
}
|
||||
return page.spineIndex == spineIndex
|
||||
}
|
||||
|
||||
public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: fixedSpread.primaryResource.href,
|
||||
progression: 0,
|
||||
lastProgression: 1
|
||||
)
|
||||
}
|
||||
|
||||
guard let href = resourceResolver.href(forSpineIndex: page.spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let progression: Double
|
||||
if page.totalPagesInChapter <= 1 {
|
||||
progression = 0
|
||||
} else {
|
||||
progression = Double(page.pageIndexInChapter) / Double(page.totalPagesInChapter - 1)
|
||||
}
|
||||
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: href,
|
||||
progression: progression,
|
||||
lastProgression: progression
|
||||
)
|
||||
}
|
||||
|
||||
public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard currentPageNumber > 0,
|
||||
activePages.indices.contains(currentPageNumber - 1) else {
|
||||
return nil
|
||||
}
|
||||
return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
public func initialSpineIndex(for location: RDEPUBLocation?) -> Int {
|
||||
guard let location,
|
||||
let normalizedHref = resourceResolver.normalizedHref(location.href),
|
||||
let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == normalizedHref }) else {
|
||||
return 0
|
||||
}
|
||||
return spineIndex
|
||||
}
|
||||
|
||||
public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? {
|
||||
guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else {
|
||||
return nil
|
||||
}
|
||||
guard let targetHref = resourceResolver.normalizedHref(location.href) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if publication.layout == .fixed {
|
||||
return activePages.firstIndex(where: { page in
|
||||
if let fixedSpread = page.fixedSpread {
|
||||
return fixedSpread.contains(normalizedHref: targetHref, normalizer: { self.resourceResolver.normalizedHref($0) })
|
||||
}
|
||||
guard let href = self.resourceResolver.href(forSpineIndex: page.spineIndex) else {
|
||||
return false
|
||||
}
|
||||
return self.resourceResolver.normalizedHref(href) == targetHref
|
||||
})
|
||||
}
|
||||
|
||||
guard let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == targetHref }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let candidates = activePages.enumerated().filter { $0.element.spineIndex == spineIndex }
|
||||
guard !candidates.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
if candidates.count == 1 {
|
||||
return candidates[0].offset
|
||||
}
|
||||
|
||||
let navigationProgression = location.navigationProgression
|
||||
let localIndex = min(
|
||||
candidates.count - 1,
|
||||
max(0, Int(round(navigationProgression * Double(candidates.count - 1))))
|
||||
)
|
||||
return candidates[localIndex].offset
|
||||
}
|
||||
|
||||
public func queueNavigation(
|
||||
to location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int? = nil,
|
||||
bookIdentifier: String?
|
||||
) -> Int? {
|
||||
guard let normalizedLocation = resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: spineIndex,
|
||||
bookIdentifier: bookIdentifier
|
||||
), let pageIndex = pageIndex(for: normalizedLocation, bookIdentifier: bookIdentifier) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
pendingNavigationLocation = normalizedLocation.fragment == nil ? nil : normalizedLocation
|
||||
pendingNavigationPageNum = normalizedLocation.fragment == nil ? nil : pageIndex + 1
|
||||
transition(to: .jumping)
|
||||
return pageIndex + 1
|
||||
}
|
||||
|
||||
public func updateReadingContext(
|
||||
pageNumber: Int,
|
||||
location: RDEPUBLocation,
|
||||
spineIndex: Int,
|
||||
chapterIndex: Int?,
|
||||
bookIdentifier: String?
|
||||
) {
|
||||
let normalizedLocation = resourceResolver.normalizedLocation(
|
||||
location,
|
||||
relativeToSpineIndex: spineIndex,
|
||||
bookIdentifier: bookIdentifier
|
||||
) ?? RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: location.href,
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: location.fragment
|
||||
)
|
||||
|
||||
let resource = RDEPUBViewportResource(
|
||||
href: normalizedLocation.href,
|
||||
spineIndex: spineIndex,
|
||||
progression: normalizedLocation.progression,
|
||||
lastProgression: normalizedLocation.lastProgression,
|
||||
fragment: normalizedLocation.fragment
|
||||
)
|
||||
let viewport = RDEPUBViewport(
|
||||
resources: [resource],
|
||||
visiblePageNumber: pageNumber,
|
||||
chapterIndex: chapterIndex,
|
||||
isFixedLayout: publication.layout == .fixed
|
||||
)
|
||||
currentViewport = viewport
|
||||
currentReadingContext = RDEPUBReadingContext(
|
||||
location: normalizedLocation,
|
||||
viewport: viewport,
|
||||
pageNumber: pageNumber,
|
||||
chapterIndex: chapterIndex
|
||||
)
|
||||
|
||||
if pendingNavigationPageNum == pageNumber {
|
||||
clearPendingNavigation()
|
||||
}
|
||||
if navigatorState == .jumping || navigatorState == .moving {
|
||||
transition(to: .idle)
|
||||
}
|
||||
}
|
||||
|
||||
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier)
|
||||
}
|
||||
|
||||
public func makePaginationSnapshot(
|
||||
pageCounts: [Int],
|
||||
preferences: RDEPUBPreferences,
|
||||
layoutContext: RDEPUBNavigatorLayoutContext
|
||||
) -> PaginationSnapshot {
|
||||
if publication.layout == .fixed {
|
||||
let spreads = publication.makeFixedSpreads(
|
||||
preferences: preferences,
|
||||
viewportSize: layoutContext.viewportSize
|
||||
)
|
||||
|
||||
let chapters = spreads.enumerated().map { spreadIndex, spread in
|
||||
EPUBChapterInfo(
|
||||
spineIndex: spread.primaryResource.spineIndex,
|
||||
title: spread.primaryResource.title,
|
||||
pageCount: 1
|
||||
)
|
||||
}
|
||||
|
||||
let pages = spreads.enumerated().map { spreadIndex, spread in
|
||||
EPUBPage(
|
||||
spineIndex: spread.primaryResource.spineIndex,
|
||||
chapterIndex: spreadIndex,
|
||||
pageIndexInChapter: 0,
|
||||
totalPagesInChapter: 1,
|
||||
chapterTitle: spread.primaryResource.title,
|
||||
fixedSpread: spread
|
||||
)
|
||||
}
|
||||
|
||||
return (pages, chapters)
|
||||
}
|
||||
|
||||
var pages: [EPUBPage] = []
|
||||
var chapters: [EPUBChapterInfo] = []
|
||||
|
||||
for (spineIndex, pageCount) in pageCounts.enumerated() {
|
||||
guard pageCount > 0, spineIndex < publication.spine.count else { continue }
|
||||
|
||||
let chapterIndex = chapters.count
|
||||
let title = publication.spine[spineIndex].title
|
||||
|
||||
chapters.append(
|
||||
EPUBChapterInfo(
|
||||
spineIndex: spineIndex,
|
||||
title: title,
|
||||
pageCount: pageCount
|
||||
)
|
||||
)
|
||||
|
||||
for pageIndex in 0..<pageCount {
|
||||
pages.append(
|
||||
EPUBPage(
|
||||
spineIndex: spineIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
pageIndexInChapter: pageIndex,
|
||||
totalPagesInChapter: pageCount,
|
||||
chapterTitle: title,
|
||||
fixedSpread: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (pages, chapters)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import UIKit
|
||||
|
||||
public enum RDEPUBFixedLayoutFit: String, Codable {
|
||||
case auto
|
||||
case page
|
||||
case width
|
||||
}
|
||||
|
||||
public enum RDEPUBFixedLayoutSpreadMode: String, Codable {
|
||||
case automatic
|
||||
case always
|
||||
case never
|
||||
}
|
||||
|
||||
public struct RDEPUBPresentationStyle: Equatable {
|
||||
public var viewportSize: CGSize
|
||||
public var contentInsets: UIEdgeInsets
|
||||
public var fontSize: CGFloat
|
||||
public var lineHeightMultiple: CGFloat
|
||||
public var themeBackgroundColor: String?
|
||||
public var themeTextColor: String?
|
||||
|
||||
public init(
|
||||
viewportSize: CGSize,
|
||||
contentInsets: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
themeBackgroundColor: String? = nil,
|
||||
themeTextColor: String? = nil
|
||||
) {
|
||||
self.viewportSize = viewportSize
|
||||
self.contentInsets = contentInsets
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.themeBackgroundColor = themeBackgroundColor
|
||||
self.themeTextColor = themeTextColor
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBReflowableRenderRequest: Equatable {
|
||||
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(
|
||||
spineIndex: Int,
|
||||
href: String,
|
||||
pageIndex: Int,
|
||||
totalPagesInChapter: Int,
|
||||
presentation: RDEPUBPresentationStyle,
|
||||
targetLocation: RDEPUBLocation? = nil,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||
) {
|
||||
self.spineIndex = spineIndex
|
||||
self.href = href
|
||||
self.pageIndex = pageIndex
|
||||
self.totalPagesInChapter = totalPagesInChapter
|
||||
self.presentation = presentation
|
||||
self.targetLocation = targetLocation
|
||||
self.highlights = highlights
|
||||
self.searchPresentation = searchPresentation
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBFixedRenderRequest: Equatable {
|
||||
public var spread: EPUBFixedSpread
|
||||
public var viewportSize: CGSize
|
||||
public var contentInset: UIEdgeInsets
|
||||
public var backgroundColorCSS: String?
|
||||
public var fit: RDEPUBFixedLayoutFit
|
||||
public var searchPresentation: RDEPUBSearchPresentation?
|
||||
|
||||
public init(
|
||||
spread: EPUBFixedSpread,
|
||||
viewportSize: CGSize,
|
||||
contentInset: UIEdgeInsets,
|
||||
backgroundColorCSS: String? = nil,
|
||||
fit: RDEPUBFixedLayoutFit = .page,
|
||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
||||
) {
|
||||
self.spread = spread
|
||||
self.viewportSize = viewportSize
|
||||
self.contentInset = contentInset
|
||||
self.backgroundColorCSS = backgroundColorCSS
|
||||
self.fit = fit
|
||||
self.searchPresentation = searchPresentation
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBRenderRequest: Equatable {
|
||||
case reflowable(RDEPUBReflowableRenderRequest)
|
||||
case fixed(RDEPUBFixedRenderRequest)
|
||||
|
||||
public var isFixedLayout: Bool {
|
||||
if case .fixed = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public var primarySpineIndex: Int {
|
||||
switch self {
|
||||
case .reflowable(let request):
|
||||
return request.spineIndex
|
||||
case .fixed(let request):
|
||||
return request.spread.primaryResource.spineIndex
|
||||
}
|
||||
}
|
||||
|
||||
public var primaryHref: String {
|
||||
switch self {
|
||||
case .reflowable(let request):
|
||||
return request.href
|
||||
case .fixed(let request):
|
||||
return request.spread.primaryResource.href
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
|
||||
public final class RDEPUBResourceResolver {
|
||||
private let parser: RDEPUBParser
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
self.parser = parser
|
||||
}
|
||||
|
||||
public var opfDirectoryURL: URL? {
|
||||
parser.opfDirectoryURL
|
||||
}
|
||||
|
||||
public func fileURL(forRelativePath relativePath: String) -> URL? {
|
||||
parser.fileURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
public func resourceURL(forRelativePath relativePath: String) -> URL? {
|
||||
parser.resourceURL(forRelativePath: relativePath)
|
||||
}
|
||||
|
||||
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
|
||||
parser.fileURL(forResourceURL: resourceURL)
|
||||
}
|
||||
|
||||
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? {
|
||||
guard let opfDirectoryURL else {
|
||||
return href.components(separatedBy: "#").first
|
||||
}
|
||||
|
||||
let pathPart = href.components(separatedBy: "#").first ?? href
|
||||
if pathPart.isEmpty {
|
||||
guard let spineIndex, parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return parser.spine[spineIndex].href.components(separatedBy: "#").first
|
||||
}
|
||||
|
||||
let baseURL: URL
|
||||
if let spineIndex, parser.spine.indices.contains(spineIndex) {
|
||||
baseURL = opfDirectoryURL
|
||||
.appendingPathComponent(parser.spine[spineIndex].href)
|
||||
.deletingLastPathComponent()
|
||||
} else {
|
||||
baseURL = opfDirectoryURL
|
||||
}
|
||||
|
||||
guard let resolvedURL = URL(string: pathPart, relativeTo: baseURL)?.standardizedFileURL else {
|
||||
return pathPart
|
||||
}
|
||||
|
||||
let opfPath = opfDirectoryURL.standardizedFileURL.path + "/"
|
||||
let resolvedPath = resolvedURL.path
|
||||
if resolvedPath.hasPrefix(opfPath) {
|
||||
return String(resolvedPath.dropFirst(opfPath.count))
|
||||
}
|
||||
return pathPart
|
||||
}
|
||||
|
||||
public func normalizedLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
relativeToSpineIndex spineIndex: Int? = nil,
|
||||
bookIdentifier: String?
|
||||
) -> RDEPUBLocation? {
|
||||
let rawHref = location.href
|
||||
let hrefParts = rawHref.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
let pathPart = hrefParts.first.map(String.init) ?? rawHref
|
||||
let fragment = location.fragment ?? (hrefParts.count > 1 ? String(hrefParts[1]) : nil)
|
||||
|
||||
let normalizedTargetHref: String
|
||||
if pathPart.isEmpty {
|
||||
guard let spineIndex, parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
normalizedTargetHref = parser.spine[spineIndex].href.components(separatedBy: "#").first ?? parser.spine[spineIndex].href
|
||||
} else {
|
||||
guard let href = normalizedHref(pathPart, relativeToSpineIndex: spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
normalizedTargetHref = href
|
||||
}
|
||||
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: normalizedTargetHref,
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: fragment
|
||||
)
|
||||
}
|
||||
|
||||
public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? {
|
||||
parser.spine.firstIndex { item in
|
||||
self.normalizedHref(item.href) == normalizedHref
|
||||
}
|
||||
}
|
||||
|
||||
public func spineIndex(for location: RDEPUBLocation) -> Int? {
|
||||
guard let normalizedHref = normalizedHref(location.href) else {
|
||||
return nil
|
||||
}
|
||||
return spineIndex(forNormalizedHref: normalizedHref)
|
||||
}
|
||||
|
||||
public func href(forSpineIndex spineIndex: Int) -> String? {
|
||||
guard parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return parser.spine[spineIndex].href
|
||||
}
|
||||
|
||||
public func title(forSpineIndex spineIndex: Int) -> String? {
|
||||
guard parser.spine.indices.contains(spineIndex) else {
|
||||
return nil
|
||||
}
|
||||
return parser.spine[spineIndex].title
|
||||
}
|
||||
|
||||
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
|
||||
let targetHref = normalizedHref(href) ?? href.components(separatedBy: "#").first ?? href
|
||||
return parser.manifest.values.first {
|
||||
normalizedHref($0.href) == targetHref
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
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")
|
||||
private var activeTasks: [ObjectIdentifier: Bool] = [:]
|
||||
|
||||
public init(parser: RDEPUBParser) {
|
||||
self.parser = parser
|
||||
super.init()
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
||||
let taskID = ObjectIdentifier(urlSchemeTask as AnyObject)
|
||||
syncQueue.sync {
|
||||
activeTasks[taskID] = true
|
||||
}
|
||||
|
||||
let requestURL = urlSchemeTask.request.url
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "start")
|
||||
|
||||
guard let parser,
|
||||
let requestURL,
|
||||
let fileURL = parser.fileURL(forResourceURL: requestURL),
|
||||
fileManager.fileExists(atPath: fileURL.path) else {
|
||||
if let requestURL, Self.isOptionalResource(requestURL) {
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-optional-resource")
|
||||
let response = URLResponse(
|
||||
url: requestURL,
|
||||
mimeType: Self.mimeType(for: requestURL.pathExtension),
|
||||
expectedContentLength: 0,
|
||||
textEncodingName: Self.textEncodingName(for: requestURL.pathExtension)
|
||||
)
|
||||
urlSchemeTask.didReceive(response)
|
||||
urlSchemeTask.didReceive(Data())
|
||||
urlSchemeTask.didFinish()
|
||||
} else {
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file")
|
||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
||||
}
|
||||
clearTask(taskID)
|
||||
return
|
||||
}
|
||||
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
guard isTaskActive(taskID) else { return }
|
||||
|
||||
let response = URLResponse(
|
||||
url: requestURL,
|
||||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
||||
expectedContentLength: data.count,
|
||||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
||||
)
|
||||
urlSchemeTask.didReceive(response)
|
||||
urlSchemeTask.didReceive(data)
|
||||
urlSchemeTask.didFinish()
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
|
||||
} catch {
|
||||
guard isTaskActive(taskID) else { return }
|
||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
|
||||
urlSchemeTask.didFailWithError(error)
|
||||
}
|
||||
|
||||
clearTask(taskID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private static func mimeType(for pathExtension: String) -> String {
|
||||
switch pathExtension.lowercased() {
|
||||
case "html", "htm":
|
||||
return "text/html"
|
||||
case "xhtml":
|
||||
return "application/xhtml+xml"
|
||||
case "css":
|
||||
return "text/css"
|
||||
case "js":
|
||||
return "application/javascript"
|
||||
case "xml", "opf", "ncx":
|
||||
return "application/xml"
|
||||
case "svg":
|
||||
return "image/svg+xml"
|
||||
case "jpg", "jpeg":
|
||||
return "image/jpeg"
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
case "ttf":
|
||||
return "font/ttf"
|
||||
case "otf":
|
||||
return "font/otf"
|
||||
case "woff":
|
||||
return "font/woff"
|
||||
case "woff2":
|
||||
return "font/woff2"
|
||||
case "mp3":
|
||||
return "audio/mpeg"
|
||||
case "mp4":
|
||||
return "video/mp4"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
private static func textEncodingName(for pathExtension: String) -> String? {
|
||||
switch pathExtension.lowercased() {
|
||||
case "html", "htm", "xhtml", "css", "js", "xml", "opf", "ncx", "txt":
|
||||
return "utf-8"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func isOptionalResource(_ url: URL) -> Bool {
|
||||
switch url.pathExtension.lowercased() {
|
||||
case "ttf", "otf", "woff", "woff2", "css", "js", "jpg", "jpeg", "png", "gif", "webp", "svg":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
protocol RDEPUBSearchEngine {
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch]
|
||||
}
|
||||
|
||||
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
||||
private let parser: RDEPUBParser
|
||||
private let publication: RDEPUBPublication
|
||||
|
||||
init(parser: RDEPUBParser, publication: RDEPUBPublication) {
|
||||
self.parser = parser
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
var searchMatches: [RDEPUBSearchMatch] = []
|
||||
for item in publication.spine where item.linear {
|
||||
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let html = parser.htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let plainText = plainText(fromHTML: html, baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent())
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(item.href) ?? item.href
|
||||
searchMatches.append(contentsOf: matches(in: plainText, href: normalizedHref, keyword: normalizedKeyword))
|
||||
}
|
||||
|
||||
return searchMatches
|
||||
}
|
||||
|
||||
private func plainText(fromHTML html: String, baseURL: URL?) -> String {
|
||||
guard let data = html.data(using: .utf8) else {
|
||||
return fallbackPlainText(fromHTML: html)
|
||||
}
|
||||
|
||||
var options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
]
|
||||
if let baseURL {
|
||||
options[NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption")] = baseURL
|
||||
}
|
||||
|
||||
if let attributed = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
|
||||
return attributed.string
|
||||
}
|
||||
return fallbackPlainText(fromHTML: html)
|
||||
}
|
||||
|
||||
private func fallbackPlainText(fromHTML html: String) -> String {
|
||||
let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
|
||||
return stripped
|
||||
.replacingOccurrences(of: " ", with: " ")
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
.replacingOccurrences(of: """, with: "\"")
|
||||
}
|
||||
|
||||
private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let source = text as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else {
|
||||
return []
|
||||
}
|
||||
|
||||
var results: [RDEPUBSearchMatch] = []
|
||||
var localMatchIndex = 0
|
||||
var searchRange = NSRange(location: 0, length: fullLength)
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else {
|
||||
break
|
||||
}
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
results.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: href,
|
||||
progression: progression,
|
||||
previewText: previewText(in: source, matchRange: foundRange),
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: foundRange.length
|
||||
)
|
||||
)
|
||||
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength {
|
||||
break
|
||||
}
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
|
||||
let range = NSRange(location: start, length: max(end - start, 0))
|
||||
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBSearchMatch: Codable, Equatable {
|
||||
public var href: String
|
||||
public var progression: Double
|
||||
public var previewText: String
|
||||
public var localMatchIndex: Int
|
||||
public var rangeLocation: Int?
|
||||
public var rangeLength: Int
|
||||
|
||||
public init(
|
||||
href: String,
|
||||
progression: Double,
|
||||
previewText: String,
|
||||
localMatchIndex: Int,
|
||||
rangeLocation: Int? = nil,
|
||||
rangeLength: Int
|
||||
) {
|
||||
self.href = href
|
||||
self.progression = progression
|
||||
self.previewText = previewText
|
||||
self.localMatchIndex = localMatchIndex
|
||||
self.rangeLocation = rangeLocation
|
||||
self.rangeLength = rangeLength
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBSearchResult: Codable, Equatable {
|
||||
public var keyword: String
|
||||
public var totalMatchCount: Int
|
||||
public var currentMatchIndex: Int?
|
||||
public var currentMatch: RDEPUBSearchMatch?
|
||||
|
||||
public init(
|
||||
keyword: String,
|
||||
totalMatchCount: Int,
|
||||
currentMatchIndex: Int?,
|
||||
currentMatch: RDEPUBSearchMatch?
|
||||
) {
|
||||
self.keyword = keyword
|
||||
self.totalMatchCount = totalMatchCount
|
||||
self.currentMatchIndex = currentMatchIndex
|
||||
self.currentMatch = currentMatch
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBSearchState: Codable, Equatable {
|
||||
public var keyword: String
|
||||
public var matches: [RDEPUBSearchMatch]
|
||||
public var currentMatchIndex: Int?
|
||||
|
||||
public init(keyword: String, matches: [RDEPUBSearchMatch], currentMatchIndex: Int? = nil) {
|
||||
self.keyword = keyword
|
||||
self.matches = matches
|
||||
self.currentMatchIndex = currentMatchIndex
|
||||
}
|
||||
|
||||
public var currentMatch: RDEPUBSearchMatch? {
|
||||
guard let currentMatchIndex,
|
||||
matches.indices.contains(currentMatchIndex) else {
|
||||
return nil
|
||||
}
|
||||
return matches[currentMatchIndex]
|
||||
}
|
||||
|
||||
public var result: RDEPUBSearchResult {
|
||||
RDEPUBSearchResult(
|
||||
keyword: keyword,
|
||||
totalMatchCount: matches.count,
|
||||
currentMatchIndex: currentMatchIndex.map { $0 + 1 },
|
||||
currentMatch: currentMatch
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBSearchPresentationResource: Codable, Equatable {
|
||||
public var href: String
|
||||
public var matchCount: Int
|
||||
public var activeLocalMatchIndex: Int?
|
||||
|
||||
public init(href: String, matchCount: Int, activeLocalMatchIndex: Int? = nil) {
|
||||
self.href = href
|
||||
self.matchCount = matchCount
|
||||
self.activeLocalMatchIndex = activeLocalMatchIndex
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBSearchPresentation: Codable, Equatable {
|
||||
public var keyword: String
|
||||
public var resources: [RDEPUBSearchPresentationResource]
|
||||
|
||||
public init(keyword: String, resources: [RDEPUBSearchPresentationResource]) {
|
||||
self.keyword = keyword
|
||||
self.resources = resources
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
|
||||
public enum RDEPUBStyleSheetBuilder {
|
||||
public static func injectPaginationCSS(
|
||||
into html: String,
|
||||
presentation: RDEPUBPresentationStyle
|
||||
) -> String {
|
||||
let styleTag = "<style id=\"ss-reader-pagination\">\(measurementCSS(for: presentation))</style>"
|
||||
if html.range(of: "</head>", options: .caseInsensitive) != nil {
|
||||
return html.replacingOccurrences(of: "</head>", with: styleTag + "</head>", options: .caseInsensitive)
|
||||
}
|
||||
if html.range(of: "<body", options: .caseInsensitive) != nil {
|
||||
return html.replacingOccurrences(of: "<body", with: styleTag + "<body", options: .caseInsensitive)
|
||||
}
|
||||
return styleTag + html
|
||||
}
|
||||
|
||||
public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String {
|
||||
let values = cssValues(for: presentation)
|
||||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
||||
let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? ""
|
||||
|
||||
return """
|
||||
html {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: \(values.viewportWidth)px !important;
|
||||
height: \(values.viewportHeight)px !important;
|
||||
min-height: \(values.viewportHeight)px !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
position: relative !important;
|
||||
width: \(values.viewportWidth)px !important;
|
||||
height: \(values.viewportHeight)px !important;
|
||||
min-height: \(values.viewportHeight)px !important;
|
||||
overflow: hidden !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
#ss-reader-viewport {
|
||||
position: relative !important;
|
||||
width: \(values.viewportWidth)px !important;
|
||||
height: \(values.viewportHeight)px !important;
|
||||
min-height: \(values.viewportHeight)px !important;
|
||||
overflow: hidden !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
#ss-reader-content {
|
||||
position: relative !important;
|
||||
box-sizing: border-box !important;
|
||||
width: \(values.viewportWidth)px !important;
|
||||
height: \(values.viewportHeight)px !important;
|
||||
min-height: \(values.viewportHeight)px !important;
|
||||
overflow: visible !important;
|
||||
padding: \(values.paddingTop)px \(values.paddingRight)px \(values.paddingBottom)px \(values.paddingLeft)px !important;
|
||||
font-size: \(values.fontSize)px !important;
|
||||
line-height: \(values.lineHeight) !important;
|
||||
-webkit-column-width: \(values.contentWidth)px !important;
|
||||
column-width: \(values.contentWidth)px !important;
|
||||
-webkit-column-gap: \(values.columnGap)px !important;
|
||||
column-gap: \(values.columnGap)px !important;
|
||||
-webkit-column-fill: auto !important;
|
||||
column-fill: auto !important;
|
||||
transform-origin: top left !important;
|
||||
will-change: transform !important;
|
||||
\(backgroundCSS)
|
||||
\(textCSS)
|
||||
}
|
||||
#ss-reader-content img, #ss-reader-content svg, #ss-reader-content video, #ss-reader-content canvas, #ss-reader-content iframe {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
break-inside: avoid-column !important;
|
||||
-webkit-column-break-inside: avoid !important;
|
||||
}
|
||||
#ss-reader-content mark.ss-reader-highlight {
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
color: inherit !important;
|
||||
}
|
||||
#ss-reader-content mark.ss-reader-highlight-highlight {
|
||||
text-decoration: none !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
#ss-reader-content mark.ss-reader-highlight-underline {
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
text-decoration-line: underline !important;
|
||||
text-decoration-thickness: 2px !important;
|
||||
text-decoration-skip-ink: auto !important;
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String {
|
||||
let values = cssValues(for: presentation)
|
||||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
||||
let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? ""
|
||||
|
||||
return """
|
||||
html {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: \(values.viewportWidth)px !important;
|
||||
height: \(values.viewportHeight)px !important;
|
||||
min-height: \(values.viewportHeight)px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
body {
|
||||
margin: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
position: relative !important;
|
||||
width: \(values.viewportWidth)px !important;
|
||||
height: \(values.viewportHeight)px !important;
|
||||
min-height: \(values.viewportHeight)px !important;
|
||||
overflow: visible !important;
|
||||
padding: \(values.paddingTop)px \(values.paddingRight)px \(values.paddingBottom)px \(values.paddingLeft)px !important;
|
||||
font-size: \(values.fontSize)px !important;
|
||||
line-height: \(values.lineHeight) !important;
|
||||
-webkit-column-width: \(values.contentWidth)px !important;
|
||||
column-width: \(values.contentWidth)px !important;
|
||||
-webkit-column-gap: \(values.columnGap)px !important;
|
||||
column-gap: \(values.columnGap)px !important;
|
||||
-webkit-column-fill: auto !important;
|
||||
column-fill: auto !important;
|
||||
transform-origin: top left !important;
|
||||
\(backgroundCSS)
|
||||
\(textCSS)
|
||||
}
|
||||
img, svg, video, canvas, iframe {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
break-inside: avoid-column !important;
|
||||
-webkit-column-break-inside: avoid !important;
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String {
|
||||
let style = measurementCSS(for: presentation)
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "`", with: "\\`")
|
||||
|
||||
let viewportWidth = Double(max(1, presentation.viewportSize.width))
|
||||
let viewportWidthJS = String(format: "%.3f", viewportWidth)
|
||||
|
||||
return """
|
||||
(function() {
|
||||
var style = document.getElementById('ss-reader-pagination');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'ss-reader-pagination';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = `\(style)`;
|
||||
var scrollingElement = document.scrollingElement || document.documentElement || document.body;
|
||||
var totalWidth = Math.max(
|
||||
scrollingElement ? scrollingElement.scrollWidth : 0,
|
||||
scrollingElement ? scrollingElement.offsetWidth : 0,
|
||||
document.body ? document.body.scrollWidth : 0,
|
||||
document.body ? document.body.offsetWidth : 0,
|
||||
document.documentElement ? document.documentElement.scrollWidth : 0,
|
||||
document.documentElement ? document.documentElement.offsetWidth : 0
|
||||
);
|
||||
return Math.max(1, Math.ceil(totalWidth / \(viewportWidthJS)));
|
||||
})();
|
||||
"""
|
||||
}
|
||||
|
||||
private static func cssValues(for presentation: RDEPUBPresentationStyle) -> (
|
||||
viewportWidth: String,
|
||||
viewportHeight: String,
|
||||
contentWidth: String,
|
||||
columnGap: String,
|
||||
paddingTop: String,
|
||||
paddingRight: String,
|
||||
paddingBottom: String,
|
||||
paddingLeft: String,
|
||||
fontSize: String,
|
||||
lineHeight: String
|
||||
) {
|
||||
let viewportWidth = max(1, presentation.viewportSize.width)
|
||||
let viewportHeight = max(1, presentation.viewportSize.height)
|
||||
let contentInsets = presentation.contentInsets
|
||||
let contentWidth = max(1, viewportWidth - contentInsets.left - contentInsets.right)
|
||||
let columnGap = max(0, contentInsets.left + contentInsets.right)
|
||||
|
||||
return (
|
||||
viewportWidth: String(format: "%.3f", viewportWidth),
|
||||
viewportHeight: String(format: "%.3f", viewportHeight),
|
||||
contentWidth: String(format: "%.3f", contentWidth),
|
||||
columnGap: String(format: "%.3f", columnGap),
|
||||
paddingTop: String(format: "%.3f", contentInsets.top),
|
||||
paddingRight: String(format: "%.3f", contentInsets.right),
|
||||
paddingBottom: String(format: "%.3f", contentInsets.bottom),
|
||||
paddingLeft: String(format: "%.3f", contentInsets.left),
|
||||
fontSize: String(format: "%.3f", presentation.fontSize),
|
||||
lineHeight: String(format: "%.3f", max(1, presentation.lineHeightMultiple))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
func configureWebViewIfNeeded(publication: RDEPUBPublication) {
|
||||
let parser = publication.parser
|
||||
let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString
|
||||
if publicationKey == configuredPublicationKey, webView != nil {
|
||||
return
|
||||
}
|
||||
|
||||
teardownWebView()
|
||||
|
||||
let userContentController = WKUserContentController()
|
||||
RDEPUBJavaScriptBridge.messageNames.forEach {
|
||||
userContentController.add(self, name: $0)
|
||||
}
|
||||
userContentController.addUserScript(
|
||||
WKUserScript(
|
||||
source: RDEPUBJavaScriptBridge.userScript,
|
||||
injectionTime: .atDocumentEnd,
|
||||
forMainFrameOnly: true
|
||||
)
|
||||
)
|
||||
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .nonPersistent()
|
||||
configuration.userContentController = userContentController
|
||||
let schemeHandler = RDEPUBResourceURLSchemeHandler(parser: parser)
|
||||
configuration.setURLSchemeHandler(schemeHandler, forURLScheme: RDEPUBResourceURLSchemeHandler.scheme)
|
||||
|
||||
let webView = RDEPUBAnnotationWebView(frame: bounds, configuration: configuration)
|
||||
webView.onSelectionAction = { [weak self] action in
|
||||
guard let self else { return }
|
||||
self.handleSelectionMenuAction(action)
|
||||
}
|
||||
UIMenuController.shared.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
||||
]
|
||||
webView.navigationDelegate = self
|
||||
webView.scrollView.isScrollEnabled = false
|
||||
webView.scrollView.showsHorizontalScrollIndicator = false
|
||||
webView.scrollView.showsVerticalScrollIndicator = false
|
||||
webView.scrollView.bounces = false
|
||||
webView.scrollView.alwaysBounceHorizontal = false
|
||||
webView.scrollView.alwaysBounceVertical = false
|
||||
webView.scrollView.clipsToBounds = true
|
||||
webView.scrollView.panGestureRecognizer.isEnabled = false
|
||||
webView.scrollView.pinchGestureRecognizer?.isEnabled = false
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
webView.clipsToBounds = true
|
||||
if #available(iOS 16.4, *) {
|
||||
webView.isInspectable = true
|
||||
}
|
||||
|
||||
addSubview(webView)
|
||||
webView.frame = bounds
|
||||
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
|
||||
self.schemeHandler = schemeHandler
|
||||
self.webView = webView
|
||||
self.configuredPublicationKey = publicationKey
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
|
||||
}
|
||||
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) {
|
||||
delegate?.epubWebView(self, didRequestSelectionAction: action)
|
||||
clearWebSelection()
|
||||
}
|
||||
|
||||
func clearWebSelection() {
|
||||
webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();")
|
||||
}
|
||||
|
||||
func teardownWebView() {
|
||||
cancelFixedLayoutReadyFallback()
|
||||
if let webView {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
||||
}
|
||||
webView?.navigationDelegate = nil
|
||||
if let userContentController = webView?.configuration.userContentController {
|
||||
RDEPUBJavaScriptBridge.messageNames.forEach {
|
||||
userContentController.removeScriptMessageHandler(forName: $0)
|
||||
}
|
||||
}
|
||||
(webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil
|
||||
webView?.removeFromSuperview()
|
||||
webView = nil
|
||||
schemeHandler = nil
|
||||
configuredPublicationKey = nil
|
||||
currentLoadSignature = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
var ss_hexString: String {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
return String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
public func loadFixedSpread(
|
||||
parser: RDEPUBParser,
|
||||
spread: EPUBFixedSpread,
|
||||
viewportSize: CGSize,
|
||||
contentInset: UIEdgeInsets,
|
||||
backgroundColor: UIColor?
|
||||
) {
|
||||
let request = RDEPUBRenderRequest.fixed(
|
||||
RDEPUBFixedRenderRequest(
|
||||
spread: spread,
|
||||
viewportSize: viewportSize,
|
||||
contentInset: contentInset,
|
||||
backgroundColorCSS: backgroundColor?.ss_hexString,
|
||||
fit: .page,
|
||||
searchPresentation: nil
|
||||
)
|
||||
)
|
||||
load(publication: parser.makePublication(), request: request)
|
||||
}
|
||||
|
||||
func handleFixedLayoutLoad(
|
||||
publication: RDEPUBPublication,
|
||||
request: RDEPUBFixedRenderRequest,
|
||||
loadSignature: String,
|
||||
in webView: WKWebView
|
||||
) {
|
||||
currentPageIndex = 0
|
||||
currentTotalPagesInChapter = 1
|
||||
viewportSize = request.viewportSize
|
||||
currentPadding = request.contentInset
|
||||
currentFontSize = 16
|
||||
currentLineHeightMultiple = 1.5
|
||||
currentThemeBackgroundColor = request.backgroundColorCSS
|
||||
currentThemeTextColor = nil
|
||||
targetLocation = nil
|
||||
pendingHighlights = []
|
||||
fixedSpread = request.spread
|
||||
isFixedLayout = true
|
||||
pendingProgressionRequest = false
|
||||
currentLoadSignature = loadSignature
|
||||
scheduleFixedLayoutReadyFallback()
|
||||
|
||||
let html = RDEPUBFixedLayoutTemplate.html(for: request, publication: publication)
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
|
||||
webView.loadHTMLString(
|
||||
html,
|
||||
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
|
||||
)
|
||||
}
|
||||
|
||||
func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String {
|
||||
[
|
||||
publicationKey,
|
||||
"fixed",
|
||||
request.spread.resources.map(\.href).joined(separator: "|"),
|
||||
String(format: "%.2f", request.viewportSize.width),
|
||||
String(format: "%.2f", request.viewportSize.height),
|
||||
String(format: "%.2f", request.contentInset.top),
|
||||
String(format: "%.2f", request.contentInset.left),
|
||||
String(format: "%.2f", request.contentInset.bottom),
|
||||
String(format: "%.2f", request.contentInset.right),
|
||||
request.backgroundColorCSS ?? "",
|
||||
request.fit.rawValue
|
||||
].joined(separator: "#")
|
||||
}
|
||||
|
||||
func scheduleFixedLayoutReadyFallback() {
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired")
|
||||
self?.applySearchDecorationsIfNeeded {
|
||||
self?.rendered()
|
||||
}
|
||||
}
|
||||
fixedLayoutReadyWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem)
|
||||
}
|
||||
|
||||
func cancelFixedLayoutReadyFallback() {
|
||||
fixedLayoutReadyWorkItem?.cancel()
|
||||
fixedLayoutReadyWorkItem = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
func internalLocation(from url: URL) -> RDEPUBLocation? {
|
||||
let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
|
||||
let fragment = url.fragment
|
||||
guard !href.isEmpty || fragment != nil else { return nil }
|
||||
return RDEPUBLocation(href: href, progression: 0, fragment: fragment)
|
||||
}
|
||||
|
||||
func currentLocation(from body: [String: Any]) -> RDEPUBLocation {
|
||||
let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0
|
||||
let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue
|
||||
let fragment = body["fragment"] as? String
|
||||
return RDEPUBLocation(href: currentHref, progression: progression, lastProgression: lastProgression, fragment: fragment)
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBWebView: WKNavigationDelegate {
|
||||
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url)
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didCommit", url: webView.url)
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
|
||||
applyPresentation()
|
||||
}
|
||||
|
||||
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url)
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error)
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
|
||||
}
|
||||
|
||||
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,
|
||||
let url = navigationAction.request.url else {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
if url.scheme == RDEPUBResourceURLSchemeHandler.scheme,
|
||||
let location = internalLocation(from: url) {
|
||||
delegate?.epubWebView(self, didActivateInternalLink: location, fromSpineIndex: currentSpineIndex)
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
if let scheme = url.scheme?.lowercased(), ["http", "https", "mailto", "tel"].contains(scheme) {
|
||||
delegate?.epubWebView(self, didActivateExternalLink: url)
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBWebView: WKScriptMessageHandler {
|
||||
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body)
|
||||
switch message.name {
|
||||
case RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue:
|
||||
guard let body = message.body as? [String: Any] else { return }
|
||||
delegate?.epubWebView(self, didUpdateLocation: currentLocation(from: body), spineIndex: currentSpineIndex)
|
||||
case RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue:
|
||||
if let body = message.body as? [String: Any],
|
||||
let text = body["text"] as? String,
|
||||
let rangeInfo = body["rangeInfo"] as? String {
|
||||
let location = currentLocation(from: body)
|
||||
let selection = RDEPUBSelection(location: location, text: text, rangeInfo: rangeInfo)
|
||||
delegate?.epubWebView(self, didChangeSelection: selection, spineIndex: currentSpineIndex)
|
||||
} else {
|
||||
delegate?.epubWebView(self, didChangeSelection: nil, spineIndex: currentSpineIndex)
|
||||
}
|
||||
case RDEPUBJavaScriptBridgeMessage.internalLink.rawValue:
|
||||
guard let body = message.body as? [String: Any],
|
||||
let href = body["href"] as? String else { return }
|
||||
let location = RDEPUBLocation(href: href, progression: 0)
|
||||
delegate?.epubWebView(self, didActivateInternalLink: location, fromSpineIndex: currentSpineIndex)
|
||||
case RDEPUBJavaScriptBridgeMessage.externalLink.rawValue:
|
||||
guard let body = message.body as? [String: Any],
|
||||
let urlString = body["url"] as? String,
|
||||
let url = URL(string: urlString) else { return }
|
||||
delegate?.epubWebView(self, didActivateExternalLink: url)
|
||||
case RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue:
|
||||
delegate?.epubWebView(self, didLogJavaScriptError: "\(message.body)")
|
||||
case RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue:
|
||||
applySearchDecorationsIfNeeded {
|
||||
self.rendered()
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
public func loadPage(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
pageIndex: Int,
|
||||
totalPagesInChapter: Int,
|
||||
viewportSize: CGSize,
|
||||
padding: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
themeBackgroundColor: String?,
|
||||
themeTextColor: String?,
|
||||
targetLocation: RDEPUBLocation?,
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
let href = parser.spine.indices.contains(spineIndex) ? parser.spine[spineIndex].href : ""
|
||||
let request = RDEPUBRenderRequest.reflowable(
|
||||
RDEPUBReflowableRenderRequest(
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
pageIndex: pageIndex,
|
||||
totalPagesInChapter: totalPagesInChapter,
|
||||
presentation: RDEPUBPresentationStyle(
|
||||
viewportSize: viewportSize,
|
||||
contentInsets: padding,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
themeBackgroundColor: themeBackgroundColor,
|
||||
themeTextColor: themeTextColor
|
||||
),
|
||||
targetLocation: targetLocation,
|
||||
highlights: highlights,
|
||||
searchPresentation: nil
|
||||
)
|
||||
)
|
||||
load(publication: parser.makePublication(), request: request)
|
||||
}
|
||||
|
||||
func handleReflowableLoad(
|
||||
publication: RDEPUBPublication,
|
||||
request: RDEPUBReflowableRenderRequest,
|
||||
loadSignature: String,
|
||||
in webView: WKWebView
|
||||
) {
|
||||
guard publication.spine.indices.contains(request.spineIndex),
|
||||
let requestURL = publication.resourceResolver.resourceURL(forRelativePath: request.href) else {
|
||||
return
|
||||
}
|
||||
|
||||
currentPageIndex = request.pageIndex
|
||||
currentTotalPagesInChapter = max(1, request.totalPagesInChapter)
|
||||
viewportSize = request.presentation.viewportSize
|
||||
currentPadding = request.presentation.contentInsets
|
||||
currentFontSize = request.presentation.fontSize
|
||||
currentLineHeightMultiple = request.presentation.lineHeightMultiple
|
||||
currentThemeBackgroundColor = request.presentation.themeBackgroundColor
|
||||
currentThemeTextColor = request.presentation.themeTextColor
|
||||
targetLocation = request.targetLocation
|
||||
pendingHighlights = request.highlights
|
||||
fixedSpread = nil
|
||||
isFixedLayout = false
|
||||
pendingProgressionRequest = true
|
||||
|
||||
if currentLoadSignature == loadSignature {
|
||||
if didRenderCurrentRequest || webView.isLoading {
|
||||
let reason = didRenderCurrentRequest ? "already-rendered" : "still-loading"
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "skip duplicate request href=\(request.href) page=\(request.pageIndex) reason=\(reason)")
|
||||
return
|
||||
}
|
||||
|
||||
if webView.url == requestURL {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "reuse existing document href=\(request.href) page=\(request.pageIndex)")
|
||||
applyPresentation()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
currentLoadSignature = loadSignature
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "load-request", url: requestURL)
|
||||
webView.load(URLRequest(url: requestURL))
|
||||
}
|
||||
|
||||
func reflowableLoadSignature(publicationKey: String, request: RDEPUBReflowableRenderRequest) -> String {
|
||||
let targetSignature = [
|
||||
request.targetLocation?.href ?? "",
|
||||
String(request.targetLocation?.progression ?? 0),
|
||||
String(request.targetLocation?.lastProgression ?? 0),
|
||||
request.targetLocation?.fragment ?? ""
|
||||
].joined(separator: "|")
|
||||
let highlightSignature = request.highlights
|
||||
.map { [$0.id, $0.rangeInfo ?? "", $0.color, $0.style.rawValue].joined(separator: "|") }
|
||||
.joined(separator: ",")
|
||||
let presentation = request.presentation
|
||||
return [
|
||||
publicationKey,
|
||||
"reflowable",
|
||||
String(request.spineIndex),
|
||||
request.href,
|
||||
String(request.pageIndex),
|
||||
String(request.totalPagesInChapter),
|
||||
String(format: "%.2f", presentation.viewportSize.width),
|
||||
String(format: "%.2f", presentation.viewportSize.height),
|
||||
String(format: "%.2f", presentation.contentInsets.top),
|
||||
String(format: "%.2f", presentation.contentInsets.left),
|
||||
String(format: "%.2f", presentation.contentInsets.bottom),
|
||||
String(format: "%.2f", presentation.contentInsets.right),
|
||||
String(format: "%.2f", presentation.fontSize),
|
||||
String(format: "%.2f", presentation.lineHeightMultiple),
|
||||
presentation.themeBackgroundColor ?? "",
|
||||
presentation.themeTextColor ?? "",
|
||||
targetSignature,
|
||||
highlightSignature
|
||||
].joined(separator: "#")
|
||||
}
|
||||
|
||||
func applyPresentation() {
|
||||
guard let webView, let currentRenderRequest else { return }
|
||||
guard case .reflowable(let request) = currentRenderRequest else {
|
||||
return
|
||||
}
|
||||
|
||||
let script = RDEPUBJavaScriptBridge.applyPresentationScript(for: request)
|
||||
RDEPUBWebViewDebug.logJavaScript(
|
||||
debugScope,
|
||||
webView: webView,
|
||||
action: "applyPresentation",
|
||||
details: "spine=\(request.spineIndex) page=\(request.pageIndex) total=\(request.totalPagesInChapter)"
|
||||
)
|
||||
|
||||
webView.evaluateJavaScript(script) { [weak self] _, error in
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
RDEPUBWebViewDebug.logJavaScript(self.debugScope, webView: webView, action: "applyPresentationFailed", details: error.localizedDescription)
|
||||
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
self.applySearchDecorationsIfNeeded {
|
||||
self.rendered()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import WebKit
|
||||
|
||||
extension RDEPUBWebView {
|
||||
func applySearchDecorationsIfNeeded(completion: (() -> Void)? = nil) {
|
||||
guard let webView else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
|
||||
let presentation: RDEPUBSearchPresentation?
|
||||
switch currentRenderRequest {
|
||||
case .reflowable(let request):
|
||||
presentation = request.searchPresentation
|
||||
case .fixed(let request):
|
||||
presentation = request.searchPresentation
|
||||
case .none:
|
||||
presentation = nil
|
||||
}
|
||||
|
||||
let script = RDEPUBJavaScriptBridge.applySearchScript(for: presentation)
|
||||
webView.evaluateJavaScript(script) { [weak self] _, error in
|
||||
guard let self else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
if let error {
|
||||
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
|
||||
}
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
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)
|
||||
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String)
|
||||
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView)
|
||||
}
|
||||
|
||||
public extension RDEPUBWebViewDelegate {
|
||||
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {}
|
||||
}
|
||||
|
||||
final class RDEPUBAnnotationWebView: WKWebView {
|
||||
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
|
||||
|
||||
override var canBecomeFirstResponder: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
switch action {
|
||||
case #selector(rd_copy(_:)),
|
||||
#selector(rd_highlight(_:)),
|
||||
#selector(rd_annotate(_:)):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@objc func rd_copy(_ sender: Any?) {
|
||||
onSelectionAction?(.copy)
|
||||
}
|
||||
|
||||
@objc func rd_highlight(_ sender: Any?) {
|
||||
onSelectionAction?(.highlight)
|
||||
}
|
||||
|
||||
@objc func rd_annotate(_ sender: Any?) {
|
||||
onSelectionAction?(.annotate)
|
||||
}
|
||||
}
|
||||
|
||||
public final class RDEPUBWebView: UIView {
|
||||
public weak var delegate: RDEPUBWebViewDelegate?
|
||||
public var onRendered: (() -> Void)?
|
||||
|
||||
var publication: RDEPUBPublication?
|
||||
var currentRenderRequest: RDEPUBRenderRequest?
|
||||
var webView: WKWebView?
|
||||
var schemeHandler: RDEPUBResourceURLSchemeHandler?
|
||||
var configuredPublicationKey: String?
|
||||
var currentLoadSignature: String?
|
||||
var currentSpineIndex = 0
|
||||
var currentHref = ""
|
||||
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] = []
|
||||
var fixedSpread: EPUBFixedSpread?
|
||||
var isFixedLayout = false
|
||||
var pendingProgressionRequest = false
|
||||
var fixedLayoutReadyWorkItem: DispatchWorkItem?
|
||||
var didRenderCurrentRequest = false
|
||||
let debugScope = "ReaderWebView"
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
teardownWebView()
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
webView?.frame = bounds
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
delegate = nil
|
||||
onRendered = nil
|
||||
publication = nil
|
||||
currentRenderRequest = nil
|
||||
currentSpineIndex = 0
|
||||
currentHref = ""
|
||||
currentPageIndex = 0
|
||||
currentTotalPagesInChapter = 1
|
||||
viewportSize = .zero
|
||||
currentPadding = .zero
|
||||
currentFontSize = 16
|
||||
currentLineHeightMultiple = 1.5
|
||||
currentThemeBackgroundColor = nil
|
||||
currentThemeTextColor = nil
|
||||
targetLocation = nil
|
||||
pendingHighlights = []
|
||||
fixedSpread = nil
|
||||
isFixedLayout = false
|
||||
pendingProgressionRequest = false
|
||||
didRenderCurrentRequest = false
|
||||
teardownWebView()
|
||||
}
|
||||
|
||||
public func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) {
|
||||
configureWebViewIfNeeded(publication: publication)
|
||||
guard let webView else { return }
|
||||
|
||||
self.publication = publication
|
||||
self.currentRenderRequest = request
|
||||
self.currentSpineIndex = request.primarySpineIndex
|
||||
self.currentHref = request.primaryHref
|
||||
self.didRenderCurrentRequest = false
|
||||
cancelFixedLayoutReadyFallback()
|
||||
|
||||
let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? ""
|
||||
let loadSignature = pageLoadSignature(publicationKey: publicationKey, request: request)
|
||||
RDEPUBWebViewDebug.log(
|
||||
debugScope,
|
||||
message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))"
|
||||
)
|
||||
|
||||
switch request {
|
||||
case .reflowable(let reflowableRequest):
|
||||
handleReflowableLoad(publication: publication, request: reflowableRequest, loadSignature: loadSignature, in: webView)
|
||||
case .fixed(let fixedRequest):
|
||||
handleFixedLayoutLoad(publication: publication, request: fixedRequest, loadSignature: loadSignature, in: webView)
|
||||
}
|
||||
}
|
||||
|
||||
func pageLoadSignature(
|
||||
publicationKey: String,
|
||||
request: RDEPUBRenderRequest
|
||||
) -> String {
|
||||
switch request {
|
||||
case .reflowable(let request):
|
||||
return reflowableLoadSignature(publicationKey: publicationKey, request: request)
|
||||
case .fixed(let request):
|
||||
return fixedLayoutLoadSignature(publicationKey: publicationKey, request: request)
|
||||
}
|
||||
}
|
||||
|
||||
func rendered() {
|
||||
guard !didRenderCurrentRequest else { return }
|
||||
didRenderCurrentRequest = true
|
||||
cancelFixedLayoutReadyFallback()
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "rendered spine=\(currentSpineIndex) href=\(currentHref) fixed=\(isFixedLayout)")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.epubWebViewDidFinishRendering(self)
|
||||
self.onRendered?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
enum RDEPUBWebViewDebug {
|
||||
static var isEnabled: Bool = {
|
||||
if let configured = UserDefaults.standard.object(forKey: "RDEPUBWebViewDebugEnabled") as? Bool {
|
||||
return configured
|
||||
}
|
||||
#if DEBUG
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}()
|
||||
|
||||
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)")
|
||||
}
|
||||
|
||||
static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) {
|
||||
guard isEnabled else { return }
|
||||
let webViewToken = webViewID(webView)
|
||||
let urlText = summarizedURL(url)
|
||||
if let error {
|
||||
log(scope, message: "webView=\(webViewToken) event=\(event) url=\(urlText) error=\(error.localizedDescription)")
|
||||
} else {
|
||||
log(scope, message: "webView=\(webViewToken) event=\(event) url=\(urlText)")
|
||||
}
|
||||
}
|
||||
|
||||
static func logJavaScript(_ scope: String, webView: WKWebView?, action: String, details: String) {
|
||||
guard isEnabled else { return }
|
||||
log(scope, message: "webView=\(webViewID(webView)) js=\(action) \(details)")
|
||||
}
|
||||
|
||||
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))")
|
||||
}
|
||||
|
||||
static func logSchemeTask(_ scope: String, requestURL: URL?, fileURL: URL? = nil, event: String, error: Error? = nil) {
|
||||
guard isEnabled else { return }
|
||||
let requestText = summarizedURL(requestURL)
|
||||
let fileText = summarizedURL(fileURL)
|
||||
if let error {
|
||||
log(scope, message: "scheme=\(event) request=\(requestText) file=\(fileText) error=\(error.localizedDescription)")
|
||||
} else {
|
||||
log(scope, message: "scheme=\(event) request=\(requestText) file=\(fileText)")
|
||||
}
|
||||
}
|
||||
|
||||
static func summarizedURL(_ url: URL?) -> String {
|
||||
guard let url else { return "nil" }
|
||||
if let scheme = url.scheme, scheme == RDEPUBResourceURLSchemeHandler.scheme {
|
||||
return url.absoluteString
|
||||
}
|
||||
let path = url.path
|
||||
if path.isEmpty {
|
||||
return url.absoluteString
|
||||
}
|
||||
return url.lastPathComponent.isEmpty ? path : url.lastPathComponent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import UIKit
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
public struct RDEPUBDTCoreTextRenderer: RDEPUBTextRenderer {
|
||||
public init() {}
|
||||
|
||||
public static var isAvailable: Bool {
|
||||
#if canImport(DTCoreText)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
public func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBRenderedChapterContent {
|
||||
#if canImport(DTCoreText)
|
||||
let markedHTML = RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)
|
||||
guard let data = markedHTML.data(using: .utf8) else {
|
||||
throw RDEPUBTextRenderingError.htmlEncodingFailed
|
||||
}
|
||||
|
||||
guard let rendered = makeAttributedString(from: data, baseURL: baseURL, style: style) else {
|
||||
return fallbackRenderedContent(for: markedHTML, style: style)
|
||||
}
|
||||
|
||||
let attributedString = NSMutableAttributedString(attributedString: rendered)
|
||||
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: style)
|
||||
return RDEPUBRenderedChapterContent(attributedString: attributedString, fragmentOffsets: fragmentOffsets)
|
||||
#else
|
||||
let markedHTML = RDEPUBTextRendererSupport.injectFragmentMarkers(into: html)
|
||||
return fallbackRenderedContent(for: markedHTML, style: style)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func fallbackRenderedContent(for html: String, style: RDEPUBTextRenderStyle) -> RDEPUBRenderedChapterContent {
|
||||
let attributedString = RDEPUBTextRendererSupport.fallbackAttributedString(for: html, style: style)
|
||||
let fragmentOffsets = RDEPUBTextRendererSupport.extractFragmentOffsets(from: attributedString)
|
||||
RDEPUBTextRendererSupport.normalizeReadingAttributes(in: attributedString, style: style)
|
||||
return RDEPUBRenderedChapterContent(attributedString: attributedString, fragmentOffsets: fragmentOffsets)
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func makeAttributedString(from data: Data, baseURL: URL?, style: RDEPUBTextRenderStyle) -> NSAttributedString? {
|
||||
let builder = DTHTMLAttributedStringBuilder(
|
||||
html: data,
|
||||
options: dtOptions(baseURL: baseURL, style: style),
|
||||
documentAttributes: nil
|
||||
)
|
||||
return builder?.generatedAttributedString()
|
||||
}
|
||||
|
||||
private func dtOptions(baseURL: URL?, style: RDEPUBTextRenderStyle) -> [AnyHashable: Any] {
|
||||
var options: [AnyHashable: Any] = [
|
||||
NSTextSizeMultiplierDocumentOption: 1.0,
|
||||
DTDefaultFontFamily: style.font.familyName,
|
||||
DTDefaultFontName: style.font.fontName,
|
||||
DTDefaultFontSize: style.font.pointSize,
|
||||
DTDefaultLineHeightMultiplier: max((style.font.lineHeight + style.lineSpacing) / max(style.font.lineHeight, 1), 1),
|
||||
DTUseiOS6Attributes: true
|
||||
]
|
||||
|
||||
if let baseURL {
|
||||
options[NSBaseURLDocumentOption] = baseURL
|
||||
}
|
||||
if let textColor = style.textColor {
|
||||
options[DTDefaultTextColor] = textColor
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import UIKit
|
||||
|
||||
public struct RDEPUBTextPage: Equatable {
|
||||
public var absolutePageIndex: Int
|
||||
public var chapterIndex: Int
|
||||
public var spineIndex: Int
|
||||
public var href: String
|
||||
public var chapterTitle: String
|
||||
public var pageIndexInChapter: Int
|
||||
public var totalPagesInChapter: Int
|
||||
public var content: NSAttributedString
|
||||
public var contentRange: NSRange
|
||||
public var pageStartOffset: Int
|
||||
public var pageEndOffset: Int
|
||||
}
|
||||
|
||||
public struct RDEPUBTextChapter: Equatable {
|
||||
public var chapterIndex: Int
|
||||
public var spineIndex: Int
|
||||
public var href: String
|
||||
public var title: String
|
||||
public var attributedContent: NSAttributedString
|
||||
public var fragmentOffsets: [String: Int]
|
||||
public var pages: [RDEPUBTextPage]
|
||||
}
|
||||
|
||||
public struct RDEPUBTextBook: Equatable {
|
||||
public var chapters: [RDEPUBTextChapter]
|
||||
public var pages: [RDEPUBTextPage]
|
||||
|
||||
public init(chapters: [RDEPUBTextChapter], pages: [RDEPUBTextPage]) {
|
||||
self.chapters = chapters
|
||||
self.pages = pages
|
||||
}
|
||||
|
||||
public func page(at pageNumber: Int) -> RDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
|
||||
return nil
|
||||
}
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
public func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
|
||||
let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let targetOffset: Int
|
||||
if let fragment = normalizedLocation.fragment, let fragmentOffset = chapter.fragmentOffsets[fragment] {
|
||||
targetOffset = fragmentOffset
|
||||
} else {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
targetOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * normalizedLocation.navigationProgression))))
|
||||
}
|
||||
|
||||
if let page = chapter.pages.first(where: { targetOffset >= $0.pageStartOffset && targetOffset <= $0.pageEndOffset }) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
return chapter.pages.last.map { $0.absolutePageIndex + 1 }
|
||||
}
|
||||
|
||||
public func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard let page = page(at: pageNumber),
|
||||
let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: page.href,
|
||||
progression: Double(page.pageStartOffset) / Double(totalLength),
|
||||
lastProgression: Double(page.pageEndOffset) / Double(totalLength),
|
||||
fragment: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public final class RDEPUBTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
|
||||
public init(renderer: RDEPUBTextRenderer) {
|
||||
self.renderer = renderer
|
||||
}
|
||||
|
||||
public convenience init() {
|
||||
self.init(renderer: RDEPUBDTCoreTextRenderer())
|
||||
}
|
||||
|
||||
public func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook {
|
||||
var chapters: [RDEPUBTextChapter] = []
|
||||
var flatPages: [RDEPUBTextPage] = []
|
||||
|
||||
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
|
||||
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let normalizedHTML = normalizeHTML(rawHTML)
|
||||
let rendered = try renderer.renderChapter(
|
||||
html: normalizedHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
style: style
|
||||
)
|
||||
|
||||
let plainText = rendered.attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if shouldSkipChapter(item: item, text: plainText) {
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterIndex = chapters.count
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
let pageRanges = content.length > 0 ? content.ss_pageRanges(size: pageSize) : []
|
||||
let effectivePageRanges = pageRanges.isEmpty && content.length > 0
|
||||
? [NSRange(location: 0, length: content.length)]
|
||||
: pageRanges
|
||||
|
||||
let pages = effectivePageRanges.enumerated().map { localPageIndex, range in
|
||||
RDEPUBTextPage(
|
||||
absolutePageIndex: flatPages.count + localPageIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectivePageRanges.count,
|
||||
content: content.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0)
|
||||
)
|
||||
}
|
||||
|
||||
chapters.append(
|
||||
RDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
attributedContent: content.copy() as! NSAttributedString,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pages: pages
|
||||
)
|
||||
)
|
||||
flatPages.append(contentsOf: pages)
|
||||
}
|
||||
|
||||
return RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
}
|
||||
|
||||
private func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
|
||||
if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
|
||||
tocItem.href.components(separatedBy: "#").first == item.href
|
||||
})?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return title
|
||||
}
|
||||
let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmedTitle.isEmpty ? item.href : trimmedTitle
|
||||
}
|
||||
|
||||
private func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
[item] + flattenedTOCItems(from: item.children)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldSkipChapter(item: RDEPUBSpineItem, text: String) -> Bool {
|
||||
let lowercasedHref = item.href.lowercased()
|
||||
if text.isEmpty && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func normalizeHTML(_ html: String) -> String {
|
||||
var cleanedHTML = html
|
||||
let replacements: [(pattern: String, template: String)] = [
|
||||
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
|
||||
(#"\r"#, "\n"),
|
||||
(#"\n+"#, "\n")
|
||||
]
|
||||
|
||||
for replacement in replacements {
|
||||
if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) {
|
||||
cleanedHTML = regex.stringByReplacingMatches(
|
||||
in: cleanedHTML,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: cleanedHTML.utf16.count),
|
||||
withTemplate: replacement.template
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedHTML
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import CoreText
|
||||
import UIKit
|
||||
|
||||
extension NSAttributedString {
|
||||
func ss_pageRanges(size: CGSize) -> [NSRange] {
|
||||
var ranges: [NSRange] = []
|
||||
let framesetter = CTFramesetterCreateWithAttributedString(self)
|
||||
let path = CGPath(rect: CGRect(origin: .zero, size: size), transform: nil)
|
||||
var visibleRange = CFRangeMake(0, 0)
|
||||
var location = 0
|
||||
|
||||
while visibleRange.location + visibleRange.length < length {
|
||||
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(location, 0), path, nil)
|
||||
visibleRange = CTFrameGetVisibleStringRange(frame)
|
||||
guard visibleRange.length > 0 else {
|
||||
break
|
||||
}
|
||||
ranges.append(NSRange(location: location, length: visibleRange.length))
|
||||
location += visibleRange.length
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
var ss_cssString: String {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
return String(format: "rgba(%d, %d, %d, %.3f)", Int(red * 255), Int(green * 255), Int(blue * 255), alpha)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import UIKit
|
||||
|
||||
public struct RDEPUBTextRenderStyle {
|
||||
public var font: UIFont
|
||||
public var lineSpacing: CGFloat
|
||||
public var textColor: UIColor?
|
||||
public var backgroundColor: UIColor?
|
||||
|
||||
public init(font: UIFont, lineSpacing: CGFloat, textColor: UIColor? = nil, backgroundColor: UIColor? = nil) {
|
||||
self.font = font
|
||||
self.lineSpacing = lineSpacing
|
||||
self.textColor = textColor
|
||||
self.backgroundColor = backgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBRenderedChapterContent {
|
||||
public var attributedString: NSAttributedString
|
||||
public var fragmentOffsets: [String: Int]
|
||||
|
||||
public init(attributedString: NSAttributedString, fragmentOffsets: [String: Int]) {
|
||||
self.attributedString = attributedString
|
||||
self.fragmentOffsets = fragmentOffsets
|
||||
}
|
||||
}
|
||||
|
||||
public protocol RDEPUBTextRenderer {
|
||||
func renderChapter(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBRenderedChapterContent
|
||||
}
|
||||
|
||||
public enum RDEPUBTextRenderingError: LocalizedError {
|
||||
case htmlEncodingFailed
|
||||
case htmlImportFailed
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .htmlEncodingFailed:
|
||||
return "HTML 编码失败"
|
||||
case .htmlImportFailed:
|
||||
return "HTML 富文本导入失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import UIKit
|
||||
|
||||
enum RDEPUBTextRendererSupport {
|
||||
static func injectFragmentMarkers(into html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
}
|
||||
return regex.stringByReplacingMatches(
|
||||
in: html,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: html.utf16.count),
|
||||
withTemplate: "${id=$2}$1"
|
||||
)
|
||||
}
|
||||
|
||||
static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
|
||||
let markerPattern = #"\$\{id=([^}]+)\}"#
|
||||
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let mutableString = NSMutableString(string: attributedString.string)
|
||||
var fragmentOffsets: [String: Int] = [:]
|
||||
var searchRange = NSRange(location: 0, length: mutableString.length)
|
||||
var offsetAdjustment = 0
|
||||
|
||||
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
|
||||
let fullMatch = mutableString.substring(with: match.range) as NSString
|
||||
let fragmentID = fullMatch
|
||||
.replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length))
|
||||
.replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression)
|
||||
|
||||
let adjustedLocation = max(0, match.range.location + offsetAdjustment)
|
||||
fragmentOffsets[fragmentID] = adjustedLocation
|
||||
attributedString.deleteCharacters(in: match.range)
|
||||
mutableString.deleteCharacters(in: match.range)
|
||||
offsetAdjustment -= match.range.length
|
||||
searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location)
|
||||
}
|
||||
|
||||
return fragmentOffsets
|
||||
}
|
||||
|
||||
static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, style: RDEPUBTextRenderStyle) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
|
||||
let sourceFont = attributes[.font] as? UIFont
|
||||
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: style.lineSpacing)
|
||||
paragraph.lineSpacing = style.lineSpacing
|
||||
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, style.lineSpacing / 2)
|
||||
|
||||
var updatedAttributes = attributes
|
||||
updatedAttributes[.font] = normalizedFont(from: sourceFont, baseFont: style.font)
|
||||
updatedAttributes[.paragraphStyle] = paragraph
|
||||
if let textColor = style.textColor {
|
||||
updatedAttributes[.foregroundColor] = textColor
|
||||
}
|
||||
attributedString.setAttributes(updatedAttributes, range: range)
|
||||
}
|
||||
}
|
||||
|
||||
static func fallbackAttributedString(for html: String, style: RDEPUBTextRenderStyle) -> NSMutableAttributedString {
|
||||
let fallbackAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: style.font,
|
||||
.paragraphStyle: paragraphStyle(lineSpacing: style.lineSpacing),
|
||||
.foregroundColor: style.textColor ?? UIColor.black
|
||||
]
|
||||
return NSMutableAttributedString(string: html, attributes: fallbackAttributes)
|
||||
}
|
||||
|
||||
private static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
|
||||
guard let sourceFont else {
|
||||
return baseFont
|
||||
}
|
||||
let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic])
|
||||
if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
|
||||
return UIFont(descriptor: descriptor, size: baseFont.pointSize)
|
||||
}
|
||||
return baseFont
|
||||
}
|
||||
|
||||
private static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle {
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = lineSpacing
|
||||
style.paragraphSpacing = max(6, lineSpacing / 2)
|
||||
return style
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBTextSearchEngine: RDEPUBSearchEngine {
|
||||
private let textBook: RDEPUBTextBook
|
||||
private let publication: RDEPUBPublication
|
||||
|
||||
init(textBook: RDEPUBTextBook, publication: RDEPUBPublication) {
|
||||
self.textBook = textBook
|
||||
self.publication = publication
|
||||
}
|
||||
|
||||
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKeyword.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
var matches: [RDEPUBSearchMatch] = []
|
||||
for chapter in textBook.chapters {
|
||||
let source = chapter.attributedContent.string as NSString
|
||||
let fullLength = source.length
|
||||
guard fullLength > 0 else {
|
||||
continue
|
||||
}
|
||||
|
||||
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
|
||||
var localMatchIndex = 0
|
||||
var searchRange = NSRange(location: 0, length: fullLength)
|
||||
|
||||
while searchRange.length > 0 {
|
||||
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
||||
guard foundRange.location != NSNotFound else {
|
||||
break
|
||||
}
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
matches.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: normalizedHref,
|
||||
progression: progression,
|
||||
previewText: previewText(in: source, matchRange: foundRange),
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: foundRange.length
|
||||
)
|
||||
)
|
||||
|
||||
localMatchIndex += 1
|
||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||||
if nextLocation >= fullLength {
|
||||
break
|
||||
}
|
||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||||
let previewRadius = 12
|
||||
let start = max(matchRange.location - previewRadius, 0)
|
||||
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
|
||||
let range = NSRange(location: start, length: max(end - start, 0))
|
||||
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import UIKit
|
||||
|
||||
/// 将纯文本文件构建为 RDEPUBTextBook,复用 EPUB 文本渲染和分页管线
|
||||
public final class RDPlainTextBookBuilder {
|
||||
private let renderer: RDEPUBTextRenderer
|
||||
|
||||
public init(renderer: RDEPUBTextRenderer = RDEPUBDTCoreTextRenderer()) {
|
||||
self.renderer = renderer
|
||||
}
|
||||
|
||||
public func build(
|
||||
textFileURL: URL,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook {
|
||||
let rawText = rd_decodeTextFile(url: textFileURL)
|
||||
let chapterSpecs = splitChapters(from: rawText)
|
||||
|
||||
var chapters: [RDEPUBTextChapter] = []
|
||||
var flatPages: [RDEPUBTextPage] = []
|
||||
|
||||
for (index, spec) in chapterSpecs.enumerated() {
|
||||
let html = wrapTextAsHTML(spec.content)
|
||||
let rendered = try renderer.renderChapter(html: html, baseURL: nil, style: style)
|
||||
|
||||
let content = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||||
let pageRanges = content.length > 0 ? content.ss_pageRanges(size: pageSize) : []
|
||||
let effectivePageRanges = pageRanges.isEmpty && content.length > 0
|
||||
? [NSRange(location: 0, length: content.length)]
|
||||
: pageRanges
|
||||
|
||||
let href = "chapter_\(index).xhtml"
|
||||
let pages = effectivePageRanges.enumerated().map { localPageIndex, range in
|
||||
RDEPUBTextPage(
|
||||
absolutePageIndex: flatPages.count + localPageIndex,
|
||||
chapterIndex: index,
|
||||
spineIndex: index,
|
||||
href: href,
|
||||
chapterTitle: spec.title ?? "第 \(index + 1) 章",
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectivePageRanges.count,
|
||||
content: content.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0)
|
||||
)
|
||||
}
|
||||
|
||||
chapters.append(
|
||||
RDEPUBTextChapter(
|
||||
chapterIndex: index,
|
||||
spineIndex: index,
|
||||
href: href,
|
||||
title: spec.title ?? "第 \(index + 1) 章",
|
||||
attributedContent: content.copy() as! NSAttributedString,
|
||||
fragmentOffsets: [:],
|
||||
pages: pages
|
||||
)
|
||||
)
|
||||
flatPages.append(contentsOf: pages)
|
||||
}
|
||||
|
||||
return RDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
}
|
||||
|
||||
// MARK: - 章节拆分
|
||||
|
||||
private struct ChapterSpec {
|
||||
let title: String?
|
||||
let content: String
|
||||
}
|
||||
|
||||
/// 按中文章节正则拆分文本。无匹配时整体作为一章。
|
||||
private func splitChapters(from text: String) -> [ChapterSpec] {
|
||||
let pattern = #"^(第[零一二三四五六七八九十百千万\d]+[章节回卷].*)$"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
|
||||
return [ChapterSpec(title: nil, content: text)]
|
||||
}
|
||||
|
||||
let nsText = text as NSString
|
||||
let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
|
||||
guard !matches.isEmpty else {
|
||||
return [ChapterSpec(title: nil, content: text)]
|
||||
}
|
||||
|
||||
var specs: [ChapterSpec] = []
|
||||
for (i, match) in matches.enumerated() {
|
||||
let titleRange = match.range(at: 1)
|
||||
let title = nsText.substring(with: titleRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let contentStart = titleRange.location + titleRange.length
|
||||
let contentEnd = (i + 1 < matches.count) ? matches[i + 1].range.location : nsText.length
|
||||
let contentRange = NSRange(location: contentStart, length: contentEnd - contentStart)
|
||||
let content = nsText.substring(with: contentRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !content.isEmpty {
|
||||
specs.append(ChapterSpec(title: title, content: content))
|
||||
}
|
||||
}
|
||||
|
||||
// 拆分结果为空(所有章节内容为空),整体作为一章
|
||||
if specs.isEmpty {
|
||||
return [ChapterSpec(title: nil, content: text)]
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
// MARK: - HTML 包装
|
||||
|
||||
/// 将纯文本包装为 HTML 段落
|
||||
private func wrapTextAsHTML(_ text: String) -> String {
|
||||
let paragraphs = text.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
let body = paragraphs.map { "<p>\($0)</p>" }.joined(separator: "\n")
|
||||
return "<html><body>\(body)</body></html>"
|
||||
}
|
||||
|
||||
// MARK: - 文本解码
|
||||
|
||||
private func rd_decodeTextFile(url: URL) -> String {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
}
|
||||
if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String {
|
||||
return content
|
||||
}
|
||||
if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String {
|
||||
return content
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView {
|
||||
var onShowTableOfContents: (() -> Void)?
|
||||
var onShowBookmarks: (() -> Void)?
|
||||
var onShowHighlights: (() -> Void)?
|
||||
var onAddHighlight: (() -> Void)?
|
||||
var onShowSettings: (() -> Void)?
|
||||
|
||||
private let stackView: UIStackView = {
|
||||
let view = UIStackView()
|
||||
view.axis = .horizontal
|
||||
view.distribution = .fillEqually
|
||||
view.alignment = .fill
|
||||
view.spacing = 16
|
||||
return view
|
||||
}()
|
||||
|
||||
private let chapterButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let bookmarksButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let highlightsButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let addHighlightButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let settingsButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
addSubview(stackView)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addArrangedSubview(chapterButton)
|
||||
stackView.addArrangedSubview(bookmarksButton)
|
||||
stackView.addArrangedSubview(highlightsButton)
|
||||
stackView.addArrangedSubview(addHighlightButton)
|
||||
stackView.addArrangedSubview(settingsButton)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
|
||||
stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
|
||||
stackView.topAnchor.constraint(equalTo: topAnchor),
|
||||
stackView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor)
|
||||
])
|
||||
|
||||
configureButton(chapterButton, systemName: "list.bullet", fallbackTitle: "目录")
|
||||
configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签")
|
||||
configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "批注")
|
||||
configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注")
|
||||
configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置")
|
||||
chapterButton.accessibilityIdentifier = "epub.reader.toc"
|
||||
bookmarksButton.accessibilityIdentifier = "epub.reader.bookmarks"
|
||||
highlightsButton.accessibilityIdentifier = "epub.reader.highlights"
|
||||
addHighlightButton.accessibilityIdentifier = "epub.reader.add-highlight"
|
||||
settingsButton.accessibilityIdentifier = "epub.reader.settings"
|
||||
|
||||
[chapterButton, bookmarksButton, highlightsButton, addHighlightButton, settingsButton].forEach { button in
|
||||
button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
|
||||
}
|
||||
|
||||
chapterButton.addTarget(self, action: #selector(chapterAction), for: .touchUpInside)
|
||||
bookmarksButton.addTarget(self, action: #selector(bookmarksAction), for: .touchUpInside)
|
||||
highlightsButton.addTarget(self, action: #selector(highlightsAction), for: .touchUpInside)
|
||||
addHighlightButton.addTarget(self, action: #selector(highlightAction), for: .touchUpInside)
|
||||
settingsButton.addTarget(self, action: #selector(settingsAction), for: .touchUpInside)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func lineFrame(in bounds: CGRect) -> CGRect {
|
||||
CGRect(x: 0, y: 0, width: bounds.width, height: 0.5)
|
||||
}
|
||||
|
||||
override func apply(theme: RDEPUBReaderTheme) {
|
||||
super.apply(theme: theme)
|
||||
[chapterButton, bookmarksButton, highlightsButton, addHighlightButton, settingsButton].forEach { button in
|
||||
button.tintColor = .black
|
||||
button.setTitleColor(.black, for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
func setAddHighlightEnabled(_ isEnabled: Bool) {
|
||||
addHighlightButton.isEnabled = isEnabled
|
||||
addHighlightButton.alpha = isEnabled ? 1 : 0.45
|
||||
}
|
||||
|
||||
func setHighlightsEnabled(_ isEnabled: Bool) {
|
||||
highlightsButton.isEnabled = isEnabled
|
||||
highlightsButton.alpha = isEnabled ? 1 : 0.45
|
||||
}
|
||||
|
||||
func setBookmarksEnabled(_ isEnabled: Bool) {
|
||||
bookmarksButton.isEnabled = isEnabled
|
||||
bookmarksButton.alpha = isEnabled ? 1 : 0.45
|
||||
}
|
||||
|
||||
func updateVisibility(
|
||||
showsTableOfContents: Bool,
|
||||
allowsHighlights: Bool,
|
||||
showsSettingsPanel: Bool
|
||||
) {
|
||||
chapterButton.isHidden = !showsTableOfContents
|
||||
bookmarksButton.isHidden = false
|
||||
highlightsButton.isHidden = !allowsHighlights
|
||||
addHighlightButton.isHidden = !allowsHighlights
|
||||
settingsButton.isHidden = !showsSettingsPanel
|
||||
}
|
||||
|
||||
private func configureButton(_ button: UIButton, systemName: String, fallbackTitle: String) {
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||
if #available(iOS 13.0, *), let image = UIImage(systemName: systemName) {
|
||||
button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
} else {
|
||||
button.setTitle(fallbackTitle, for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func chapterAction() {
|
||||
onShowTableOfContents?()
|
||||
}
|
||||
|
||||
@objc private func bookmarksAction() {
|
||||
onShowBookmarks?()
|
||||
}
|
||||
|
||||
@objc private func highlightsAction() {
|
||||
onShowHighlights?()
|
||||
}
|
||||
|
||||
@objc private func highlightAction() {
|
||||
onAddHighlight?()
|
||||
}
|
||||
|
||||
@objc private func settingsAction() {
|
||||
onShowSettings?()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderChapterListController: UITableViewController {
|
||||
var onSelectItem: ((RDEPUBReaderTableOfContentsItem) -> Void)?
|
||||
|
||||
private let items: [RDEPUBReaderTableOfContentsItem]
|
||||
private let currentItem: RDEPUBReaderTableOfContentsItem?
|
||||
private let theme: RDEPUBReaderTheme
|
||||
|
||||
init(
|
||||
items: [RDEPUBReaderTableOfContentsItem],
|
||||
currentItem: RDEPUBReaderTableOfContentsItem?,
|
||||
theme: RDEPUBReaderTheme
|
||||
) {
|
||||
self.items = items
|
||||
self.currentItem = currentItem
|
||||
self.theme = theme
|
||||
super.init(style: .plain)
|
||||
title = "目录"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.backgroundColor = theme.contentBackgroundColor
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
items.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let item = items[indexPath.row]
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
|
||||
cell.backgroundColor = theme.contentBackgroundColor
|
||||
cell.textLabel?.numberOfLines = 2
|
||||
cell.textLabel?.text = item.title
|
||||
cell.textLabel?.textColor = isCurrentItem(item) ? .systemBlue : theme.contentTextColor
|
||||
cell.indentationLevel = item.depth
|
||||
cell.indentationWidth = 18
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
onSelectItem?(items[indexPath.row])
|
||||
}
|
||||
|
||||
private func isCurrentItem(_ item: RDEPUBReaderTableOfContentsItem) -> Bool {
|
||||
guard let currentItem else { return false }
|
||||
return currentItem.href == item.href
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import UIKit
|
||||
|
||||
public enum RDEPUBTextRenderingEngine: Equatable {
|
||||
case dtCoreText
|
||||
}
|
||||
|
||||
public struct RDEPUBReaderConfiguration: Equatable {
|
||||
public var fontSize: CGFloat
|
||||
public var lineHeightMultiple: CGFloat
|
||||
public var displayType: RDReaderView.DisplayType
|
||||
public var landscapeDualPageEnabled: Bool
|
||||
public var showsTableOfContents: Bool
|
||||
public var allowsHighlights: Bool
|
||||
public var showsSettingsPanel: Bool
|
||||
public var reflowableContentInsets: UIEdgeInsets
|
||||
public var fixedContentInset: UIEdgeInsets
|
||||
public var theme: RDEPUBReaderTheme
|
||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
||||
public var textRenderingEngine: RDEPUBTextRenderingEngine
|
||||
|
||||
public init(
|
||||
fontSize: CGFloat = 15,
|
||||
lineHeightMultiple: CGFloat = 1.6,
|
||||
displayType: RDReaderView.DisplayType = .pageCurl,
|
||||
landscapeDualPageEnabled: Bool = true,
|
||||
showsTableOfContents: Bool = true,
|
||||
allowsHighlights: Bool = true,
|
||||
showsSettingsPanel: Bool = true,
|
||||
reflowableContentInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16),
|
||||
fixedContentInset: UIEdgeInsets = .zero,
|
||||
theme: RDEPUBReaderTheme = .light,
|
||||
fixedLayoutFit: RDEPUBFixedLayoutFit = .page,
|
||||
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
|
||||
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText
|
||||
) {
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.displayType = displayType
|
||||
self.landscapeDualPageEnabled = landscapeDualPageEnabled
|
||||
self.showsTableOfContents = showsTableOfContents
|
||||
self.allowsHighlights = allowsHighlights
|
||||
self.showsSettingsPanel = showsSettingsPanel
|
||||
self.reflowableContentInsets = reflowableContentInsets
|
||||
self.fixedContentInset = fixedContentInset
|
||||
self.theme = theme
|
||||
self.fixedLayoutFit = fixedLayoutFit
|
||||
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
|
||||
self.textRenderingEngine = textRenderingEngine
|
||||
}
|
||||
|
||||
public static let `default` = RDEPUBReaderConfiguration()
|
||||
}
|
||||
|
||||
extension RDEPUBReaderConfiguration {
|
||||
func makePreferences() -> RDEPUBPreferences {
|
||||
RDEPUBPreferences(
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
reflowableContentInsets: reflowableContentInsets,
|
||||
fixedContentInset: fixedContentInset,
|
||||
themeBackgroundColor: theme.themeBackgroundColorCSS,
|
||||
themeTextColor: theme.themeTextColorCSS,
|
||||
fixedBackgroundColor: theme.themeBackgroundColorCSS,
|
||||
fixedLayoutFit: fixedLayoutFit,
|
||||
fixedLayoutSpreadMode: fixedLayoutSpreadMode
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
import UIKit
|
||||
|
||||
public protocol RDEPUBReaderDelegate: AnyObject {
|
||||
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication)
|
||||
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation)
|
||||
func epubReaderDidReachEnd(_ reader: UIViewController)
|
||||
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?)
|
||||
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight])
|
||||
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark])
|
||||
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?)
|
||||
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?)
|
||||
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?)
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL)
|
||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error)
|
||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView)
|
||||
}
|
||||
|
||||
public extension RDEPUBReaderDelegate {
|
||||
func epubReader(_ reader: UIViewController, didOpen publication: RDEPUBPublication) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateLocation location: RDEPUBLocation) {}
|
||||
func epubReaderDidReachEnd(_ reader: UIViewController) {}
|
||||
func epubReader(_ reader: UIViewController, didChangeSelection selection: RDEPUBSelection?) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateHighlights highlights: [RDEPUBHighlight]) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateBookmarks bookmarks: [RDEPUBBookmark]) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateSearchResult result: RDEPUBSearchResult?) {}
|
||||
func epubReader(_ reader: UIViewController, didChangeCurrentSearchMatch match: RDEPUBSearchMatch?) {}
|
||||
func epubReader(_ reader: UIViewController, didUpdateCurrentTableOfContentsItem item: RDEPUBReaderTableOfContentsItem?) {}
|
||||
func epubReader(_ reader: UIViewController, didActivateExternalLink url: URL) {}
|
||||
func epubReader(_ reader: UIViewController, didFailWithError error: Error) {}
|
||||
func epubReader(_ reader: UIViewController, configureTopToolView topToolView: RDEPUBReaderTopToolView) {}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
var onSelectHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
var onUpdateHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
var onDeleteHighlight: ((RDEPUBHighlight) -> Void)?
|
||||
|
||||
private var highlights: [RDEPUBHighlight]
|
||||
private let theme: RDEPUBReaderTheme
|
||||
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
|
||||
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
|
||||
|
||||
private var filteredHighlights: [RDEPUBHighlight] {
|
||||
switch filterControl.selectedSegmentIndex {
|
||||
case 1:
|
||||
return highlights.filter(\.hasNote)
|
||||
case 2:
|
||||
return highlights.filter { $0.style == .highlight }
|
||||
default:
|
||||
return highlights
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
highlights: [RDEPUBHighlight],
|
||||
theme: RDEPUBReaderTheme,
|
||||
sectionTitleProvider: @escaping (RDEPUBHighlight) -> String?
|
||||
) {
|
||||
self.highlights = highlights.sorted { $0.createdAt > $1.createdAt }
|
||||
self.theme = theme
|
||||
self.sectionTitleProvider = sectionTitleProvider
|
||||
super.init(style: .plain)
|
||||
title = "标注"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||||
configureFilterControl()
|
||||
applyTheme()
|
||||
updateEmptyState()
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
filteredHighlights.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cellIdentifier = "HighlightCell"
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||||
let highlight = filteredHighlights[indexPath.row]
|
||||
|
||||
cell.backgroundColor = theme.contentBackgroundColor
|
||||
cell.textLabel?.textColor = theme.contentTextColor
|
||||
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
cell.textLabel?.numberOfLines = 2
|
||||
cell.textLabel?.text = titleText(for: highlight)
|
||||
|
||||
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
||||
cell.detailTextLabel?.numberOfLines = 3
|
||||
cell.detailTextLabel?.text = detailText(for: highlight)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
presentActions(for: filteredHighlights[indexPath.row], sourceIndexPath: indexPath)
|
||||
}
|
||||
|
||||
private func configureFilterControl() {
|
||||
filterControl.selectedSegmentIndex = 0
|
||||
filterControl.addTarget(self, action: #selector(filterChangedAction), for: .valueChanged)
|
||||
navigationItem.titleView = filterControl
|
||||
}
|
||||
|
||||
private func applyTheme() {
|
||||
tableView.backgroundColor = theme.contentBackgroundColor
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
guard filteredHighlights.isEmpty else {
|
||||
tableView.backgroundView = nil
|
||||
return
|
||||
}
|
||||
|
||||
let label = UILabel()
|
||||
label.text = emptyStateText()
|
||||
label.textAlignment = .center
|
||||
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
label.numberOfLines = 0
|
||||
tableView.backgroundView = label
|
||||
}
|
||||
|
||||
@objc private func filterChangedAction() {
|
||||
tableView.reloadData()
|
||||
updateEmptyState()
|
||||
}
|
||||
|
||||
private func emptyStateText() -> String {
|
||||
switch filterControl.selectedSegmentIndex {
|
||||
case 1:
|
||||
return "暂无批注"
|
||||
case 2:
|
||||
return "暂无划线"
|
||||
default:
|
||||
return "暂无标注"
|
||||
}
|
||||
}
|
||||
|
||||
private func detailText(for highlight: RDEPUBHighlight) -> String {
|
||||
let style = styleDescription(for: highlight)
|
||||
let chapter = sectionTitleProvider(highlight)
|
||||
let note = normalizedNote(highlight.note)
|
||||
let parts = [style, chapter, note].compactMap { $0 }
|
||||
if !parts.isEmpty {
|
||||
return parts.joined(separator: "\n")
|
||||
}
|
||||
return highlight.location.href
|
||||
}
|
||||
|
||||
private func titleText(for highlight: RDEPUBHighlight) -> String {
|
||||
let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if highlight.hasNote {
|
||||
return "批注: \(text)"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private func styleDescription(for highlight: RDEPUBHighlight) -> String {
|
||||
switch highlight.style {
|
||||
case .highlight:
|
||||
return highlight.hasNote ? "高亮批注" : "高亮"
|
||||
case .underline:
|
||||
return highlight.hasNote ? "划线批注" : "划线"
|
||||
}
|
||||
}
|
||||
|
||||
private func presentActions(for highlight: RDEPUBHighlight, sourceIndexPath: IndexPath) {
|
||||
let alert = UIAlertController(title: "标注管理", message: nil, preferredStyle: .actionSheet)
|
||||
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
|
||||
self?.onSelectHighlight?(highlight)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "编辑批注", style: .default) { [weak self] _ in
|
||||
self?.presentNoteEditor(for: highlight)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "删除标注", style: .destructive) { [weak self] _ in
|
||||
self?.deleteHighlight(highlight)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
|
||||
if let popover = alert.popoverPresentationController,
|
||||
let cell = tableView.cellForRow(at: sourceIndexPath) {
|
||||
popover.sourceView = cell
|
||||
popover.sourceRect = cell.bounds
|
||||
}
|
||||
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentNoteEditor(for highlight: RDEPUBHighlight) {
|
||||
let alert = UIAlertController(title: "编辑批注", message: nil, preferredStyle: .alert)
|
||||
alert.addTextField { textField in
|
||||
textField.placeholder = "输入批注内容"
|
||||
textField.text = highlight.note
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
|
||||
guard let self,
|
||||
let note = alert?.textFields?.first?.text,
|
||||
let index = self.highlights.firstIndex(where: { $0.id == highlight.id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
var updated = highlight
|
||||
updated.note = self.normalizedNote(note)
|
||||
self.highlights[index] = updated
|
||||
self.tableView.reloadData()
|
||||
self.onUpdateHighlight?(updated)
|
||||
self.updateEmptyState()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func deleteHighlight(_ highlight: RDEPUBHighlight) {
|
||||
guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return }
|
||||
highlights.remove(at: index)
|
||||
tableView.reloadData()
|
||||
onDeleteHighlight?(highlight)
|
||||
updateEmptyState()
|
||||
}
|
||||
|
||||
private func normalizedNote(_ note: String?) -> String? {
|
||||
let trimmed = note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
var onSelectBookmark: ((RDEPUBBookmark) -> Void)?
|
||||
var onDeleteBookmark: ((RDEPUBBookmark) -> Void)?
|
||||
|
||||
private var bookmarks: [RDEPUBBookmark]
|
||||
private let theme: RDEPUBReaderTheme
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) {
|
||||
self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt }
|
||||
self.theme = theme
|
||||
super.init(style: .plain)
|
||||
title = "书签"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.tableFooterView = UIView(frame: .zero)
|
||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||||
applyTheme()
|
||||
updateEmptyState()
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
bookmarks.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cellIdentifier = "BookmarkCell"
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||||
let bookmark = bookmarks[indexPath.row]
|
||||
|
||||
cell.backgroundColor = theme.contentBackgroundColor
|
||||
cell.textLabel?.textColor = theme.contentTextColor
|
||||
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
cell.textLabel?.numberOfLines = 2
|
||||
cell.textLabel?.text = titleText(for: bookmark)
|
||||
|
||||
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
||||
cell.detailTextLabel?.numberOfLines = 3
|
||||
cell.detailTextLabel?.text = detailText(for: bookmark)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
presentActions(for: bookmarks[indexPath.row], sourceIndexPath: indexPath)
|
||||
}
|
||||
|
||||
private func applyTheme() {
|
||||
tableView.backgroundColor = theme.contentBackgroundColor
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
guard bookmarks.isEmpty == false else {
|
||||
let label = UILabel()
|
||||
label.text = "暂无书签"
|
||||
label.textAlignment = .center
|
||||
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
label.numberOfLines = 0
|
||||
tableView.backgroundView = label
|
||||
return
|
||||
}
|
||||
tableView.backgroundView = nil
|
||||
}
|
||||
|
||||
private func titleText(for bookmark: RDEPUBBookmark) -> String {
|
||||
if let chapterTitle = bookmark.chapterTitle, !chapterTitle.isEmpty {
|
||||
return chapterTitle
|
||||
}
|
||||
return bookmark.location.href
|
||||
}
|
||||
|
||||
private func detailText(for bookmark: RDEPUBBookmark) -> String {
|
||||
let parts = [
|
||||
dateFormatter.string(from: bookmark.createdAt),
|
||||
bookmark.note?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
bookmark.location.href
|
||||
].compactMap { value -> String? in
|
||||
guard let value, !value.isEmpty else { return nil }
|
||||
return value
|
||||
}
|
||||
return parts.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func presentActions(for bookmark: RDEPUBBookmark, sourceIndexPath: IndexPath) {
|
||||
let alert = UIAlertController(title: "书签管理", message: nil, preferredStyle: .actionSheet)
|
||||
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
|
||||
self?.onSelectBookmark?(bookmark)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "删除书签", style: .destructive) { [weak self] _ in
|
||||
self?.deleteBookmark(bookmark)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
|
||||
if let popover = alert.popoverPresentationController,
|
||||
let cell = tableView.cellForRow(at: sourceIndexPath) {
|
||||
popover.sourceView = cell
|
||||
popover.sourceRect = cell.bounds
|
||||
}
|
||||
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func deleteBookmark(_ bookmark: RDEPUBBookmark) {
|
||||
guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return }
|
||||
bookmarks.remove(at: index)
|
||||
tableView.reloadData()
|
||||
onDeleteBookmark?(bookmark)
|
||||
updateEmptyState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
|
||||
public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
func loadLocation(for bookIdentifier: String) -> RDEPUBLocation?
|
||||
func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String)
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark]
|
||||
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String)
|
||||
func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight]
|
||||
func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String)
|
||||
func loadReaderSettings() -> RDEPUBReaderSettings?
|
||||
func saveReaderSettings(_ settings: RDEPUBReaderSettings)
|
||||
}
|
||||
|
||||
public extension RDEPUBReaderPersistence {
|
||||
func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||
_ = bookIdentifier
|
||||
return []
|
||||
}
|
||||
|
||||
func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
|
||||
_ = bookmarks
|
||||
_ = bookIdentifier
|
||||
}
|
||||
|
||||
func loadReaderSettings() -> RDEPUBReaderSettings? {
|
||||
nil
|
||||
}
|
||||
|
||||
func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
|
||||
_ = settings
|
||||
}
|
||||
}
|
||||
|
||||
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
private let defaults: UserDefaults
|
||||
private let locationPrefix: String
|
||||
private let bookmarksPrefix: String
|
||||
private let highlightsPrefix: String
|
||||
private let settingsKey: String
|
||||
|
||||
public init(
|
||||
defaults: UserDefaults = .standard,
|
||||
locationPrefix: String = "ssreader.epub.location.",
|
||||
bookmarksPrefix: String = "ssreader.epub.bookmarks.",
|
||||
highlightsPrefix: String = "ssreader.epub.highlights.",
|
||||
settingsKey: String = "ssreader.epub.settings"
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.locationPrefix = locationPrefix
|
||||
self.bookmarksPrefix = bookmarksPrefix
|
||||
self.highlightsPrefix = highlightsPrefix
|
||||
self.settingsKey = settingsKey
|
||||
}
|
||||
|
||||
public func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? {
|
||||
guard let data = defaults.data(forKey: locationPrefix + bookIdentifier) else {
|
||||
return nil
|
||||
}
|
||||
return try? JSONDecoder().decode(RDEPUBLocation.self, from: data)
|
||||
}
|
||||
|
||||
public func saveLocation(_ location: RDEPUBLocation, for bookIdentifier: String) {
|
||||
guard let data = try? JSONEncoder().encode(location) else {
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: locationPrefix + bookIdentifier)
|
||||
}
|
||||
|
||||
public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||
guard let data = defaults.data(forKey: bookmarksPrefix + bookIdentifier) else {
|
||||
return []
|
||||
}
|
||||
return (try? JSONDecoder().decode([RDEPUBBookmark].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
|
||||
guard let data = try? JSONEncoder().encode(bookmarks) else {
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: bookmarksPrefix + bookIdentifier)
|
||||
}
|
||||
|
||||
public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] {
|
||||
guard let data = defaults.data(forKey: highlightsPrefix + bookIdentifier) else {
|
||||
return []
|
||||
}
|
||||
return (try? JSONDecoder().decode([RDEPUBHighlight].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
public func saveHighlights(_ highlights: [RDEPUBHighlight], for bookIdentifier: String) {
|
||||
guard let data = try? JSONEncoder().encode(highlights) else {
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
|
||||
}
|
||||
|
||||
public func loadReaderSettings() -> RDEPUBReaderSettings? {
|
||||
guard let data = defaults.data(forKey: settingsKey) else {
|
||||
return nil
|
||||
}
|
||||
return try? JSONDecoder().decode(RDEPUBReaderSettings.self, from: data)
|
||||
}
|
||||
|
||||
public func saveReaderSettings(_ settings: RDEPUBReaderSettings) {
|
||||
guard let data = try? JSONEncoder().encode(settings) else {
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: settingsKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import UIKit
|
||||
|
||||
public enum RDEPUBReaderDisplayMode: String, Codable, Equatable {
|
||||
case pageCurl
|
||||
case horizontalScroll
|
||||
case verticalScroll
|
||||
case horizontalCoverScroll
|
||||
|
||||
init(displayType: RDReaderView.DisplayType) {
|
||||
switch displayType {
|
||||
case .pageCurl:
|
||||
self = .pageCurl
|
||||
case .horizontalScroll:
|
||||
self = .horizontalScroll
|
||||
case .verticalScroll:
|
||||
self = .verticalScroll
|
||||
}
|
||||
}
|
||||
|
||||
var displayType: RDReaderView.DisplayType {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
return .pageCurl
|
||||
case .horizontalScroll, .horizontalCoverScroll:
|
||||
return .horizontalScroll
|
||||
case .verticalScroll:
|
||||
return .verticalScroll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDEPUBReaderThemePreset: String, Codable, CaseIterable, Equatable {
|
||||
case light
|
||||
case yellow
|
||||
case green
|
||||
case pink
|
||||
case blue
|
||||
case dark
|
||||
|
||||
public var theme: RDEPUBReaderTheme {
|
||||
switch self {
|
||||
case .light:
|
||||
return .light
|
||||
case .yellow:
|
||||
return .yellow
|
||||
case .green:
|
||||
return .green
|
||||
case .pink:
|
||||
return .pink
|
||||
case .blue:
|
||||
return .blue
|
||||
case .dark:
|
||||
return .dark
|
||||
}
|
||||
}
|
||||
|
||||
public init?(theme: RDEPUBReaderTheme) {
|
||||
switch theme {
|
||||
case .light:
|
||||
self = .light
|
||||
case .yellow:
|
||||
self = .yellow
|
||||
case .green:
|
||||
self = .green
|
||||
case .pink:
|
||||
self = .pink
|
||||
case .blue:
|
||||
self = .blue
|
||||
case .dark:
|
||||
self = .dark
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RDEPUBReaderSettings: Codable, Equatable {
|
||||
public var brightness: CGFloat?
|
||||
public var fontSize: CGFloat?
|
||||
public var lineHeightMultiple: CGFloat?
|
||||
public var displayMode: RDEPUBReaderDisplayMode?
|
||||
public var themePreset: RDEPUBReaderThemePreset?
|
||||
|
||||
public init(
|
||||
brightness: CGFloat? = nil,
|
||||
fontSize: CGFloat? = nil,
|
||||
lineHeightMultiple: CGFloat? = nil,
|
||||
displayMode: RDEPUBReaderDisplayMode? = nil,
|
||||
themePreset: RDEPUBReaderThemePreset? = nil
|
||||
) {
|
||||
self.brightness = brightness
|
||||
self.fontSize = fontSize
|
||||
self.lineHeightMultiple = lineHeightMultiple
|
||||
self.displayMode = displayMode
|
||||
self.themePreset = themePreset
|
||||
}
|
||||
|
||||
public func applying(to configuration: RDEPUBReaderConfiguration) -> RDEPUBReaderConfiguration {
|
||||
var resolvedConfiguration = configuration
|
||||
|
||||
if let fontSize {
|
||||
resolvedConfiguration.fontSize = fontSize
|
||||
}
|
||||
if let lineHeightMultiple {
|
||||
resolvedConfiguration.lineHeightMultiple = lineHeightMultiple
|
||||
}
|
||||
if let displayMode {
|
||||
resolvedConfiguration.displayType = displayMode.displayType
|
||||
}
|
||||
if let themePreset {
|
||||
resolvedConfiguration.theme = themePreset.theme
|
||||
}
|
||||
|
||||
return resolvedConfiguration
|
||||
}
|
||||
|
||||
public static func capture(
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
brightness: CGFloat
|
||||
) -> RDEPUBReaderSettings {
|
||||
RDEPUBReaderSettings(
|
||||
brightness: max(0, min(1, brightness)),
|
||||
fontSize: configuration.fontSize,
|
||||
lineHeightMultiple: configuration.lineHeightMultiple,
|
||||
displayMode: RDEPUBReaderDisplayMode(displayType: configuration.displayType),
|
||||
themePreset: RDEPUBReaderThemePreset(theme: configuration.theme)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import UIKit
|
||||
|
||||
final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||
var onBrightnessChange: ((CGFloat) -> Void)?
|
||||
var onFontSizeChange: ((CGFloat) -> Void)?
|
||||
var onLineHeightChange: ((CGFloat) -> Void)?
|
||||
var onDisplayTypeChange: ((RDReaderView.DisplayType) -> Void)?
|
||||
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
|
||||
|
||||
private enum ThemePreset: Int, CaseIterable {
|
||||
case light
|
||||
case yellow
|
||||
case green
|
||||
case pink
|
||||
case blue
|
||||
case dark
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .light: return "浅色"
|
||||
case .yellow: return "米黄"
|
||||
case .green: return "青绿"
|
||||
case .pink: return "粉色"
|
||||
case .blue: return "蓝灰"
|
||||
case .dark: return "夜间"
|
||||
}
|
||||
}
|
||||
|
||||
var theme: RDEPUBReaderTheme {
|
||||
switch self {
|
||||
case .light: return .light
|
||||
case .yellow: return .yellow
|
||||
case .green: return .green
|
||||
case .pink: return .pink
|
||||
case .blue: return .blue
|
||||
case .dark: return .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = 20
|
||||
return stackView
|
||||
}()
|
||||
private let brightnessSlider = UISlider()
|
||||
private let fontValueLabel = UILabel()
|
||||
private let decreaseFontButton = UIButton(type: .system)
|
||||
private let increaseFontButton = UIButton(type: .system)
|
||||
private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"])
|
||||
private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"])
|
||||
private let themeStackView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .fillEqually
|
||||
stackView.spacing = 12
|
||||
return stackView
|
||||
}()
|
||||
private var themeButtons: [UIButton] = []
|
||||
|
||||
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
|
||||
private var currentConfiguration: RDEPUBReaderConfiguration
|
||||
|
||||
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
|
||||
self.currentConfiguration = configuration
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
brightnessSlider.value = Float(brightness)
|
||||
title = "样式设置"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupNavigationItems()
|
||||
setupViews()
|
||||
syncControls()
|
||||
applyTheme(currentConfiguration.theme)
|
||||
}
|
||||
|
||||
private func setupNavigationItems() {
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction))
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
view.addSubview(scrollView)
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.addSubview(contentStack)
|
||||
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16),
|
||||
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16),
|
||||
contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 20),
|
||||
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24),
|
||||
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32)
|
||||
])
|
||||
|
||||
brightnessSlider.minimumValue = 0
|
||||
brightnessSlider.maximumValue = 1
|
||||
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
|
||||
|
||||
fontValueLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold)
|
||||
fontValueLabel.textAlignment = .center
|
||||
fontValueLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
|
||||
configureFontButton(decreaseFontButton, title: "A-")
|
||||
configureFontButton(increaseFontButton, title: "A+")
|
||||
decreaseFontButton.addTarget(self, action: #selector(decreaseFontAction), for: .touchUpInside)
|
||||
increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside)
|
||||
|
||||
lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged)
|
||||
displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged)
|
||||
|
||||
ThemePreset.allCases.forEach { preset in
|
||||
let button = UIButton(type: .system)
|
||||
button.tag = preset.rawValue
|
||||
button.layer.cornerRadius = 18
|
||||
button.layer.borderWidth = 1.5
|
||||
button.backgroundColor = preset.theme.contentBackgroundColor
|
||||
button.accessibilityLabel = preset.title
|
||||
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
|
||||
themeButtons.append(button)
|
||||
themeStackView.addArrangedSubview(button)
|
||||
NSLayoutConstraint.activate([
|
||||
button.heightAnchor.constraint(equalToConstant: 36)
|
||||
])
|
||||
}
|
||||
|
||||
contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider))
|
||||
contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow()))
|
||||
contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl))
|
||||
contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView))
|
||||
}
|
||||
|
||||
private func makeSection(title: String, content: UIView) -> UIView {
|
||||
let container = UIStackView()
|
||||
container.axis = .vertical
|
||||
container.spacing = 10
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = title
|
||||
titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
||||
|
||||
content.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addArrangedSubview(titleLabel)
|
||||
container.addArrangedSubview(content)
|
||||
return container
|
||||
}
|
||||
|
||||
private func makeFontSizeRow() -> UIView {
|
||||
let stackView = UIStackView(arrangedSubviews: [decreaseFontButton, fontValueLabel, increaseFontButton])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.distribution = .fill
|
||||
stackView.spacing = 12
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
decreaseFontButton.widthAnchor.constraint(equalToConstant: 64),
|
||||
increaseFontButton.widthAnchor.constraint(equalToConstant: 64),
|
||||
decreaseFontButton.heightAnchor.constraint(equalToConstant: 36),
|
||||
increaseFontButton.heightAnchor.constraint(equalToConstant: 36)
|
||||
])
|
||||
return stackView
|
||||
}
|
||||
|
||||
private func configureFontButton(_ button: UIButton, title: String) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
|
||||
button.layer.cornerRadius = 18
|
||||
button.layer.borderWidth = 1
|
||||
}
|
||||
|
||||
private func syncControls() {
|
||||
fontValueLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
|
||||
|
||||
let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1
|
||||
lineHeightControl.selectedSegmentIndex = lineHeightIndex
|
||||
|
||||
switch currentConfiguration.displayType {
|
||||
case .pageCurl:
|
||||
displayTypeControl.selectedSegmentIndex = 0
|
||||
case .horizontalScroll:
|
||||
displayTypeControl.selectedSegmentIndex = 1
|
||||
case .verticalScroll:
|
||||
displayTypeControl.selectedSegmentIndex = 2
|
||||
}
|
||||
|
||||
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
|
||||
updateThemeSelection(selectedPreset)
|
||||
}
|
||||
|
||||
private func applyTheme(_ theme: RDEPUBReaderTheme) {
|
||||
view.backgroundColor = theme.contentBackgroundColor
|
||||
scrollView.backgroundColor = theme.contentBackgroundColor
|
||||
contentStack.arrangedSubviews
|
||||
.compactMap { $0 as? UIStackView }
|
||||
.flatMap { $0.arrangedSubviews }
|
||||
.compactMap { $0 as? UILabel }
|
||||
.forEach { $0.textColor = theme.contentTextColor }
|
||||
|
||||
fontValueLabel.textColor = theme.contentTextColor
|
||||
[decreaseFontButton, increaseFontButton].forEach { button in
|
||||
button.setTitleColor(theme.toolControlTextColor, for: .normal)
|
||||
button.layer.borderColor = theme.toolControlBorderUnselectColor.cgColor
|
||||
button.backgroundColor = theme.toolBackgroundColor
|
||||
}
|
||||
|
||||
[lineHeightControl, displayTypeControl].forEach { control in
|
||||
control.backgroundColor = theme.toolBackgroundColor
|
||||
if #available(iOS 13.0, *) {
|
||||
control.selectedSegmentTintColor = theme.toolControlTextColor.withAlphaComponent(0.14)
|
||||
} else {
|
||||
control.tintColor = theme.toolControlTextColor
|
||||
}
|
||||
control.setTitleTextAttributes([.foregroundColor: theme.contentTextColor], for: .normal)
|
||||
control.setTitleTextAttributes([.foregroundColor: theme.toolControlTextColor], for: .selected)
|
||||
}
|
||||
|
||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
||||
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light)
|
||||
}
|
||||
|
||||
private func updateThemeSelection(_ preset: ThemePreset) {
|
||||
themeButtons.forEach { button in
|
||||
let isSelected = button.tag == preset.rawValue
|
||||
button.layer.borderWidth = isSelected ? 2 : 1
|
||||
button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func doneAction() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func brightnessChanged(_ slider: UISlider) {
|
||||
onBrightnessChange?(CGFloat(slider.value))
|
||||
}
|
||||
|
||||
@objc private func decreaseFontAction() {
|
||||
let nextValue = max(12, currentConfiguration.fontSize - 1)
|
||||
guard nextValue != currentConfiguration.fontSize else { return }
|
||||
currentConfiguration.fontSize = nextValue
|
||||
fontValueLabel.text = String(Int(nextValue.rounded()))
|
||||
onFontSizeChange?(nextValue)
|
||||
}
|
||||
|
||||
@objc private func increaseFontAction() {
|
||||
let nextValue = min(36, currentConfiguration.fontSize + 1)
|
||||
guard nextValue != currentConfiguration.fontSize else { return }
|
||||
currentConfiguration.fontSize = nextValue
|
||||
fontValueLabel.text = String(Int(nextValue.rounded()))
|
||||
onFontSizeChange?(nextValue)
|
||||
}
|
||||
|
||||
@objc private func lineHeightChanged(_ control: UISegmentedControl) {
|
||||
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
|
||||
let value = lineHeightValues[index]
|
||||
currentConfiguration.lineHeightMultiple = value
|
||||
onLineHeightChange?(value)
|
||||
}
|
||||
|
||||
@objc private func displayTypeChanged(_ control: UISegmentedControl) {
|
||||
let displayType: RDReaderView.DisplayType
|
||||
switch control.selectedSegmentIndex {
|
||||
case 1:
|
||||
displayType = .horizontalScroll
|
||||
case 2:
|
||||
displayType = .verticalScroll
|
||||
default:
|
||||
displayType = .pageCurl
|
||||
}
|
||||
currentConfiguration.displayType = displayType
|
||||
onDisplayTypeChange?(displayType)
|
||||
}
|
||||
|
||||
@objc private func themeButtonAction(_ sender: UIButton) {
|
||||
guard let preset = ThemePreset(rawValue: sender.tag) else { return }
|
||||
currentConfiguration.theme = preset.theme
|
||||
applyTheme(preset.theme)
|
||||
onThemeChange?(preset.theme)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDEPUBReaderTableOfContentsItem: Equatable {
|
||||
public var title: String
|
||||
public var href: String
|
||||
public var depth: Int
|
||||
public var pageNumber: Int?
|
||||
|
||||
public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {
|
||||
self.title = title
|
||||
self.href = href
|
||||
self.depth = depth
|
||||
self.pageNumber = pageNumber
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import UIKit
|
||||
|
||||
public struct RDEPUBReaderTheme: Equatable {
|
||||
public var contentBackgroundColor: UIColor
|
||||
public var contentTextColor: UIColor
|
||||
public var toolBackgroundColor: UIColor
|
||||
public var toolControlTextColor: UIColor
|
||||
public var toolControlBorderUnselectColor: UIColor
|
||||
public var toolLineColor: UIColor
|
||||
|
||||
public init(
|
||||
contentBackgroundColor: UIColor,
|
||||
contentTextColor: UIColor,
|
||||
toolBackgroundColor: UIColor,
|
||||
toolControlTextColor: UIColor,
|
||||
toolControlBorderUnselectColor: UIColor,
|
||||
toolLineColor: UIColor
|
||||
) {
|
||||
self.contentBackgroundColor = contentBackgroundColor
|
||||
self.contentTextColor = contentTextColor
|
||||
self.toolBackgroundColor = toolBackgroundColor
|
||||
self.toolControlTextColor = toolControlTextColor
|
||||
self.toolControlBorderUnselectColor = toolControlBorderUnselectColor
|
||||
self.toolLineColor = toolLineColor
|
||||
}
|
||||
|
||||
public static let light = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: .white,
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: .white,
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let dark = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: .black,
|
||||
contentTextColor: .white,
|
||||
toolBackgroundColor: .black,
|
||||
toolControlTextColor: .white,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let yellow = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1),
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let green = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1),
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let pink = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1),
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
|
||||
public static let blue = RDEPUBReaderTheme(
|
||||
contentBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
|
||||
contentTextColor: .black,
|
||||
toolBackgroundColor: UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1),
|
||||
toolControlTextColor: .black,
|
||||
toolControlBorderUnselectColor: UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: UIColor.lightGray.withAlphaComponent(0.5)
|
||||
)
|
||||
}
|
||||
|
||||
extension RDEPUBReaderTheme {
|
||||
var themeBackgroundColorCSS: String {
|
||||
contentBackgroundColor.ss_cssString
|
||||
}
|
||||
|
||||
var themeTextColorCSS: String {
|
||||
contentTextColor.ss_cssString
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import UIKit
|
||||
|
||||
open class RDEPUBReaderToolView: UIView {
|
||||
private let lineView = UIView()
|
||||
private let lineHeight: CGFloat = 0.5
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(lineView)
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
open override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
lineView.frame = lineFrame(in: bounds)
|
||||
}
|
||||
|
||||
open func apply(theme: RDEPUBReaderTheme) {
|
||||
backgroundColor = .white
|
||||
lineView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
open func lineFrame(in bounds: CGRect) -> CGRect {
|
||||
CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight)
|
||||
}
|
||||
}
|
||||
|
||||
final class RDEPUBReaderTintButton: UIButton {
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import UIKit
|
||||
|
||||
public final class RDEPUBReaderTopToolView: RDEPUBReaderToolView {
|
||||
var onBack: (() -> Void)?
|
||||
var onToggleBookmark: (() -> Void)?
|
||||
|
||||
private let backButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let bookmarkButton = RDEPUBReaderTintButton(type: .system)
|
||||
private let titleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textAlignment = .center
|
||||
label.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
|
||||
label.numberOfLines = 1
|
||||
return label
|
||||
}()
|
||||
private var isBookmarked = false
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
self.backgroundColor = .white
|
||||
addSubview(backButton)
|
||||
addSubview(bookmarkButton)
|
||||
addSubview(titleLabel)
|
||||
|
||||
backButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
bookmarkButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
|
||||
bookmarkButton.addTarget(self, action: #selector(bookmarkAction), for: .touchUpInside)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
|
||||
backButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
|
||||
backButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
|
||||
backButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
backButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
bookmarkButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
||||
bookmarkButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 4),
|
||||
bookmarkButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
|
||||
bookmarkButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
bookmarkButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
titleLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: bookmarkButton.leadingAnchor, constant: -8),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor)
|
||||
])
|
||||
|
||||
if #available(iOS 13.0, *) {
|
||||
backButton.setImage(UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
} else {
|
||||
backButton.setTitle("返回", for: .normal)
|
||||
}
|
||||
backButton.accessibilityIdentifier = "epub.reader.back"
|
||||
bookmarkButton.accessibilityIdentifier = "epub.reader.bookmark"
|
||||
titleLabel.accessibilityIdentifier = "epub.reader.title"
|
||||
updateBookmarkButtonAppearance()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override public func lineFrame(in bounds: CGRect) -> CGRect {
|
||||
CGRect(x: 0, y: bounds.height - 0.5, width: bounds.width, height: 0.5)
|
||||
}
|
||||
|
||||
override public func apply(theme: RDEPUBReaderTheme) {
|
||||
super.apply(theme: theme)
|
||||
titleLabel.textColor = .black
|
||||
backButton.tintColor = .black
|
||||
bookmarkButton.tintColor = .black
|
||||
if #unavailable(iOS 13.0) {
|
||||
backButton.setTitleColor(.black, for: .normal)
|
||||
bookmarkButton.setTitleColor(.black, for: .normal)
|
||||
}
|
||||
updateBookmarkButtonAppearance()
|
||||
}
|
||||
|
||||
func setTitle(_ title: String?) {
|
||||
titleLabel.text = title
|
||||
}
|
||||
|
||||
func setBookmarkSelected(_ isSelected: Bool) {
|
||||
isBookmarked = isSelected
|
||||
updateBookmarkButtonAppearance()
|
||||
}
|
||||
|
||||
func setBookmarkEnabled(_ isEnabled: Bool) {
|
||||
bookmarkButton.isEnabled = isEnabled
|
||||
bookmarkButton.alpha = isEnabled ? 1 : 0.45
|
||||
}
|
||||
|
||||
@objc private func backAction() {
|
||||
onBack?()
|
||||
}
|
||||
|
||||
@objc private func bookmarkAction() {
|
||||
onToggleBookmark?()
|
||||
}
|
||||
|
||||
private func updateBookmarkButtonAppearance() {
|
||||
if #available(iOS 13.0, *) {
|
||||
let imageName = isBookmarked ? "bookmark.fill" : "bookmark"
|
||||
bookmarkButton.setImage(UIImage(systemName: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal)
|
||||
} else {
|
||||
bookmarkButton.setTitle(isBookmarked ? "已签" : "书签", for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import UIKit
|
||||
import Foundation
|
||||
|
||||
protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
}
|
||||
|
||||
final class RDEPUBSelectableTextView: UITextView {
|
||||
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
switch action {
|
||||
case #selector(rd_copy(_:)),
|
||||
#selector(rd_highlight(_:)),
|
||||
#selector(rd_annotate(_:)):
|
||||
return selectedRange.location != NSNotFound && selectedRange.length > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@objc func rd_copy(_ sender: Any?) {
|
||||
onSelectionAction?(.copy)
|
||||
}
|
||||
|
||||
@objc func rd_highlight(_ sender: Any?) {
|
||||
onSelectionAction?(.highlight)
|
||||
}
|
||||
|
||||
@objc func rd_annotate(_ sender: Any?) {
|
||||
onSelectionAction?(.annotate)
|
||||
}
|
||||
}
|
||||
|
||||
final class RDEPUBTextContentView: UIView {
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
private var highlightedRanges: [RDEPUBHighlight] = []
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
private let textView: RDEPUBSelectableTextView = {
|
||||
let view = RDEPUBSelectableTextView()
|
||||
view.isEditable = false
|
||||
view.isScrollEnabled = false
|
||||
view.isSelectable = true
|
||||
view.backgroundColor = .clear
|
||||
view.textContainerInset = .zero
|
||||
view.textContainer.lineFragmentPadding = 0
|
||||
return view
|
||||
}()
|
||||
|
||||
private let pageNumberLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(textView)
|
||||
addSubview(pageNumberLabel)
|
||||
textView.delegate = self
|
||||
textView.onSelectionAction = { [weak self] action in
|
||||
guard let self else { return }
|
||||
self.delegate?.textContentView(self, didRequestSelectionAction: action)
|
||||
}
|
||||
UIMenuController.shared.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBSelectableTextView.rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBSelectableTextView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(RDEPUBSelectableTextView.rd_annotate(_:)))
|
||||
]
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
textView.frame = bounds.inset(by: contentInsets)
|
||||
|
||||
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
||||
pageNumberLabel.frame = CGRect(
|
||||
x: bounds.width - labelSize.width - 24,
|
||||
y: bounds.height - labelSize.height - 20,
|
||||
width: labelSize.width,
|
||||
height: labelSize.height
|
||||
)
|
||||
}
|
||||
|
||||
func configure(
|
||||
page: RDEPUBTextPage,
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchState: RDEPUBSearchState? = nil
|
||||
) {
|
||||
currentPage = page
|
||||
highlightedRanges = highlights
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
|
||||
let displayContent = NSMutableAttributedString(attributedString: page.content)
|
||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
||||
displayContent.addAttribute(
|
||||
.foregroundColor,
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: fullRange
|
||||
)
|
||||
applyHighlights(to: displayContent, page: page)
|
||||
applySearchHighlights(to: displayContent, page: page, searchState: searchState)
|
||||
textView.tintColor = configuration.theme.toolControlTextColor
|
||||
textView.attributedText = displayContent
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
}
|
||||
|
||||
private func applyHighlights(to content: NSMutableAttributedString, page: RDEPUBTextPage) {
|
||||
let pageStart = Int(page.pageStartOffset)
|
||||
let pageEndExclusive = Int(page.pageEndOffset) + 1
|
||||
|
||||
for highlight in highlightedRanges where highlight.location.href == page.href {
|
||||
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { continue }
|
||||
let overlapStart = max(range.location, pageStart)
|
||||
let overlapEnd = min(range.location + range.length, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let relativeRange = NSRange(
|
||||
location: overlapStart - pageStart,
|
||||
length: overlapEnd - overlapStart
|
||||
)
|
||||
switch highlight.style {
|
||||
case .highlight:
|
||||
content.addAttribute(
|
||||
.backgroundColor,
|
||||
value: UIColor(hexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45),
|
||||
range: relativeRange
|
||||
)
|
||||
case .underline:
|
||||
content.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: relativeRange)
|
||||
if let color = UIColor(hexString: highlight.color, alpha: 1) {
|
||||
content.addAttribute(.underlineColor, value: color, range: relativeRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applySearchHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
searchState: RDEPUBSearchState?
|
||||
) {
|
||||
guard let searchState else { return }
|
||||
|
||||
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||
let pageStart = Int(page.pageStartOffset)
|
||||
let pageEndExclusive = Int(page.pageEndOffset) + 1
|
||||
|
||||
for match in searchState.matches where match.href == page.href {
|
||||
guard let matchStart = match.rangeLocation else { continue }
|
||||
let matchEnd = matchStart + match.rangeLength
|
||||
let overlapStart = max(matchStart, pageStart)
|
||||
let overlapEnd = min(matchEnd, pageEndExclusive)
|
||||
guard overlapStart < overlapEnd else { continue }
|
||||
|
||||
let relativeRange = NSRange(location: Int(overlapStart - pageStart), length: Int(overlapEnd - overlapStart))
|
||||
let color = match == searchState.currentMatch ? activeColor : normalColor
|
||||
content.addAttribute(.backgroundColor, value: color, range: relativeRange)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension RDEPUBTextContentView: UITextViewDelegate {
|
||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||
guard let page = currentPage else {
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let selectedRange = textView.selectedRange
|
||||
guard selectedRange.location != NSNotFound,
|
||||
selectedRange.length > 0,
|
||||
let attributedText = textView.attributedText else {
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let source = attributedText.string as NSString
|
||||
let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedText.isEmpty else {
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let globalStart = page.pageStartOffset + selectedRange.location
|
||||
let globalEnd = globalStart + selectedRange.length
|
||||
let totalLength = max(page.content.length - 1, 1)
|
||||
let selection = RDEPUBSelection(
|
||||
location: RDEPUBLocation(
|
||||
href: page.href,
|
||||
progression: Double(selectedRange.location) / Double(totalLength),
|
||||
lastProgression: Double(max(selectedRange.location + selectedRange.length - 1, 0)) / Double(totalLength),
|
||||
fragment: nil
|
||||
),
|
||||
text: selectedText,
|
||||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
|
||||
)
|
||||
delegate?.textContentView(self, didChangeSelection: selection)
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
convenience init?(hexString: String, alpha: CGFloat) {
|
||||
var value = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
value = value.replacingOccurrences(of: "#", with: "")
|
||||
guard value.count == 6, let hex = Int(value, radix: 16) else { return nil }
|
||||
self.init(
|
||||
red: CGFloat((hex >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((hex >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(hex & 0xFF) / 255,
|
||||
alpha: alpha
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import UIKit
|
||||
|
||||
protocol RDEPUBWebContentViewDelegate: AnyObject {
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction)
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didActivateExternalLink url: URL)
|
||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didLogJavaScriptError message: String)
|
||||
}
|
||||
|
||||
final class RDEPUBWebContentView: UIView {
|
||||
weak var delegate: RDEPUBWebContentViewDelegate?
|
||||
|
||||
private let epubWebView = RDEPUBWebView()
|
||||
private let pageNumberLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
clipsToBounds = true
|
||||
layer.masksToBounds = true
|
||||
|
||||
addSubview(epubWebView)
|
||||
addSubview(pageNumberLabel)
|
||||
epubWebView.delegate = self
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
epubWebView.frame = bounds
|
||||
|
||||
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
|
||||
pageNumberLabel.frame = CGRect(
|
||||
x: bounds.width - labelSize.width - 24,
|
||||
y: bounds.height - labelSize.height - 20,
|
||||
width: labelSize.width,
|
||||
height: labelSize.height
|
||||
)
|
||||
}
|
||||
|
||||
func configure(
|
||||
publication: RDEPUBPublication,
|
||||
request: RDEPUBRenderRequest,
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
theme: RDEPUBReaderTheme
|
||||
) {
|
||||
backgroundColor = theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
epubWebView.load(publication: publication, request: request)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func releaseResources() {
|
||||
epubWebView.reset()
|
||||
epubWebView.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBWebContentView: RDEPUBWebViewDelegate {
|
||||
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
|
||||
delegate?.epubWebContentView(self, didUpdateLocation: location, spineIndex: spineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
|
||||
delegate?.epubWebContentView(self, didChangeSelection: selection, spineIndex: spineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {
|
||||
delegate?.epubWebContentView(self, didRequestSelectionAction: action)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
|
||||
delegate?.epubWebContentView(self, didActivateInternalLink: location, fromSpineIndex: fromSpineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) {
|
||||
delegate?.epubWebContentView(self, didActivateExternalLink: url)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) {
|
||||
delegate?.epubWebContentView(self, didLogJavaScriptError: message)
|
||||
}
|
||||
|
||||
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import UIKit
|
||||
|
||||
struct EPUBTOCDisplayItem {
|
||||
var title: String
|
||||
var href: String
|
||||
var depth: Int
|
||||
var pageNumber: Int?
|
||||
}
|
||||
|
||||
struct LegacyRDEPUBTextPage {
|
||||
var absolutePageIndex: Int
|
||||
var chapterIndex: Int
|
||||
var spineIndex: Int
|
||||
var href: String
|
||||
var chapterTitle: String
|
||||
var pageIndexInChapter: Int
|
||||
var totalPagesInChapter: Int
|
||||
var content: NSAttributedString
|
||||
var contentRange: NSRange
|
||||
var pageStartOffset: Int
|
||||
var pageEndOffset: Int
|
||||
}
|
||||
|
||||
struct LegacyRDEPUBTextChapter {
|
||||
var chapterIndex: Int
|
||||
var spineIndex: Int
|
||||
var href: String
|
||||
var title: String
|
||||
var attributedContent: NSAttributedString
|
||||
var fragmentOffsets: [String: Int]
|
||||
var pages: [LegacyRDEPUBTextPage]
|
||||
}
|
||||
|
||||
struct LegacyRDEPUBTextBook {
|
||||
var chapters: [LegacyRDEPUBTextChapter]
|
||||
var pages: [LegacyRDEPUBTextPage]
|
||||
|
||||
func page(at pageNumber: Int) -> LegacyRDEPUBTextPage? {
|
||||
guard pageNumber > 0, pages.indices.contains(pageNumber - 1) else {
|
||||
return nil
|
||||
}
|
||||
return pages[pageNumber - 1]
|
||||
}
|
||||
|
||||
func pageNumber(for location: RDEPUBLocation, resolver: RDEPUBResourceResolver, bookIdentifier: String?) -> Int? {
|
||||
guard let normalizedLocation = resolver.normalizedLocation(location, bookIdentifier: bookIdentifier),
|
||||
let chapter = chapters.first(where: { $0.href == normalizedLocation.href }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let targetOffset: Int
|
||||
if let fragment = normalizedLocation.fragment, let fragmentOffset = chapter.fragmentOffsets[fragment] {
|
||||
targetOffset = fragmentOffset
|
||||
} else {
|
||||
let lastOffset = max(chapter.attributedContent.length - 1, 0)
|
||||
targetOffset = min(lastOffset, max(0, Int(round(Double(lastOffset) * normalizedLocation.navigationProgression))))
|
||||
}
|
||||
|
||||
if let page = chapter.pages.first(where: { targetOffset >= $0.pageStartOffset && targetOffset <= $0.pageEndOffset }) {
|
||||
return page.absolutePageIndex + 1
|
||||
}
|
||||
|
||||
return chapter.pages.last.map { $0.absolutePageIndex + 1 }
|
||||
}
|
||||
|
||||
func location(forPageNumber pageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
||||
guard let page = page(at: pageNumber),
|
||||
let chapter = chapters.first(where: { $0.chapterIndex == page.chapterIndex }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let totalLength = max(chapter.attributedContent.length - 1, 1)
|
||||
return RDEPUBLocation(
|
||||
bookIdentifier: bookIdentifier,
|
||||
href: page.href,
|
||||
progression: Double(page.pageStartOffset) / Double(totalLength),
|
||||
lastProgression: Double(page.pageEndOffset) / Double(totalLength),
|
||||
fragment: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class LegacyRDEPUBTextBookBuilder {
|
||||
static func build(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
font: UIFont,
|
||||
lineSpacing: CGFloat
|
||||
) -> LegacyRDEPUBTextBook {
|
||||
var chapters: [LegacyRDEPUBTextChapter] = []
|
||||
var flatPages: [LegacyRDEPUBTextPage] = []
|
||||
|
||||
for (spineIndex, item) in publication.spine.enumerated() where item.linear {
|
||||
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
||||
let rawHTML = parser.htmlString(forRelativePath: item.href) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterTitle = resolvedChapterTitle(for: item, toc: publication.tableOfContents)
|
||||
let normalizedHTML = normalizeHTML(rawHTML)
|
||||
let markedHTML = injectFragmentMarkers(into: normalizedHTML)
|
||||
guard let attributedContent = attributedHTML(
|
||||
html: markedHTML,
|
||||
baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent(),
|
||||
font: font,
|
||||
lineSpacing: lineSpacing
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let mutableContent = NSMutableAttributedString(attributedString: attributedContent)
|
||||
let fragmentOffsets = extractFragmentOffsets(from: mutableContent)
|
||||
normalizeReadingAttributes(in: mutableContent, font: font, lineSpacing: lineSpacing)
|
||||
|
||||
let plainText = mutableContent.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if shouldSkipChapter(item: item, text: plainText) {
|
||||
continue
|
||||
}
|
||||
|
||||
let chapterIndex = chapters.count
|
||||
let pageRanges = mutableContent.length > 0 ? mutableContent.pageRanges(size: pageSize) : []
|
||||
let effectivePageRanges = pageRanges.isEmpty && mutableContent.length > 0
|
||||
? [NSRange(location: 0, length: mutableContent.length)]
|
||||
: pageRanges
|
||||
|
||||
let pages = effectivePageRanges.enumerated().map { localPageIndex, range in
|
||||
LegacyRDEPUBTextPage(
|
||||
absolutePageIndex: flatPages.count + localPageIndex,
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
chapterTitle: chapterTitle,
|
||||
pageIndexInChapter: localPageIndex,
|
||||
totalPagesInChapter: effectivePageRanges.count,
|
||||
content: mutableContent.attributedSubstring(from: range),
|
||||
contentRange: range,
|
||||
pageStartOffset: range.location,
|
||||
pageEndOffset: range.location + max(range.length - 1, 0)
|
||||
)
|
||||
}
|
||||
|
||||
let chapter = LegacyRDEPUBTextChapter(
|
||||
chapterIndex: chapterIndex,
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: chapterTitle,
|
||||
attributedContent: mutableContent.copy() as! NSAttributedString,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
pages: pages
|
||||
)
|
||||
chapters.append(chapter)
|
||||
flatPages.append(contentsOf: pages)
|
||||
}
|
||||
|
||||
return LegacyRDEPUBTextBook(chapters: chapters, pages: flatPages)
|
||||
}
|
||||
|
||||
private static func resolvedChapterTitle(for item: RDEPUBSpineItem, toc: [EPUBTableOfContentsItem]) -> String {
|
||||
if let title = flattenedTOCItems(from: toc).first(where: { tocItem in
|
||||
tocItem.href.components(separatedBy: "#").first == item.href
|
||||
})?.title.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return title
|
||||
}
|
||||
let trimmedTitle = item.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmedTitle.isEmpty ? item.href : trimmedTitle
|
||||
}
|
||||
|
||||
private static func flattenedTOCItems(from items: [EPUBTableOfContentsItem]) -> [EPUBTableOfContentsItem] {
|
||||
items.flatMap { item in
|
||||
[item] + flattenedTOCItems(from: item.children)
|
||||
}
|
||||
}
|
||||
|
||||
private static func shouldSkipChapter(item: RDEPUBSpineItem, text: String) -> Bool {
|
||||
let lowercasedHref = item.href.lowercased()
|
||||
if text.isEmpty && (lowercasedHref.contains("cover") || lowercasedHref.contains("title")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func normalizeHTML(_ html: String) -> String {
|
||||
var cleanedHTML = html
|
||||
let replacements: [(pattern: String, template: String)] = [
|
||||
(#"<hr\s+lang="zh-CN">分页符</hr>"#, ""),
|
||||
(#"\r"#, "\n"),
|
||||
(#"\n+"#, "\n")
|
||||
]
|
||||
|
||||
for replacement in replacements {
|
||||
if let regex = try? NSRegularExpression(pattern: replacement.pattern, options: [.caseInsensitive]) {
|
||||
cleanedHTML = regex.stringByReplacingMatches(
|
||||
in: cleanedHTML,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: cleanedHTML.utf16.count),
|
||||
withTemplate: replacement.template
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedHTML
|
||||
}
|
||||
|
||||
private static func injectFragmentMarkers(into html: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"(<[^>]+\sid="([^"]+)"[^>]*>)"#, options: [.caseInsensitive]) else {
|
||||
return html
|
||||
}
|
||||
return regex.stringByReplacingMatches(
|
||||
in: html,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: html.utf16.count),
|
||||
withTemplate: "${id=$2}$1"
|
||||
)
|
||||
}
|
||||
|
||||
private static func attributedHTML(
|
||||
html: String,
|
||||
baseURL: URL?,
|
||||
font: UIFont,
|
||||
lineSpacing: CGFloat
|
||||
) -> NSAttributedString? {
|
||||
guard let data = html.data(using: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue,
|
||||
NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption"): baseURL as Any
|
||||
]
|
||||
|
||||
if let attributed = try? NSMutableAttributedString(data: data, options: options, documentAttributes: nil) {
|
||||
return attributed
|
||||
}
|
||||
|
||||
let fallbackAttributes: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.paragraphStyle: paragraphStyle(lineSpacing: lineSpacing)
|
||||
]
|
||||
return NSAttributedString(string: html, attributes: fallbackAttributes)
|
||||
}
|
||||
|
||||
private static func extractFragmentOffsets(from attributedString: NSMutableAttributedString) -> [String: Int] {
|
||||
let markerPattern = #"\$\{id=([^}]+)\}"#
|
||||
guard let regex = try? NSRegularExpression(pattern: markerPattern, options: []) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let mutableString = NSMutableString(string: attributedString.string)
|
||||
var fragmentOffsets: [String: Int] = [:]
|
||||
var searchRange = NSRange(location: 0, length: mutableString.length)
|
||||
var offsetAdjustment = 0
|
||||
|
||||
while let match = regex.firstMatch(in: mutableString as String, options: [], range: searchRange) {
|
||||
let fullMatch = mutableString.substring(with: match.range) as NSString
|
||||
let fragmentID = fullMatch
|
||||
.replacingOccurrences(of: #"\$\{id="#, with: "", options: .regularExpression, range: NSRange(location: 0, length: fullMatch.length))
|
||||
.replacingOccurrences(of: #"\}"#, with: "", options: .regularExpression)
|
||||
|
||||
let adjustedLocation = max(0, match.range.location + offsetAdjustment)
|
||||
fragmentOffsets[fragmentID] = adjustedLocation
|
||||
attributedString.deleteCharacters(in: match.range)
|
||||
mutableString.deleteCharacters(in: match.range)
|
||||
offsetAdjustment -= match.range.length
|
||||
searchRange = NSRange(location: match.range.location, length: mutableString.length - match.range.location)
|
||||
}
|
||||
|
||||
return fragmentOffsets
|
||||
}
|
||||
|
||||
private static func normalizeReadingAttributes(in attributedString: NSMutableAttributedString, font: UIFont, lineSpacing: CGFloat) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
attributedString.enumerateAttributes(in: fullRange) { attributes, range, _ in
|
||||
let sourceFont = attributes[.font] as? UIFont
|
||||
let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? paragraphStyle(lineSpacing: lineSpacing)
|
||||
paragraph.lineSpacing = lineSpacing
|
||||
paragraph.paragraphSpacing = max(paragraph.paragraphSpacing, lineSpacing / 2)
|
||||
|
||||
var updatedAttributes = attributes
|
||||
updatedAttributes[.font] = normalizedFont(from: sourceFont, baseFont: font)
|
||||
updatedAttributes[.paragraphStyle] = paragraph
|
||||
attributedString.setAttributes(updatedAttributes, range: range)
|
||||
}
|
||||
}
|
||||
|
||||
private static func normalizedFont(from sourceFont: UIFont?, baseFont: UIFont) -> UIFont {
|
||||
guard let sourceFont else {
|
||||
return baseFont
|
||||
}
|
||||
let traits = sourceFont.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic])
|
||||
if let descriptor = baseFont.fontDescriptor.withSymbolicTraits(traits) {
|
||||
return UIFont(descriptor: descriptor, size: baseFont.pointSize)
|
||||
}
|
||||
return baseFont
|
||||
}
|
||||
|
||||
private static func paragraphStyle(lineSpacing: CGFloat) -> NSMutableParagraphStyle {
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = lineSpacing
|
||||
style.paragraphSpacing = max(6, lineSpacing / 2)
|
||||
return style
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// RDReaderBottomToolView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
public class RDReaderBottomToolView: RDReaderToolView, SSEventTrigger {
|
||||
enum Event {
|
||||
case chapterList, highlight, settings, darkAndLight
|
||||
}
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.distribution = .fillEqually
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = 20
|
||||
return stackView
|
||||
}()
|
||||
|
||||
lazy var chapterListButton: TintColorButton = {
|
||||
let button = TintColorButton(type: .system)
|
||||
button.setImage(toolbarImage(named: "read_edit_chapterlist", fallbackSystemName: "list.bullet"), for: .normal)
|
||||
addSubview(button)
|
||||
button.addTarget(self, action: #selector(chapterListAction), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var settingsButton: TintColorButton = {
|
||||
let button = TintColorButton(type: .system)
|
||||
button.setImage(toolbarImage(named: "read_edit_font", fallbackSystemName: "textformat"), for: .normal)
|
||||
addSubview(button)
|
||||
button.addTarget(self, action: #selector(settingsAction), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var highlightButton: TintColorButton = {
|
||||
let button = TintColorButton(type: .system)
|
||||
button.setImage(UIImage(systemName: "highlighter"), for: .normal)
|
||||
addSubview(button)
|
||||
button.addTarget(self, action: #selector(highlightAction), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
|
||||
lazy var lightModeButton: TintColorButton = {
|
||||
let button = TintColorButton(type: .system)
|
||||
button.setImage(toolbarImage(named: "read_edit_night", fallbackSystemName: "moon.fill"), for: .normal)
|
||||
addSubview(button)
|
||||
button.addTarget(self, action: #selector(lightAndDarkModeAction), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
override func makeUI() {
|
||||
super.makeUI()
|
||||
addSubview(containerView)
|
||||
containerView.addArrangedSubview(chapterListButton)
|
||||
containerView.addArrangedSubview(highlightButton)
|
||||
containerView.addArrangedSubview(settingsButton)
|
||||
containerView.addArrangedSubview(lightModeButton)
|
||||
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.left.equalTo(16)
|
||||
make.top.equalTo(0)
|
||||
make.right.equalTo(-16)
|
||||
make.bottom.equalTo(-RDReaderCommon.safeAreaInsets.bottom)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@objc private func chapterListAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.chapterList)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func settingsAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.settings)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func highlightAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.highlight)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func lightAndDarkModeAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.darkAndLight)
|
||||
}
|
||||
}
|
||||
|
||||
private func toolbarImage(named: String, fallbackSystemName: String) -> UIImage? {
|
||||
if let image = UIImage(named: named) {
|
||||
return image.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
return UIImage(systemName: fallbackSystemName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//
|
||||
// RDReaderCommon.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import CoreText
|
||||
|
||||
struct RDReaderCommon {
|
||||
static var safeAreaInsets: UIEdgeInsets = {
|
||||
guard #available(iOS 11.0, *) else {
|
||||
return .zero
|
||||
}
|
||||
return UIApplication.shared.windows[0].safeAreaInsets
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
extension RDReaderCommon {
|
||||
enum SpaceType: Int, RDReaderObserverType {
|
||||
case min = 0
|
||||
case meduim
|
||||
case max
|
||||
var mulitiple: CGFloat {
|
||||
switch self {
|
||||
case .min: return 1.6
|
||||
case .meduim: return 2.0
|
||||
case .max: return 3.0
|
||||
}
|
||||
}
|
||||
|
||||
var image: UIImage? {
|
||||
switch self {
|
||||
case .min:
|
||||
return UIImage.rdToolbarImage(named: "read_edit_textSpace_3", fallbackSystemName: "text.justify.leading")
|
||||
case .meduim:
|
||||
return UIImage.rdToolbarImage(named: "read_edit_textSpace_2", fallbackSystemName: "text.justify")
|
||||
case .max:
|
||||
return UIImage.rdToolbarImage(named: "read_edit_textSpace_1", fallbackSystemName: "text.justify.right")
|
||||
}
|
||||
}
|
||||
|
||||
var cachesValue: Int {
|
||||
return rawValue
|
||||
}
|
||||
var value: CGFloat {
|
||||
return mulitiple
|
||||
}
|
||||
func transform(value: Any?) -> RDReaderCommon.SpaceType {
|
||||
guard let value = value as? Int else {
|
||||
return self
|
||||
}
|
||||
return RDReaderCommon.SpaceType(rawValue: value) ?? self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RDReaderCommon {
|
||||
enum PageCurlType: Int, RDReaderObserverType {
|
||||
case pageCurl
|
||||
case horizontalScroll
|
||||
case verticalScroll
|
||||
case horizontalCoverScroll
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
return "仿真"
|
||||
case .horizontalScroll:
|
||||
return "左右滑动"
|
||||
case .verticalScroll:
|
||||
return "上下滚动"
|
||||
case .horizontalCoverScroll:
|
||||
return "左右覆盖"
|
||||
}
|
||||
}
|
||||
|
||||
var cachesValue: Int {
|
||||
return rawValue
|
||||
}
|
||||
var value: RDReaderView.DisplayType {
|
||||
switch self {
|
||||
case .pageCurl:
|
||||
return RDReaderView.DisplayType.pageCurl
|
||||
case .horizontalScroll:
|
||||
return RDReaderView.DisplayType.horizontalScroll
|
||||
case .verticalScroll:
|
||||
return RDReaderView.DisplayType.verticalScroll
|
||||
case .horizontalCoverScroll:
|
||||
return RDReaderView.DisplayType.horizontalScroll
|
||||
}
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> RDReaderCommon.PageCurlType {
|
||||
guard let value = value as? Int else {
|
||||
return self
|
||||
}
|
||||
return RDReaderCommon.PageCurlType(rawValue: value) ?? self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RDReaderCommon {
|
||||
|
||||
enum Colors: Int, RDReaderObserverType{
|
||||
|
||||
case white, dark, pink, yellow, green, blue
|
||||
var value: RDReaderTheme {
|
||||
switch self {
|
||||
case .white:
|
||||
return whiteTheme
|
||||
case .dark:
|
||||
return darkTheme
|
||||
case .pink:
|
||||
return pinkTheme
|
||||
case .yellow:
|
||||
return yellowTheme
|
||||
case .green:
|
||||
return greenTheme
|
||||
case .blue:
|
||||
return blueTheme
|
||||
}
|
||||
}
|
||||
var cachesValue: Int {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var color: UIColor {
|
||||
switch self {
|
||||
case .white:
|
||||
return UIColor(red: 0.98, green: 0.98, blue: 0.99, alpha: 1)
|
||||
case .yellow:
|
||||
return UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1)
|
||||
case .green:
|
||||
return UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1)
|
||||
case .pink:
|
||||
return UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1)
|
||||
case .blue:
|
||||
return UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1)
|
||||
case .dark:
|
||||
return UIColor(red: 0.09, green: 0.09, blue: 0.09, alpha: 1)
|
||||
}
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> RDReaderCommon.Colors {
|
||||
guard let value = value as? Int else {
|
||||
return self
|
||||
}
|
||||
return RDReaderCommon.Colors(rawValue: value) ?? self
|
||||
}
|
||||
}
|
||||
static let whiteTheme = WhiteTheme()
|
||||
static let darkTheme = DarkTheme()
|
||||
static let pinkTheme = PinkTheme()
|
||||
static let yellowTheme = YellowTheme()
|
||||
static let greenTheme = GreenTheme()
|
||||
static let blueTheme = BlueTheme()
|
||||
|
||||
}
|
||||
|
||||
|
||||
extension NSAttributedString {
|
||||
func pageRanges(size: CGSize) -> [NSRange] {
|
||||
var ranges = [NSRange]()
|
||||
let framesetter = CTFramesetterCreateWithAttributedString(self)
|
||||
let path = CGPath(rect: CGRect(origin: .zero, size: size), transform: nil)
|
||||
var range = CFRangeMake(0, 0)
|
||||
var loc = 0
|
||||
while range.location + range.length < length {
|
||||
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(loc, 0), path, nil)
|
||||
range = CTFrameGetVisibleStringRange(frame)
|
||||
ranges.append(NSMakeRange(loc, range.length))
|
||||
loc += range.length
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension String {
|
||||
static func encodeTextFile(url: URL?) -> String {
|
||||
var content: String? = nil
|
||||
guard let url = url else {
|
||||
return content ?? ""
|
||||
}
|
||||
content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String
|
||||
if content == nil {
|
||||
content = try? NSString(contentsOf: url, encoding: 0x80000632) as String
|
||||
}
|
||||
if content == nil {
|
||||
content = try? NSString(contentsOf: url, encoding: 0x80000631) as String
|
||||
}
|
||||
return content ?? ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// RDReaderContentView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/11.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
class RDReaderContentView: UIView {
|
||||
lazy var textLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
return label
|
||||
}()
|
||||
lazy var pageNumLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(textLabel)
|
||||
addSubview(pageNumLabel)
|
||||
|
||||
textLabel.text = """
|
||||
孩子为了不进职校,压力之大,我甚至听说,有初三学生晚上八点睡觉,凌晨两点起床读书做作业,忙到天亮,吃好早饭赶往学校。
|
||||
|
||||
平心静气地讲,一个孩子如果不是读书的料,完全可以去职校学一门手艺。但家长却有三重焦虑:
|
||||
|
||||
第一,一个普通高校的学生可以轻松去读职校的课程,但职校学生要去读普通高校的课程,难上加难。同一年级的各类学校和专业,在学习上是有难易梯度的。家长多不会同意让孩子一开始就选择容易的学校和专业,否则在未来竞争中,将处于不利地位。
|
||||
|
||||
第二,初中毕业生社会经验匮乏,实际上根本没有能力做人生规划。选择职校学一门技术,同时也就意味着,将来从事其他工作的门槛是很高的。如果没有继续学习的能力,改行的成本之高,难以想象。
|
||||
|
||||
以我的个人经验来讲,初中阶段,一度想去学习屠宰的手艺,毕业时也有上建筑类职高的机会,但都没去,在普通高中混到高三才决定考大学,如果去上职高,很可能就是个小包工头。我不知道这是不是我的真实意愿,但我觉得当下的工作更符合秉性。
|
||||
|
||||
第三,中国的一些职校,风评并不十分好,家长并不十分放心把十五六岁的孩子送入这些学校,他们不担心孩子学艺不成,而是担心孩子“学坏了”。
|
||||
|
||||
并且,随着科技加速进步,很多好端端的传统职业,忽然消失了。以汽修为例,现在的汽修专业毕业生,谁能保证他的精湛技术在10年之后不会归零?一些新职业出现没几年又消失了,谁能保证中高职教育能够跟上科技潮流?
|
||||
"""
|
||||
textLabel.snp.makeConstraints { make in
|
||||
make.edges.equalTo(UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16))
|
||||
}
|
||||
pageNumLabel.snp.makeConstraints { make in
|
||||
make.bottom.equalTo(-20)
|
||||
make.right.equalTo(-30)
|
||||
}
|
||||
}
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
final class RDReaderEPUBTextContentView: UIView {
|
||||
private(set) lazy var textLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
return label
|
||||
}()
|
||||
|
||||
private(set) lazy var pageNumLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(textLabel)
|
||||
addSubview(pageNumLabel)
|
||||
|
||||
textLabel.snp.makeConstraints { make in
|
||||
make.edges.equalTo(UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16))
|
||||
}
|
||||
pageNumLabel.snp.makeConstraints { make in
|
||||
make.bottom.equalTo(-20)
|
||||
make.right.equalTo(-30)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(page: LegacyRDEPUBTextPage, pageNumber: Int, totalPages: Int, theme: RDReaderTheme) {
|
||||
backgroundColor = theme.contentBackgroudColor
|
||||
pageNumLabel.textColor = theme.contentTextColor
|
||||
pageNumLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
|
||||
let displayContent = NSMutableAttributedString(attributedString: page.content)
|
||||
let fullRange = NSRange(location: 0, length: displayContent.length)
|
||||
displayContent.addAttribute(.foregroundColor, value: theme.contentTextColor ?? UIColor.black, range: fullRange)
|
||||
textLabel.attributedText = displayContent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import UIKit
|
||||
|
||||
extension RDReaderController {
|
||||
func currentEPUBTextPageSize() -> CGSize {
|
||||
let pageWidth: CGFloat
|
||||
if readerView.pagesPerScreen > 1 {
|
||||
pageWidth = view.frame.width / CGFloat(readerView.pagesPerScreen) - 16 * 2
|
||||
} else {
|
||||
pageWidth = view.frame.width - 16 * 2
|
||||
}
|
||||
return CGSize(width: max(pageWidth, 1), height: max(view.frame.height - 40 * 2, 1))
|
||||
}
|
||||
|
||||
func loadEPUBTextBook(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
let pageSize = currentEPUBTextPageSize()
|
||||
let font = UIFont.systemFont(ofSize: RDReaderManager.shared.fontValue.currentType)
|
||||
let lineSpacing = RDReaderManager.shared.lineSpaceType.currentType.mulitiple * 15
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
let textBook = LegacyRDEPUBTextBookBuilder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
font: font,
|
||||
lineSpacing: lineSpacing
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self?.applyEPUBTextBook(textBook, publication: publication, restoreLocation: restoreLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyEPUBTextBook(
|
||||
_ textBook: LegacyRDEPUBTextBook,
|
||||
publication: RDEPUBPublication,
|
||||
restoreLocation: RDEPUBLocation?
|
||||
) {
|
||||
epubTextBook = textBook
|
||||
epubTOCItems = flattenedTOCItems(from: publication.tableOfContents, textBook: textBook)
|
||||
print("[EPUB] Text book ready chapters=\(textBook.chapters.count) pages=\(textBook.pages.count) toc=\(epubTOCItems.count)")
|
||||
let resolvedRestorePage = restoreLocation.flatMap {
|
||||
textBook.pageNumber(
|
||||
for: $0,
|
||||
resolver: epubResolver ?? publicationFallbackResolver(),
|
||||
bookIdentifier: currentEPUBBookIdentifier
|
||||
)
|
||||
}
|
||||
storeEPUBPaginationValidation(
|
||||
mode: "textReflowable",
|
||||
stage: "final",
|
||||
pageCount: textBook.pages.count,
|
||||
chapterCount: textBook.chapters.count,
|
||||
tocCount: epubTOCItems.count,
|
||||
restoreLocation: restoreLocation,
|
||||
resolvedRestorePage: resolvedRestorePage
|
||||
)
|
||||
isReaderContentReady = true
|
||||
hideLoading()
|
||||
readerView.reloadData()
|
||||
restoreEPUBLocation(restoreLocation)
|
||||
if restoreLocation == nil {
|
||||
epubSession?.transition(to: .idle)
|
||||
}
|
||||
}
|
||||
|
||||
func flattenedTOCItems(
|
||||
from items: [EPUBTableOfContentsItem],
|
||||
depth: Int = 0,
|
||||
textBook: LegacyRDEPUBTextBook? = nil
|
||||
) -> [EPUBTOCDisplayItem] {
|
||||
var result: [EPUBTOCDisplayItem] = []
|
||||
for item in items {
|
||||
let pageNumber = textBook.flatMap { book in
|
||||
book.pageNumber(
|
||||
for: RDEPUBLocation(bookIdentifier: currentEPUBBookIdentifier, href: item.href, progression: 0),
|
||||
resolver: epubResolver ?? publicationFallbackResolver(),
|
||||
bookIdentifier: currentEPUBBookIdentifier
|
||||
)
|
||||
}
|
||||
result.append(EPUBTOCDisplayItem(title: item.title, href: item.href, depth: depth, pageNumber: pageNumber))
|
||||
result.append(contentsOf: flattenedTOCItems(from: item.children, depth: depth + 1, textBook: textBook))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func publicationFallbackResolver() -> RDEPUBResourceResolver {
|
||||
epubPublication?.resourceResolver ?? RDEPUBResourceResolver(parser: epubParser ?? RDEPUBParser())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// RDReaderCoverView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/11.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
class RDReaderCoverView: UIView {
|
||||
lazy var textLabel: UILabel = {
|
||||
let textLabel = UILabel()
|
||||
textLabel.numberOfLines = 0
|
||||
return textLabel
|
||||
}()
|
||||
|
||||
lazy var imageV: UIImageView = {
|
||||
let imageV = UIImageView()
|
||||
imageV.image = UIImage(named: "cover")
|
||||
return imageV
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(imageV)
|
||||
addSubview(textLabel)
|
||||
|
||||
imageV.snp.makeConstraints { make in
|
||||
make.top.equalTo(60)
|
||||
make.centerX.equalToSuperview()
|
||||
make.size.equalTo(CGSize(width: 88 * 1.5, height: 120 * 1.5))
|
||||
}
|
||||
|
||||
textLabel.snp.makeConstraints { make in
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.top.equalTo(imageV.snp.bottom).offset(20)
|
||||
make.bottom.equalTo(-30)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
protocol RDReaderEPUBContentViewDelegate: AnyObject {
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int)
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int)
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int)
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didActivateExternalLink url: URL)
|
||||
func epubContentView(_ contentView: RDReaderEPUBContentView, didLogJavaScriptError message: String)
|
||||
}
|
||||
|
||||
final class RDReaderEPUBContentView: UIView {
|
||||
weak var delegate: RDReaderEPUBContentViewDelegate?
|
||||
|
||||
lazy var pageNumLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 13)
|
||||
return label
|
||||
}()
|
||||
|
||||
private let epubWebView = RDEPUBWebView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
clipsToBounds = true
|
||||
layer.masksToBounds = true
|
||||
addSubview(epubWebView)
|
||||
addSubview(pageNumLabel)
|
||||
|
||||
epubWebView.delegate = self
|
||||
epubWebView.clipsToBounds = true
|
||||
epubWebView.layer.masksToBounds = true
|
||||
|
||||
epubWebView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
pageNumLabel.snp.makeConstraints { make in
|
||||
make.right.equalToSuperview().offset(-30)
|
||||
make.bottom.equalToSuperview().offset(-20)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func load(publication: RDEPUBPublication, request: RDEPUBRenderRequest) {
|
||||
epubWebView.load(publication: publication, request: request)
|
||||
}
|
||||
|
||||
func loadPage(
|
||||
parser: RDEPUBParser,
|
||||
spineIndex: Int,
|
||||
pageIndex: Int,
|
||||
totalPagesInChapter: Int,
|
||||
viewportSize: CGSize,
|
||||
padding: UIEdgeInsets,
|
||||
fontSize: CGFloat,
|
||||
lineHeightMultiple: CGFloat,
|
||||
themeBackgroundColor: String?,
|
||||
themeTextColor: String?,
|
||||
targetLocation: RDEPUBLocation?,
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
epubWebView.loadPage(
|
||||
parser: parser,
|
||||
spineIndex: spineIndex,
|
||||
pageIndex: pageIndex,
|
||||
totalPagesInChapter: totalPagesInChapter,
|
||||
viewportSize: viewportSize,
|
||||
padding: padding,
|
||||
fontSize: fontSize,
|
||||
lineHeightMultiple: lineHeightMultiple,
|
||||
themeBackgroundColor: themeBackgroundColor,
|
||||
themeTextColor: themeTextColor,
|
||||
targetLocation: targetLocation,
|
||||
highlights: highlights
|
||||
)
|
||||
}
|
||||
|
||||
func loadFixedSpread(
|
||||
parser: RDEPUBParser,
|
||||
spread: EPUBFixedSpread,
|
||||
viewportSize: CGSize,
|
||||
contentInset: UIEdgeInsets,
|
||||
backgroundColor: UIColor?
|
||||
) {
|
||||
epubWebView.loadFixedSpread(
|
||||
parser: parser,
|
||||
spread: spread,
|
||||
viewportSize: viewportSize,
|
||||
contentInset: contentInset,
|
||||
backgroundColor: backgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
func releaseResources() {
|
||||
epubWebView.reset()
|
||||
epubWebView.delegate = self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension RDReaderEPUBContentView: RDEPUBWebViewDelegate {
|
||||
func epubWebView(_ webView: RDEPUBWebView, didUpdateLocation location: RDEPUBLocation, spineIndex: Int) {
|
||||
delegate?.epubContentView(self, didUpdateLocation: location, spineIndex: spineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didChangeSelection selection: RDEPUBSelection?, spineIndex: Int) {
|
||||
delegate?.epubContentView(self, didChangeSelection: selection, spineIndex: spineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateInternalLink location: RDEPUBLocation, fromSpineIndex: Int) {
|
||||
delegate?.epubContentView(self, didActivateInternalLink: location, fromSpineIndex: fromSpineIndex)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didActivateExternalLink url: URL) {
|
||||
delegate?.epubContentView(self, didActivateExternalLink: url)
|
||||
}
|
||||
|
||||
func epubWebView(_ webView: RDEPUBWebView, didLogJavaScriptError message: String) {
|
||||
delegate?.epubContentView(self, didLogJavaScriptError: message)
|
||||
}
|
||||
|
||||
func epubWebViewDidFinishRendering(_ webView: RDEPUBWebView) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
//
|
||||
// RDReaderEditView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/19.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// 亮度
|
||||
class RDReaderBrightSlider: UISlider, SSEventTrigger {
|
||||
enum Event {
|
||||
case valueDidChange(value: CGFloat)
|
||||
}
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
override func setValue(_ value: Float, animated: Bool) {
|
||||
super.setValue(value, animated: animated)
|
||||
UIScreen.main.brightness = CGFloat(value)
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.valueDidChange(value: CGFloat(value)))
|
||||
}
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
let color = UIColor(red: 0.97, green: 0.97, blue: 0.97, alpha: 1)
|
||||
thumbTintColor = color
|
||||
let image = UIImage.rdToolbarImage(named: "read_edit_slide", fallbackSystemName: "circle.fill")
|
||||
setThumbImage(image, for: .normal)
|
||||
setThumbImage(image, for: .highlighted)
|
||||
minimumValueImage = UIImage.rdToolbarImage(named: "read_edit_bright_min", fallbackSystemName: "sun.min.fill")
|
||||
maximumValueImage = UIImage.rdToolbarImage(named: "read_edit_bright_max", fallbackSystemName: "sun.max.fill")
|
||||
}
|
||||
|
||||
override func trackRect(forBounds bounds: CGRect) -> CGRect {
|
||||
self.layer.cornerRadius = 2.5 / 2
|
||||
return CGRect(x: 30, y: (bounds.height - 2.5)/2, width: bounds.width - 30 * 2, height: 2.5)
|
||||
}
|
||||
|
||||
override func thumbRect(forBounds bounds: CGRect, trackRect rect: CGRect, value: Float) -> CGRect {
|
||||
// print(value)
|
||||
return CGRect(x: 30 + (bounds.width - 30 * 2 - 16) * CGFloat(value), y: (bounds.height - 16)/2, width: 16, height: 16)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 字号
|
||||
class RDReaderFontView: UIView, SSEventTrigger {
|
||||
enum Event {
|
||||
case fontSize(size: CGFloat)
|
||||
}
|
||||
var maxFontSize: CGFloat = 30
|
||||
var minFontSize: CGFloat = 15
|
||||
var currentFontSize: CGFloat = 15 {
|
||||
didSet {
|
||||
fontLabel.text = "\(Int(currentFontSize))"
|
||||
if currentFontSize != oldValue {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.fontSize(size: currentFontSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lazy var reduceButton: UIButton = {
|
||||
let button = UIButton(type: .system)
|
||||
button.setImage(UIImage.rdToolbarImage(named: "read_edit_font_reduce", fallbackSystemName: "textformat.size.smaller"), for: .normal)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2)
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var increaseButton: UIButton = {
|
||||
let button = UIButton(type: .system)
|
||||
button.setImage(UIImage.rdToolbarImage(named: "read_edit_font_increase", fallbackSystemName: "textformat.size.larger"), for: .normal)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.backgroundColor = UIColor.lightGray.withAlphaComponent(0.2)
|
||||
return button
|
||||
}()
|
||||
|
||||
lazy var fontLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.font = UIFont.systemFont(ofSize: 17)
|
||||
label.textAlignment = .center
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .fill
|
||||
stackView.spacing = 0
|
||||
return stackView
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
addSubview(containerView)
|
||||
containerView.addArrangedSubview(reduceButton)
|
||||
containerView.addArrangedSubview(fontLabel)
|
||||
containerView.addArrangedSubview(increaseButton)
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
reduceButton.snp.makeConstraints { make in
|
||||
make.width.equalTo(increaseButton.snp.width).priority(.high)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
increaseButton.snp.makeConstraints { make in
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
fontLabel.snp.makeConstraints { make in
|
||||
make.width.equalTo(50)
|
||||
}
|
||||
|
||||
reduceButton.addTarget(self, action: #selector(reduceAction(button:)), for: .touchUpInside)
|
||||
increaseButton.addTarget(self, action: #selector(increaseAction(button:)), for: .touchUpInside)
|
||||
fontLabel.text = "\(Int(currentFontSize))"
|
||||
}
|
||||
|
||||
@objc func reduceAction(button: UIButton) {
|
||||
if currentFontSize == minFontSize {
|
||||
return
|
||||
}
|
||||
currentFontSize -= 1
|
||||
}
|
||||
|
||||
@objc func increaseAction(button: UIButton) {
|
||||
if currentFontSize == maxFontSize {
|
||||
return
|
||||
}
|
||||
currentFontSize += 1
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 行距
|
||||
class RDReaderLineSpaceView: UIView, SSEventTrigger {
|
||||
|
||||
enum Event {
|
||||
case chose(type: RDReaderCommon.SpaceType)
|
||||
}
|
||||
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .fillEqually
|
||||
stackView.spacing = 10
|
||||
return stackView
|
||||
}()
|
||||
|
||||
private var buttons = [UIButton]()
|
||||
let allTypes: [RDReaderCommon.SpaceType] = [.min, .meduim, .max]
|
||||
var currentType: RDReaderCommon.SpaceType = .min {
|
||||
didSet {
|
||||
let index = allTypes.firstIndex(of: currentType)
|
||||
let button = buttons[index!]
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
addSubview(containerView)
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
allTypes.forEach { type in
|
||||
let button = UIButton(type: .system)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.layer.borderWidth = 1
|
||||
button.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
|
||||
button.setImage(type.image?.withRenderingMode(.alwaysOriginal), for: .normal)
|
||||
containerView.addArrangedSubview(button)
|
||||
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
|
||||
button.snp.makeConstraints { make in
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
buttons.append(button)
|
||||
if type == .min {
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func buttonAction(button: UIButton) {
|
||||
for btn in buttons {
|
||||
if btn == button {
|
||||
btn.layer.borderColor = UIColor.black.cgColor
|
||||
let index = buttons.firstIndex(of: btn)
|
||||
let type = allTypes[index!]
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.chose(type: type))
|
||||
}
|
||||
} else {
|
||||
btn.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻页方式
|
||||
class RDReaderPageCurlTypeView: UIView, SSEventTrigger {
|
||||
|
||||
enum Event {
|
||||
case chose(type: RDReaderCommon.PageCurlType)
|
||||
}
|
||||
|
||||
private lazy var scrollView: UIScrollView = {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
return scrollView
|
||||
}()
|
||||
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .equalSpacing
|
||||
stackView.spacing = 10
|
||||
return stackView
|
||||
}()
|
||||
|
||||
var currentType: RDReaderCommon.PageCurlType = .pageCurl {
|
||||
didSet {
|
||||
let index = allTypes.firstIndex(of: currentType)
|
||||
let button = buttons[index!]
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
private var buttons = [UIButton]()
|
||||
let allTypes: [RDReaderCommon.PageCurlType] = [.pageCurl, .horizontalScroll, .verticalScroll, .horizontalCoverScroll]
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
addSubview(scrollView)
|
||||
scrollView.addSubview(containerView)
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
allTypes.forEach { type in
|
||||
let button = UIButton(type: .system)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.layer.borderWidth = 1
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 17)
|
||||
button.setTitleColor(UIColor.black, for: .normal)
|
||||
button.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
|
||||
button.setTitle(type.title, for: .normal)
|
||||
let size = button.titleLabel!.sizeThatFits(CGSize(width: CGFloat(MAXFLOAT), height: 37))
|
||||
containerView.addArrangedSubview(button)
|
||||
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
|
||||
button.snp.makeConstraints { make in
|
||||
make.height.equalTo(37)
|
||||
make.width.equalTo(size.width + 30)
|
||||
}
|
||||
buttons.append(button)
|
||||
if type == .pageCurl {
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func buttonAction(button: UIButton) {
|
||||
for btn in buttons {
|
||||
if btn == button {
|
||||
btn.layer.borderColor = UIColor.black.cgColor
|
||||
let index = buttons.firstIndex(of: btn)
|
||||
let type = allTypes[index!]
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.chose(type: type))
|
||||
}
|
||||
} else {
|
||||
btn.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 主题颜色
|
||||
class RDReaderThemeColorsView: UIView, SSEventTrigger {
|
||||
enum Event {
|
||||
case chose(color: RDReaderCommon.Colors)
|
||||
}
|
||||
|
||||
private lazy var scrollView: UIScrollView = {
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
return scrollView
|
||||
}()
|
||||
|
||||
private lazy var containerView: UIStackView = {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.distribution = .equalSpacing
|
||||
stackView.spacing = 30
|
||||
return stackView
|
||||
}()
|
||||
|
||||
var currentType: RDReaderCommon.Colors = .white {
|
||||
didSet {
|
||||
let index = allTypes.firstIndex(of: currentType)
|
||||
let button = buttons[index!]
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var buttons = [UIButton]()
|
||||
let allTypes: [RDReaderCommon.Colors] = [.white, .yellow, .green, .pink, .blue, .dark]
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
addSubview(scrollView)
|
||||
scrollView.addSubview(containerView)
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(0)
|
||||
}
|
||||
allTypes.forEach { type in
|
||||
let button = UIButton(type: .system)
|
||||
button.layer.cornerRadius = 37 / 2
|
||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 17)
|
||||
button.layer.borderColor = UIColor.black.cgColor
|
||||
button.backgroundColor = type.color
|
||||
containerView.addArrangedSubview(button)
|
||||
button.addTarget(self, action: #selector(buttonAction(button:)), for: .touchUpInside)
|
||||
button.snp.makeConstraints { make in
|
||||
make.height.width.equalTo(37)
|
||||
}
|
||||
buttons.append(button)
|
||||
if type == .white {
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func buttonAction(button: UIButton) {
|
||||
for btn in buttons {
|
||||
if btn == button {
|
||||
btn.layer.borderWidth = 1
|
||||
let index = buttons.firstIndex(of: btn)
|
||||
let type = allTypes[index!]
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.chose(color: type))
|
||||
}
|
||||
} else {
|
||||
btn.layer.borderWidth = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let space = (frame.width - CGFloat(allTypes.count) * 37) / CGFloat(allTypes.count - 1)
|
||||
containerView.spacing = space
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 编辑弹窗
|
||||
class RDReaderEditView: UIView {
|
||||
|
||||
lazy var lineView: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = UIColor.lightGray
|
||||
return view
|
||||
}()
|
||||
|
||||
lazy var brightSlider: RDReaderBrightSlider = {
|
||||
let slider = RDReaderBrightSlider()
|
||||
return slider
|
||||
}()
|
||||
|
||||
lazy var fontView: RDReaderFontView = {
|
||||
let view = RDReaderFontView()
|
||||
return view
|
||||
}()
|
||||
|
||||
lazy var lineSpaceView: RDReaderLineSpaceView = {
|
||||
let view = RDReaderLineSpaceView()
|
||||
return view
|
||||
}()
|
||||
|
||||
lazy var pageCurlTypeView: RDReaderPageCurlTypeView = {
|
||||
let view = RDReaderPageCurlTypeView()
|
||||
return view
|
||||
}()
|
||||
|
||||
lazy var colorView: RDReaderThemeColorsView = {
|
||||
let view = RDReaderThemeColorsView()
|
||||
return view
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
func makeUI() {
|
||||
backgroundColor = .white
|
||||
addSubview(lineView)
|
||||
addSubview(brightSlider)
|
||||
addSubview(fontView)
|
||||
addSubview(lineSpaceView)
|
||||
addSubview(pageCurlTypeView)
|
||||
addSubview(colorView)
|
||||
|
||||
lineView.snp.makeConstraints { make in
|
||||
make.left.right.top.equalTo(0)
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
|
||||
brightSlider.snp.makeConstraints { make in
|
||||
make.top.equalTo(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(20)
|
||||
}
|
||||
|
||||
fontView.snp.makeConstraints { make in
|
||||
make.top.equalTo(brightSlider.snp.bottom).offset(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
|
||||
lineSpaceView.snp.makeConstraints { make in
|
||||
make.top.equalTo(fontView.snp.bottom).offset(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
|
||||
pageCurlTypeView.snp.makeConstraints { make in
|
||||
make.top.equalTo(lineSpaceView.snp.bottom).offset(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
|
||||
colorView.snp.makeConstraints { make in
|
||||
make.top.equalTo(pageCurlTypeView.snp.bottom).offset(20)
|
||||
make.left.equalTo(16)
|
||||
make.right.equalTo(-16)
|
||||
make.height.equalTo(37)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// RDReaderEndView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/11.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
class RDReaderEndView: UIView {
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
let textLabel = UILabel()
|
||||
textLabel.text = "结束页"
|
||||
textLabel.backgroundColor = .red
|
||||
textLabel.textAlignment = .center
|
||||
addSubview(textLabel)
|
||||
textLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(CGSize(width: 100, height: 100))
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//
|
||||
// RDReaderManager.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/20.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
struct Book {
|
||||
var desc: String?
|
||||
var chapters: [Chapter]
|
||||
}
|
||||
|
||||
struct Chapter {
|
||||
/// 第几章
|
||||
var sort: Int
|
||||
/// 章节名称
|
||||
var title: String?
|
||||
/// 划分了多少页
|
||||
var pageCount: Int
|
||||
/// 内容
|
||||
var content: String
|
||||
/// 分页
|
||||
var pages: [ChapterPage]
|
||||
|
||||
/// 文字的长度
|
||||
var range: NSRange
|
||||
}
|
||||
|
||||
struct ChapterPage {
|
||||
/// 第几章
|
||||
var chapterSort: Int
|
||||
/// 第几页
|
||||
var pageNum: Int
|
||||
/// 内容
|
||||
var content: String
|
||||
/// 内容
|
||||
var attrContent: NSAttributedString
|
||||
/// 文字的长度
|
||||
var range: NSRange
|
||||
}
|
||||
|
||||
public struct DemoBookItem {
|
||||
public var title: String
|
||||
public var source: BookSource
|
||||
|
||||
public init(title: String, source: BookSource) {
|
||||
self.title = title
|
||||
self.source = source
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension RDReaderManager {
|
||||
static let themeTypeKey = "themeType.key"
|
||||
static let brightValueKey = "brightValue.key"
|
||||
static let fontValueKey = "fontValue.key"
|
||||
static let lineSpaceKey = "lineSpace.key"
|
||||
static let pageCurlTypeKey = "pageCurlType.key"
|
||||
static let readProgressKey = "readProgress.key"
|
||||
}
|
||||
|
||||
typealias BrightValue = CGFloat
|
||||
extension BrightValue: RDReaderObserverType {
|
||||
var cachesValue: CGFloat {
|
||||
return self
|
||||
}
|
||||
var value: CGFloat {
|
||||
return self
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> BrightValue {
|
||||
return (value as? BrightValue) ?? self
|
||||
}
|
||||
}
|
||||
|
||||
extension Int: RDReaderObserverType {
|
||||
var cachesValue: Int {
|
||||
return self
|
||||
}
|
||||
var value: Int {
|
||||
return self
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> Int {
|
||||
return (value as? Int) ?? self
|
||||
}
|
||||
}
|
||||
|
||||
extension String: RDReaderObserverType {
|
||||
var cachesValue: String {
|
||||
return self
|
||||
}
|
||||
|
||||
var value: String {
|
||||
return self
|
||||
}
|
||||
|
||||
func transform(value: Any?) -> String {
|
||||
return (value as? String) ?? self
|
||||
}
|
||||
}
|
||||
|
||||
public class RDReaderManager: NSObject {
|
||||
public static let shared = RDReaderManager()
|
||||
let themeType = RDReaderObserver(key: RDReaderManager.themeTypeKey, defaultType: RDReaderCommon.Colors.white)
|
||||
let brightValue = RDReaderObserver(key: RDReaderManager.brightValueKey, defaultType: UIScreen.main.brightness)
|
||||
let fontValue = RDReaderObserver(key: RDReaderManager.fontValueKey, defaultType: CGFloat(15.0))
|
||||
let lineSpaceType = RDReaderObserver(key: RDReaderManager.lineSpaceKey, defaultType: RDReaderCommon.SpaceType.min)
|
||||
let pageCurlTypeType = RDReaderObserver(key: RDReaderManager.pageCurlTypeKey, defaultType: RDReaderCommon.PageCurlType.pageCurl)
|
||||
let readProgress = RDReaderObserver(key: RDReaderManager.readProgressKey, defaultType: 0)
|
||||
let epubReadLocation = RDReaderObserver(key: "epubReadLocation.key", defaultType: "")
|
||||
let epubSelection = RDReaderObserver(key: "epubSelection.key", defaultType: "")
|
||||
let epubHighlights = RDReaderObserver(key: "epubHighlights.key", defaultType: "")
|
||||
|
||||
}
|
||||
|
||||
protocol RDReaderObserverType {
|
||||
associatedtype Value
|
||||
associatedtype CacheValue
|
||||
associatedtype CurrentType
|
||||
var cachesValue: CacheValue { get }
|
||||
var value: Value { get }
|
||||
func transform(value: Any?) -> CurrentType
|
||||
}
|
||||
|
||||
|
||||
|
||||
class RDReaderObserver<CurrentType> where CurrentType: RDReaderObserverType {
|
||||
private var _currentType: CurrentType? = nil
|
||||
private var key: String
|
||||
private var defaultType: CurrentType
|
||||
typealias Observer = ((CurrentType.Value) -> Void)
|
||||
private var observers = [WeakObserver<Observer>]()
|
||||
var currentType: CurrentType {
|
||||
get {
|
||||
if _currentType != nil {
|
||||
return _currentType!
|
||||
} else {
|
||||
_currentType = (self.defaultType.transform(value: UserDefaults.standard.object(forKey: key)) as? CurrentType) ?? self.defaultType
|
||||
return _currentType!
|
||||
}
|
||||
}
|
||||
set {
|
||||
_currentType = newValue
|
||||
UserDefaults.standard.set(newValue.cachesValue, forKey: key)
|
||||
UserDefaults.standard.synchronize()
|
||||
observers.forEach { observer in
|
||||
if let observer = observer.observer?.observer {
|
||||
observer(_currentType!.value)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
init(key: String, defaultType: CurrentType) {
|
||||
self.key = key
|
||||
self.defaultType = defaultType
|
||||
}
|
||||
|
||||
func observeChange(onNext: Observer?) {
|
||||
if let onNext = onNext {
|
||||
onNext(currentType.value)
|
||||
observers.append(WeakObserver(observer: onNext))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class WeakObserver<T> {
|
||||
class _Observer<T>: NSObject {
|
||||
var observer: T
|
||||
init(observer: T) {
|
||||
self.observer = observer
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
weak var observer: _Observer<T>?
|
||||
private var _strongObserver: _Observer<T>?
|
||||
init(observer: T) {
|
||||
let t = _Observer(observer: observer)
|
||||
_strongObserver = t
|
||||
self.observer = _strongObserver
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
extension RDReaderManager {
|
||||
func epubReaderConfiguration() -> RDEPUBReaderConfiguration {
|
||||
let theme = themeType.currentType.value
|
||||
return RDEPUBReaderConfiguration(
|
||||
fontSize: fontValue.currentType,
|
||||
lineHeightMultiple: lineSpaceType.currentType.mulitiple,
|
||||
displayType: pageCurlTypeType.currentType.value,
|
||||
landscapeDualPageEnabled: true,
|
||||
reflowableContentInsets: UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16),
|
||||
fixedContentInset: .zero,
|
||||
theme: RDEPUBReaderTheme(
|
||||
contentBackgroundColor: theme.contentBackgroudColor ?? .white,
|
||||
contentTextColor: theme.contentTextColor ?? .black,
|
||||
toolBackgroundColor: theme.toolBackgroudColor ?? .white,
|
||||
toolControlTextColor: theme.toolControlTextColor ?? .black,
|
||||
toolControlBorderUnselectColor: theme.toolControlBorderUnSelectColor ?? UIColor.lightGray.withAlphaComponent(0.5),
|
||||
toolLineColor: theme.toolLineColor ?? UIColor.lightGray.withAlphaComponent(0.5)
|
||||
),
|
||||
fixedLayoutFit: .page,
|
||||
fixedLayoutSpreadMode: .automatic,
|
||||
textRenderingEngine: .dtCoreText
|
||||
)
|
||||
}
|
||||
|
||||
func epubPreferences(
|
||||
theme: RDReaderTheme? = nil,
|
||||
reflowableInsets: UIEdgeInsets,
|
||||
fixedContentInset: UIEdgeInsets
|
||||
) -> RDEPUBPreferences {
|
||||
let resolvedTheme = theme ?? themeType.currentType.value
|
||||
let backgroundColorCSS = resolvedTheme.contentBackgroudColor?.ss_cssString
|
||||
return RDEPUBPreferences(
|
||||
fontSize: fontValue.currentType,
|
||||
lineHeightMultiple: lineSpaceType.currentType.mulitiple,
|
||||
reflowableContentInsets: reflowableInsets,
|
||||
fixedContentInset: fixedContentInset,
|
||||
themeBackgroundColor: backgroundColorCSS,
|
||||
themeTextColor: resolvedTheme.contentTextColor?.ss_cssString,
|
||||
fixedBackgroundColor: backgroundColorCSS,
|
||||
fixedLayoutFit: .page,
|
||||
fixedLayoutSpreadMode: .automatic
|
||||
)
|
||||
}
|
||||
|
||||
func bundledEPUBURLs(in bundle: Bundle = .main) -> [URL] {
|
||||
(bundle.urls(forResourcesWithExtension: "epub", subdirectory: nil) ?? [])
|
||||
.sorted { lhs, rhs in
|
||||
lhs.deletingPathExtension().lastPathComponent.localizedStandardCompare(rhs.deletingPathExtension().lastPathComponent) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
public func demoBookItems(in bundle: Bundle = .main) -> [DemoBookItem] {
|
||||
let textDemo = DemoBookItem(title: "宠她.txt", source: .textFile)
|
||||
let epubItems = bundledEPUBURLs(in: bundle).map { url in
|
||||
DemoBookItem(title: url.deletingPathExtension().lastPathComponent, source: .epubFile(url: url))
|
||||
}
|
||||
return [textDemo] + epubItems
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func startReading(book item: DemoBookItem, from navigationController: UINavigationController?, animated: Bool = true) -> UIViewController? {
|
||||
let readerController: UIViewController
|
||||
switch item.source {
|
||||
case .textFile:
|
||||
guard let textURL = Bundle.main.url(forResource: "宠她", withExtension: "txt") else {
|
||||
return nil
|
||||
}
|
||||
readerController = RDURLReaderController(bookURL: textURL)
|
||||
case .epubFile(let url):
|
||||
readerController = RDURLReaderController(
|
||||
bookURL: url,
|
||||
epubConfiguration: epubReaderConfiguration()
|
||||
)
|
||||
}
|
||||
readerController.title = item.title
|
||||
navigationController?.pushViewController(readerController, animated: animated)
|
||||
return readerController
|
||||
}
|
||||
|
||||
func encodeTextFile(font: UIFont, lineSapce: CGFloat, size: CGSize) -> Book {
|
||||
let decodeString = String.encodeTextFile(url: URL(fileURLWithPath: Bundle.main.path(forResource: "宠她", ofType: ".txt")!))
|
||||
let parten = "第[0-9一二三四五六七八九十百千]*[章回].*"
|
||||
var chapters = [Chapter]()
|
||||
var desc: String?
|
||||
if let expression = try? NSRegularExpression(pattern: parten, options: .caseInsensitive) {
|
||||
let results = expression.matches(in: decodeString, options: .reportCompletion, range: NSMakeRange(0, decodeString.count))
|
||||
var startCount = -1
|
||||
var lastTitle: String?
|
||||
for result in results {
|
||||
let index = results.firstIndex(of: result)
|
||||
let range = result.range
|
||||
|
||||
if startCount != -1 && index! > 0 {
|
||||
let contentRange = NSMakeRange(startCount, range.location - startCount)
|
||||
|
||||
let content = decodeString.substring(with: Range(contentRange, in: decodeString)!)
|
||||
let pages = chapterPages(content: content, font: font, lineSapce: lineSapce, size: size, chapterSort: index!)
|
||||
let chapter = Chapter(sort: index!,title: lastTitle, pageCount: pages.count, content: String(content), pages: pages, range: contentRange)
|
||||
chapters.append(chapter)
|
||||
if index == results.count - 1 {
|
||||
let contentRange = NSMakeRange(range.location, decodeString.count - range.location)
|
||||
let content = decodeString.substring(with: Range(contentRange, in: decodeString)!)
|
||||
let title = decodeString.substring(with: Range(range, in: decodeString)!)
|
||||
let pages = chapterPages(content: content, font: font, lineSapce: lineSapce, size: size, chapterSort: index!)
|
||||
let chapter = Chapter(sort: index! + 1, title: title, pageCount: pages.count, content: String(content), pages: pages, range: contentRange)
|
||||
chapters.append(chapter)
|
||||
}
|
||||
}
|
||||
startCount = range.location
|
||||
lastTitle = decodeString.substring(with: Range(range, in: decodeString)!)
|
||||
if index == 0 && range.location > 0 {
|
||||
desc = decodeString.substring(with: Range(NSMakeRange(0, range.location), in: decodeString)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Book(desc: desc, chapters: chapters)
|
||||
}
|
||||
|
||||
func chapterPages(content: String, font: UIFont, lineSapce: CGFloat, size: CGSize, chapterSort: Int) -> [ChapterPage] {
|
||||
let attrString = NSMutableAttributedString(string: content)
|
||||
let style = NSMutableParagraphStyle()
|
||||
style.lineSpacing = lineSapce
|
||||
attrString.addAttributes([NSAttributedString.Key.font : font, NSAttributedString.Key.paragraphStyle: style], range: NSMakeRange(0, content.count))
|
||||
let pageRanges = attrString.pageRanges(size: size)
|
||||
let pages = pageRanges.map({
|
||||
pageRange -> ChapterPage in
|
||||
let pageContent = content.substring(with: Range(pageRange, in: content)!)
|
||||
let pageAttrContent = attrString.attributedSubstring(from: pageRange)
|
||||
return ChapterPage(chapterSort: chapterSort, pageNum: pageRanges.firstIndex(of: pageRange)!, content: pageContent, attrContent: pageAttrContent, range: pageRange)
|
||||
})
|
||||
|
||||
return pages
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// RDReaderTheme.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
|
||||
public protocol RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? { get }
|
||||
var contentTextColor: UIColor? { get }
|
||||
var toolBackgroudColor: UIColor? { get }
|
||||
var toolControlTextColor: UIColor? { get }
|
||||
var toolControlBorderUnSelectColor: UIColor? { get }
|
||||
var toolLineColor: UIColor? { get }
|
||||
}
|
||||
|
||||
struct WhiteTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor.white
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor.white
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
struct DarkTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor.black
|
||||
var contentTextColor: UIColor? = UIColor.white
|
||||
var toolBackgroudColor: UIColor? = UIColor.black
|
||||
var toolControlTextColor: UIColor? = UIColor.white
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
struct YellowTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1)
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor(red: 0.89, green: 0.87, blue: 0.79, alpha: 1)
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
|
||||
struct GreenTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1)
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor(red: 0.87, green: 0.91, blue: 0.82, alpha: 1)
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
struct PinkTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1)
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor(red: 1, green: 0.89, blue: 0.91, alpha: 1)
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
|
||||
|
||||
struct BlueTheme: RDReaderTheme {
|
||||
var contentBackgroudColor: UIColor? = UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1)
|
||||
var contentTextColor: UIColor? = UIColor.black
|
||||
var toolBackgroudColor: UIColor? = UIColor(red: 0.8, green: 0.84, blue: 0.89, alpha: 1)
|
||||
var toolControlTextColor: UIColor? = UIColor.black
|
||||
var toolControlBorderUnSelectColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
var toolLineColor: UIColor? = UIColor.lightGray.withAlphaComponent(0.5)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// RDReaderToolView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class RDReaderToolView: UIView {
|
||||
lazy var lineView: UIView = {
|
||||
let view = UIView()
|
||||
return view
|
||||
}()
|
||||
var lineHeight: CGFloat = 0.5
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
makeUI()
|
||||
}
|
||||
|
||||
|
||||
func makeUI() {
|
||||
addSubview(lineView)
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
lineView.frame = CGRect(x: 0, y: 0, width: frame.width, height: lineHeight)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
public class TintColorButton: UIButton {
|
||||
public override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
if let image = self.currentImage {
|
||||
let image = image.tintColorImage(color: tintColor)
|
||||
setImage(image, for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension UIImage {
|
||||
static func rdToolbarImage(named: String, fallbackSystemName: String) -> UIImage? {
|
||||
if let image = UIImage(named: named) {
|
||||
return image.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
return UIImage(systemName: fallbackSystemName)
|
||||
}
|
||||
|
||||
func tintColorImage(color: UIColor) -> UIImage? {
|
||||
UIGraphicsBeginImageContextWithOptions(size, false, scale)
|
||||
let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
|
||||
color.set()
|
||||
UIRectFill(rect)
|
||||
draw(at: .zero, blendMode: .destinationIn, alpha: 1)
|
||||
let newImage = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return newImage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// RDReaderTopToolView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
|
||||
|
||||
public class RDReaderTopToolView: RDReaderToolView,SSEventTrigger {
|
||||
|
||||
|
||||
enum Event {
|
||||
case back
|
||||
}
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
lineView.frame = CGRect(x: 0, y: frame.height - lineHeight, width: frame.width, height: lineHeight)
|
||||
}
|
||||
|
||||
lazy var backButton: TintColorButton = {
|
||||
let backButton = TintColorButton(type: .system)
|
||||
backButton.setImage(toolbarImage(named: "arrow_left", fallbackSystemName: "chevron.left"), for: .normal)
|
||||
return backButton
|
||||
}()
|
||||
|
||||
override func makeUI() {
|
||||
super.makeUI()
|
||||
addSubview(backButton)
|
||||
backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.left.equalTo(0)
|
||||
make.bottom.equalTo(0)
|
||||
make.height.equalTo(44)
|
||||
make.width.equalTo(50)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func backAction() {
|
||||
if let trigger = self.triggerEvent {
|
||||
trigger(.back)
|
||||
}
|
||||
}
|
||||
|
||||
private func toolbarImage(named: String, fallbackSystemName: String) -> UIImage? {
|
||||
if let image = UIImage(named: named) {
|
||||
return image.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
return UIImage(systemName: fallbackSystemName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// SSChapterListController.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/19.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
class SSChapterListController: UITableViewController {
|
||||
var selectedChapter: ((Int) -> Void)?
|
||||
var chapters: [Chapter]
|
||||
var currentChapterNum: Int
|
||||
init(chapters:[Chapter], currentChapterNum: Int) {
|
||||
self.chapters = chapters
|
||||
self.currentChapterNum = currentChapterNum
|
||||
super.init(style: .plain)
|
||||
|
||||
tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: view.frame.width, height: 0))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "UITableViewCell")
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return self.chapters.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell")
|
||||
cell?.textLabel?.text = self.chapters[indexPath.row].title
|
||||
cell?.textLabel?.textColor = indexPath.row == currentChapterNum ? UIColor.red : UIColor.black
|
||||
return cell!
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
if let selectedChapter = selectedChapter {
|
||||
selectedChapter(indexPath.row)
|
||||
}
|
||||
}
|
||||
|
||||
func selectedChapter(onTrigger: @escaping (Int) -> Void) {
|
||||
self.selectedChapter = onTrigger
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// EventTrigger.swift
|
||||
//
|
||||
//
|
||||
// Created by yangsq on 2020/11/4.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol SSEventTrigger {
|
||||
associatedtype Event
|
||||
typealias TriggerEvent = (Event) -> Void
|
||||
var triggerEvent: TriggerEvent? { get set }
|
||||
func trigger(event: TriggerEvent?)
|
||||
}
|
||||
|
||||
private var triggerEventKey: UInt8 = 0
|
||||
extension SSEventTrigger {
|
||||
private var _triggerEvent:TriggerEvent? {
|
||||
get {return objc_getAssociatedObject(self, &triggerEventKey) as? Self.TriggerEvent}
|
||||
set {objc_setAssociatedObject(self, &triggerEventKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)}
|
||||
}
|
||||
|
||||
var triggerEvent: TriggerEvent? {
|
||||
get{_triggerEvent}
|
||||
set{
|
||||
if newValue != nil {
|
||||
_triggerEvent = newValue!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func trigger(event: TriggerEvent?) {
|
||||
if event != nil {
|
||||
objc_setAssociatedObject(self, &triggerEventKey, event!, .OBJC_ASSOCIATION_COPY_NONATOMIC)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// RDReaderContentCell.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/7.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class RDReaderContentCell: UICollectionViewCell {
|
||||
private var _containerView: UIView? = nil
|
||||
var containerView: UIView? {
|
||||
set {
|
||||
guard newValue !== _containerView else { return }
|
||||
_containerView?.removeFromSuperview()
|
||||
_containerView = newValue
|
||||
if let containerView = _containerView {
|
||||
contentView.addSubview(containerView)
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
get {
|
||||
return _containerView
|
||||
}
|
||||
}
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
_containerView?.frame = CGRect(x: 0, y: 0, width: contentView.frame.width, height: contentView.frame.height)
|
||||
}
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
}
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//
|
||||
// RDReaderFlowLayout.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/6.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public protocol RDReaderFlowLayoutDataSoure: NSObjectProtocol {
|
||||
func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat?
|
||||
}
|
||||
|
||||
@objc public protocol RDReaderFlowLayoutDelegate: NSObjectProtocol {
|
||||
func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int)
|
||||
}
|
||||
|
||||
public class RDReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
|
||||
|
||||
var displayType: RDReaderView.DisplayType = .horizontalScroll {
|
||||
didSet {
|
||||
invalidateLayout()
|
||||
}
|
||||
}
|
||||
|
||||
var isLandscapeDualPage: Bool = false {
|
||||
didSet {
|
||||
if oldValue != isLandscapeDualPage {
|
||||
invalidateLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var coverPageIndex: Int? = nil {
|
||||
didSet {
|
||||
if oldValue != coverPageIndex {
|
||||
invalidateLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var lastPreparedBoundsSize: CGSize = .zero
|
||||
|
||||
var pagesPerScreen: Int {
|
||||
guard isLandscapeDualPage else { return 1 }
|
||||
switch displayType {
|
||||
case .horizontalScroll:
|
||||
if let cv = collectionView, cv.bounds.width > cv.bounds.height {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
weak var dataSource: RDReaderFlowLayoutDataSoure? = nil
|
||||
weak var delegate: RDReaderFlowLayoutDelegate? = nil
|
||||
|
||||
private var hasCoverPageInDualMode: Bool {
|
||||
return pagesPerScreen > 1 && coverPageIndex != nil
|
||||
}
|
||||
|
||||
private func coverAwareFrame(for index: Int, screenWidth: CGFloat, halfWidth: CGFloat, height: CGFloat) -> CGRect {
|
||||
guard let coverIndex = coverPageIndex else {
|
||||
let pairIdx = index / 2
|
||||
let side = index % 2
|
||||
let x = CGFloat(pairIdx) * screenWidth + CGFloat(side) * halfWidth
|
||||
return CGRect(x: x, y: 0, width: halfWidth, height: height)
|
||||
}
|
||||
if index == coverIndex {
|
||||
let screensBeforeCover = coverIndex / 2
|
||||
let x = CGFloat(screensBeforeCover) * screenWidth
|
||||
return CGRect(x: x, y: 0, width: screenWidth, height: height)
|
||||
}
|
||||
let adjustedIndex = index - (coverIndex + 1)
|
||||
let pairIdx = adjustedIndex / 2
|
||||
let side = adjustedIndex % 2
|
||||
let coverScreens = coverIndex / 2 + 1
|
||||
let x = CGFloat(coverScreens + pairIdx) * screenWidth + CGFloat(side) * halfWidth
|
||||
return CGRect(x: x, y: 0, width: halfWidth, height: height)
|
||||
}
|
||||
|
||||
/// cover-aware screen start index calculation
|
||||
private func coverAwareStartIndex(for offset: CGFloat, screenWidth: CGFloat) -> Int {
|
||||
let pps = pagesPerScreen
|
||||
guard let coverIdx = coverPageIndex, hasCoverPageInDualMode else {
|
||||
let screenIndex = Int((offset / screenWidth).rounded(.down))
|
||||
return screenIndex * pps
|
||||
}
|
||||
let coverScreens = coverIdx / 2 + 1
|
||||
let coverEnd = CGFloat(coverScreens) * screenWidth
|
||||
if offset < coverEnd {
|
||||
let screenIdx = Int((offset / screenWidth).rounded(.down))
|
||||
return screenIdx >= coverIdx / 2 ? coverIdx : screenIdx * pps
|
||||
} else {
|
||||
let pairIdx = Int(((offset - coverEnd) / screenWidth).rounded(.down))
|
||||
return coverIdx + 1 + pairIdx * 2
|
||||
}
|
||||
}
|
||||
|
||||
private var currentPage: Int = 0 {
|
||||
|
||||
didSet {
|
||||
if let delegate = delegate, delegate.responds(to: #selector(RDReaderFlowLayoutDelegate.pageNum(flowLayout:pageIndex:))), currentPage != oldValue, currentPage >= 0 {
|
||||
delegate.pageNum(flowLayout: self, pageIndex: currentPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(displayType: RDReaderView.DisplayType) {
|
||||
self.displayType = displayType
|
||||
super.init()
|
||||
|
||||
}
|
||||
|
||||
public override func prepare() {
|
||||
super.prepare()
|
||||
guard let collectionView = self.collectionView else {
|
||||
return
|
||||
}
|
||||
guard collectionView.frame.width > 0, collectionView.frame.height > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
lastPreparedBoundsSize = collectionView.bounds.size
|
||||
|
||||
if #available(iOS 11.0, *) {
|
||||
collectionView.contentInsetAdjustmentBehavior = .never
|
||||
} else {
|
||||
collectionView.ss_superViewController?.automaticallyAdjustsScrollViewInsets = false
|
||||
}
|
||||
collectionView.showsVerticalScrollIndicator = false
|
||||
collectionView.showsHorizontalScrollIndicator = false
|
||||
minimumLineSpacing = 0
|
||||
minimumInteritemSpacing = 0
|
||||
switch self.displayType {
|
||||
case .horizontalScroll:
|
||||
scrollDirection = .horizontal
|
||||
let columns = CGFloat(pagesPerScreen)
|
||||
itemSize = CGSize(width: collectionView.frame.width / columns, height: collectionView.frame.height)
|
||||
collectionView.isPagingEnabled = true
|
||||
case .verticalScroll:
|
||||
itemSize = CGSize(width: collectionView.frame.width, height: collectionView.frame.height)
|
||||
scrollDirection = .vertical
|
||||
collectionView.isPagingEnabled = false
|
||||
collectionView.showsVerticalScrollIndicator = true
|
||||
|
||||
default: break
|
||||
}
|
||||
|
||||
}
|
||||
public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
|
||||
if newBounds.size != lastPreparedBoundsSize {
|
||||
return true
|
||||
}
|
||||
switch displayType {
|
||||
case .verticalScroll:
|
||||
return true
|
||||
case .horizontalScroll:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public override var collectionViewContentSize: CGSize {
|
||||
var size = super.collectionViewContentSize
|
||||
guard let collectionView = collectionView else {
|
||||
return size
|
||||
}
|
||||
if displayType == .verticalScroll {
|
||||
let totalHeight = [Int](0..<collectionView.numberOfItems(inSection: 0)).reduce(0.0, { result, num in
|
||||
result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: num) ?? collectionView.frame.height)
|
||||
})
|
||||
size.height = totalHeight
|
||||
}
|
||||
if hasCoverPageInDualMode, displayType == .horizontalScroll {
|
||||
let totalItems = collectionView.numberOfItems(inSection: 0)
|
||||
let screenWidth = collectionView.frame.width
|
||||
let remainingItems = max(0, totalItems - 1)
|
||||
let pairedScreens = (remainingItems + 1) / 2
|
||||
size.width = screenWidth * CGFloat(1 + pairedScreens)
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
|
||||
var attributes = super.layoutAttributesForElements(in: rect)
|
||||
guard let collectionView = collectionView else {
|
||||
return attributes
|
||||
}
|
||||
|
||||
if self.displayType == .verticalScroll {
|
||||
let rowCount = collectionView.numberOfItems(inSection: 0)
|
||||
var totalHeight: CGFloat = 0
|
||||
for index in 0..<rowCount {
|
||||
totalHeight += dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: index) ?? collectionView.frame.height
|
||||
if totalHeight > collectionView.contentOffset.y {
|
||||
self.currentPage = index
|
||||
break
|
||||
}
|
||||
}
|
||||
(attributes ?? []).forEach({ attr in
|
||||
let indexPath = attr.indexPath
|
||||
let lastRow = max(0, indexPath.row - 1)
|
||||
let height = dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: indexPath.row) ?? collectionView.frame.height
|
||||
var lastTotalHeight: CGFloat = 0
|
||||
if indexPath.row > 0 {
|
||||
lastTotalHeight = [Int](0...lastRow).reduce(0.0) { result, row in
|
||||
result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: row) ?? collectionView.frame.height)
|
||||
}
|
||||
}
|
||||
if let cell = collectionView.cellForItem(at: indexPath) {
|
||||
cell.layer.shadowOpacity = 0
|
||||
}
|
||||
attr.frame = CGRect(x: 0, y: lastTotalHeight, width: collectionView.frame.width, height: height)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
if self.displayType == .horizontalScroll {
|
||||
let pps = pagesPerScreen
|
||||
|
||||
if hasCoverPageInDualMode {
|
||||
let screenWidth = collectionView.frame.width
|
||||
let halfWidth = screenWidth / 2.0
|
||||
let rows = collectionView.numberOfItems(inSection: 0)
|
||||
|
||||
self.currentPage = coverAwareStartIndex(for: collectionView.contentOffset.x, screenWidth: screenWidth)
|
||||
|
||||
var attrs = [UICollectionViewLayoutAttributes]()
|
||||
for index in 0..<rows {
|
||||
let frame = coverAwareFrame(for: index, screenWidth: screenWidth, halfWidth: halfWidth, height: collectionView.frame.height)
|
||||
guard frame.intersects(rect) else { continue }
|
||||
let indexPath = IndexPath(item: index, section: 0)
|
||||
let attr = UICollectionViewLayoutAttributes(forCellWith: indexPath)
|
||||
attr.frame = frame
|
||||
attrs.append(attr)
|
||||
if let cell = collectionView.cellForItem(at: indexPath) {
|
||||
cell.layer.shadowOpacity = 0
|
||||
}
|
||||
}
|
||||
attributes = attrs
|
||||
} else if pps > 1 {
|
||||
let currentPage = Int((collectionView.contentOffset.x / collectionView.frame.width).rounded(.down)) * pps
|
||||
self.currentPage = currentPage
|
||||
(attributes ?? []).forEach({ attr in
|
||||
if let cell = collectionView.cellForItem(at: attr.indexPath) {
|
||||
cell.layer.shadowOpacity = 0
|
||||
}
|
||||
})
|
||||
} else {
|
||||
let currentPage = Int((collectionView.contentOffset.x / collectionView.frame.width).rounded(.down))
|
||||
self.currentPage = currentPage
|
||||
(attributes ?? []).forEach({ attr in
|
||||
if let cell = collectionView.cellForItem(at: attr.indexPath) {
|
||||
cell.layer.shadowOpacity = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
func currentContentOffset(count: Int) -> CGPoint {
|
||||
guard let collectionView = collectionView else {
|
||||
return .zero
|
||||
}
|
||||
guard collectionView.frame.width > 0, collectionView.frame.height > 0 else {
|
||||
return .zero
|
||||
}
|
||||
let safeCount = max(0, count)
|
||||
switch displayType {
|
||||
case .verticalScroll:
|
||||
guard safeCount > 0 else { return .zero }
|
||||
let totalHeight = [Int](0...(safeCount - 1)).reduce(0.0) { result, pageNum in
|
||||
result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: pageNum) ?? collectionView.frame.height)
|
||||
}
|
||||
return CGPoint(x: 0, y: totalHeight)
|
||||
default:
|
||||
let pps = pagesPerScreen
|
||||
let screenWidth = collectionView.frame.width
|
||||
if hasCoverPageInDualMode, let coverIdx = coverPageIndex {
|
||||
if safeCount == coverIdx {
|
||||
let screensBeforeCover = coverIdx / 2
|
||||
return CGPoint(x: CGFloat(screensBeforeCover) * screenWidth, y: 0)
|
||||
}
|
||||
if safeCount < coverIdx {
|
||||
return CGPoint(x: CGFloat(safeCount / 2) * screenWidth, y: 0)
|
||||
}
|
||||
let coverScreens = coverIdx / 2 + 1
|
||||
let adjustedIndex = safeCount - (coverIdx + 1)
|
||||
let pairIndex = adjustedIndex / 2
|
||||
return CGPoint(x: CGFloat(coverScreens + pairIndex) * screenWidth, y: 0)
|
||||
} else if pps > 1 {
|
||||
let screenIndex = safeCount / pps
|
||||
return CGPoint(x: CGFloat(screenIndex) * screenWidth, y: 0)
|
||||
} else {
|
||||
return CGPoint(x: CGFloat(safeCount) * itemSize.width, y: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// RDReaderGestureController.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/10.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
|
||||
|
||||
class RDReaderGestureController: UIViewController {
|
||||
var topToolView: UIView?
|
||||
var bottomToolView: UIView?
|
||||
init(topToolView: UIView?, bottomToolView: UIView?) {
|
||||
self.topToolView = topToolView
|
||||
self.bottomToolView = bottomToolView
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Do any additional setup after loading the view.
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
// MARK: - Navigation
|
||||
|
||||
// In a storyboard-based application, you will often want to do a little preparation before navigation
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
// Get the new view controller using segue.destination.
|
||||
// Pass the selected object to the new view controller.
|
||||
}
|
||||
*/
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// RDReaderPageChildViewController.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/9.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class RDReaderPageChildViewController: UIViewController {
|
||||
private let contentContainerView = UIView()
|
||||
var contentView: UIView? {
|
||||
didSet {
|
||||
guard isViewLoaded else { return }
|
||||
installContentView()
|
||||
}
|
||||
}
|
||||
var pageNum: Int = 0
|
||||
init(contentView: UIView?, pageNum: Int = 0) {
|
||||
self.contentView = contentView
|
||||
self.pageNum = pageNum
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
contentContainerView.frame = view.bounds
|
||||
contentContainerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
contentContainerView.backgroundColor = .clear
|
||||
view.addSubview(contentContainerView)
|
||||
installContentView()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Do any additional setup after loading the view.
|
||||
}
|
||||
|
||||
private func installContentView() {
|
||||
contentContainerView.subviews.forEach { $0.removeFromSuperview() }
|
||||
guard let contentView else { return }
|
||||
contentView.removeFromSuperview()
|
||||
contentView.frame = contentContainerView.bounds
|
||||
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
contentContainerView.addSubview(contentView)
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
// MARK: - Navigation
|
||||
|
||||
// In a storyboard-based application, you will often want to do a little preparation before navigation
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
// Get the new view controller using segue.destination.
|
||||
// Pass the selected object to the new view controller.
|
||||
}
|
||||
*/
|
||||
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
//
|
||||
// RDReaderView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/6.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
|
||||
@objc public protocol RDReaderDataSource: NSObjectProtocol {
|
||||
func pageCountOfReaderView(readerView: RDReaderView) -> Int
|
||||
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
|
||||
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
|
||||
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
|
||||
}
|
||||
|
||||
@objc public protocol RDReaderDelegate: NSObjectProtocol {
|
||||
func pageNum(readerView: RDReaderView, pageNum: Int)
|
||||
/// 横竖屏切换时回调,可在此重新分页
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
|
||||
}
|
||||
|
||||
extension RDReaderView {
|
||||
public enum DisplayType {
|
||||
case pageCurl
|
||||
case horizontalScroll
|
||||
case verticalScroll
|
||||
}
|
||||
|
||||
/// 翻页方向
|
||||
public enum PageDirection {
|
||||
/// 从左往右翻页(默认,适用于中文/英文书籍)
|
||||
case leftToRight
|
||||
/// 从右往左翻页(适用于日文漫画等)
|
||||
case rightToLeft
|
||||
}
|
||||
}
|
||||
|
||||
public class RDReaderView: UIView {
|
||||
|
||||
enum TapEvent {
|
||||
case none, left, center, right
|
||||
}
|
||||
|
||||
private lazy var pageViewController: UIPageViewController = {
|
||||
let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil)
|
||||
pageVC.delegate = self
|
||||
pageVC.dataSource = self
|
||||
return pageVC
|
||||
}()
|
||||
|
||||
private lazy var layout: RDReaderFlowLayout = {
|
||||
let layout = RDReaderFlowLayout(displayType: .horizontalScroll)
|
||||
layout.dataSource = self
|
||||
layout.delegate = self
|
||||
return layout
|
||||
}()
|
||||
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
|
||||
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collectionView.backgroundColor = UIColor.clear
|
||||
return collectionView
|
||||
}()
|
||||
|
||||
public var currentPage: Int = -1 {
|
||||
didSet {
|
||||
if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDReaderDelegate.pageNum(readerView:pageNum:))) {
|
||||
delegate.pageNum(readerView: self, pageNum: currentPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private(set) lazy var tapGestureRecognizer: UITapGestureRecognizer = {
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(tapAction(tap:)))
|
||||
return tap
|
||||
}()
|
||||
|
||||
private var tapEvent: TapEvent = .none {
|
||||
didSet {
|
||||
let isRTL = pageDirection == .rightToLeft
|
||||
switch tapEvent {
|
||||
case .left:
|
||||
if currentDisplayType != .pageCurl {
|
||||
if isRTL { goNextPage() } else { goPreviousPage() }
|
||||
}
|
||||
case .right:
|
||||
if currentDisplayType != .pageCurl {
|
||||
if isRTL { goPreviousPage() } else { goNextPage() }
|
||||
}
|
||||
case .center:
|
||||
tapCenter()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func goNextPage() {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
if pagesPerScreen > 1 {
|
||||
if let target = adjacentDualPage(from: currentPage, forward: true) {
|
||||
transitionToPage(pageNum: target, animated: true)
|
||||
}
|
||||
} else if currentPage + 1 < totalPages {
|
||||
transitionToPage(pageNum: currentPage + 1, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func goPreviousPage() {
|
||||
if pagesPerScreen > 1 {
|
||||
if let target = adjacentDualPage(from: currentPage, forward: false) {
|
||||
transitionToPage(pageNum: target, animated: true)
|
||||
}
|
||||
} else if currentPage - 1 >= 0 {
|
||||
transitionToPage(pageNum: currentPage - 1, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
public weak var dataSource: RDReaderDataSource? = nil
|
||||
public weak var delegate: RDReaderDelegate? = nil
|
||||
public var currentDisplayType: RDReaderView.DisplayType = .pageCurl
|
||||
public var toolViewAnimationDuration: TimeInterval = 0.3
|
||||
/// 是否启用横屏双页显示
|
||||
public var landscapeDualPageEnabled: Bool = false
|
||||
/// 翻页方向,默认从左往右(.leftToRight)
|
||||
public var pageDirection: RDReaderView.PageDirection = .leftToRight
|
||||
/// 横屏双页模式下,封面页的索引。设置后该页在横屏时独占一屏,后续页面两两配对。
|
||||
/// 设为 nil 表示没有封面页(所有页面正常两两配对)。
|
||||
public var coverPageIndex: Int? = nil
|
||||
|
||||
/// 是否启用了封面页独占
|
||||
private var hasCoverPage: Bool {
|
||||
return coverPageIndex != nil
|
||||
}
|
||||
|
||||
/// 当前是否横屏
|
||||
private var isLandscape: Bool {
|
||||
return bounds.width > bounds.height
|
||||
}
|
||||
|
||||
/// 每屏显示的页数
|
||||
public var pagesPerScreen: Int {
|
||||
if !landscapeDualPageEnabled { return 1 }
|
||||
if currentDisplayType == .verticalScroll { return 1 }
|
||||
return isLandscape ? 2 : 1
|
||||
}
|
||||
|
||||
/// 判断某页在横屏双页模式下是否独占一屏(封面页)
|
||||
/// - Parameter pageNum: 页码
|
||||
/// - Returns: 是否独占一屏
|
||||
public func isFullScreenPage(_ pageNum: Int) -> Bool {
|
||||
guard landscapeDualPageEnabled, isLandscape, let coverIndex = coverPageIndex else { return false }
|
||||
return pageNum == coverIndex
|
||||
}
|
||||
|
||||
/// 根据逻辑页码计算横屏双页模式下的配对信息
|
||||
/// 封面页独占一屏,后续页面两两配对
|
||||
/// - Parameter pageNum: 逻辑页码
|
||||
/// - Returns: (左页码, 右页码(可选))
|
||||
private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
if let coverIndex = coverPageIndex {
|
||||
if pageNum == coverIndex {
|
||||
// 封面页独占一屏
|
||||
return (coverIndex, nil)
|
||||
}
|
||||
// 封面之后的页面:偏移1后两两配对
|
||||
// 例如封面是0页,则 1+2, 3+4, 5+6...
|
||||
let adjustedIndex = pageNum - (coverIndex + 1) // 跳过封面后的偏移
|
||||
let pairStart = coverIndex + 1 + (adjustedIndex / 2) * 2
|
||||
let left = pairStart
|
||||
let right = pairStart + 1 < totalPages ? pairStart + 1 : nil
|
||||
return (left, right)
|
||||
} else {
|
||||
// 无封面页:正常两两配对 0+1, 2+3, 4+5...
|
||||
let left = (pageNum / 2) * 2
|
||||
let right = left + 1 < totalPages ? left + 1 : nil
|
||||
return (left, right)
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算横屏双页下,某页往前/后翻一屏后的起始页码
|
||||
/// - Parameters:
|
||||
/// - pageNum: 当前页码
|
||||
/// - forward: 是否向后翻
|
||||
/// - Returns: 目标页码,nil 表示到头了
|
||||
private func adjacentDualPage(from pageNum: Int, forward: Bool) -> Int? {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
let pair = dualPagePair(for: pageNum)
|
||||
if forward {
|
||||
let nextStart = (pair.right ?? pair.left) + 1
|
||||
return nextStart < totalPages ? nextStart : nil
|
||||
} else {
|
||||
let prevEnd = pair.left - 1
|
||||
guard prevEnd >= 0 else { return nil }
|
||||
return dualPagePair(for: prevEnd).left
|
||||
}
|
||||
}
|
||||
|
||||
private var previousIsLandscape: Bool?
|
||||
|
||||
private var contentViews = [String : UIView.Type]()
|
||||
private var willPreviousTransitionToViewController: UIViewController? = nil
|
||||
private var willNextTransitionToViewController: UIViewController? = nil
|
||||
private var willTransitionToViewController: UIViewController? = nil
|
||||
private var topToolView: UIView?
|
||||
private var bottomToolView: UIView?
|
||||
private var isShowToolView: Bool = false
|
||||
private var isTransitioning: Bool = false
|
||||
private var didBuildUI = false
|
||||
|
||||
/// 用于 pageCurl 双页模式下封面页旁边的空白页
|
||||
static let blankPageNum = Int.max
|
||||
/// 用于 pageCurl 双页模式下末尾不成对页旁边的空白页
|
||||
static let blankEndPageNum = Int.max - 1
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||
let nowLandscape = isLandscape
|
||||
if let prev = previousIsLandscape, prev != nowLandscape {
|
||||
previousIsLandscape = nowLandscape
|
||||
// 延迟到下一个 RunLoop 执行,避免在 layoutSubviews 中嵌套触发 reloadData/layoutIfNeeded
|
||||
// 造成 collectionView 中间态尺寸不一致的问题
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.orientationChanged(isNowLandscape: nowLandscape)
|
||||
}
|
||||
} else if previousIsLandscape == nil {
|
||||
previousIsLandscape = nowLandscape
|
||||
layout.isLandscapeDualPage = landscapeDualPageEnabled && nowLandscape
|
||||
}
|
||||
}
|
||||
|
||||
/// 横竖屏切换处理
|
||||
/// - Parameter isNowLandscape: 当前是否为横屏
|
||||
private func orientationChanged(isNowLandscape: Bool) {
|
||||
let savedPage = max(0, currentPage)
|
||||
// 1. 通知代理方向变化。正文级重分页由上层控制器统一接管,这里只保留容器级刷新钩子。
|
||||
delegate?.readerViewOrientationWillChange?(readerView: self, isLandscape: isNowLandscape)
|
||||
// 2. 更新布局的横屏双页标记
|
||||
layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape
|
||||
layout.coverPageIndex = coverPageIndex
|
||||
|
||||
switch currentDisplayType {
|
||||
case .pageCurl:
|
||||
// 仿真翻页:通过 spineLocation 原生支持双页,需要重建 PageViewController
|
||||
rebuildPageViewController()
|
||||
transitionToPage(pageNum: savedPage)
|
||||
default:
|
||||
// 滚动模式:禁用动画防止旋转过渡中出现尺寸抖动
|
||||
UIView.performWithoutAnimation {
|
||||
// 强制使布局完全失效并重新计算
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
collectionView.reloadData()
|
||||
collectionView.layoutIfNeeded()
|
||||
// 3. 根据保存的页码恢复滚动位置
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
let safePage = min(savedPage, max(0, totalPages - 1))
|
||||
let targetOffset = layout.currentContentOffset(count: safePage)
|
||||
collectionView.setContentOffset(targetOffset, animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带指定 spine 位置的 UIPageViewController
|
||||
/// - Parameter isDualPage: 是否双页模式(横屏时书脊在中间:.mid)
|
||||
private func createPageViewController(isDualPage: Bool) -> UIPageViewController {
|
||||
let options: [UIPageViewController.OptionsKey: Any]?
|
||||
if isDualPage {
|
||||
options = [.spineLocation: NSNumber(value: UIPageViewController.SpineLocation.mid.rawValue)]
|
||||
} else {
|
||||
options = nil
|
||||
}
|
||||
let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: options)
|
||||
pageVC.delegate = self
|
||||
pageVC.dataSource = self
|
||||
pageVC.isDoubleSided = isDualPage
|
||||
return pageVC
|
||||
}
|
||||
|
||||
/// 重建 UIPageViewController。横竖屏切换时需要重建,因为 spineLocation 只能在初始化时设置
|
||||
private func rebuildPageViewController() {
|
||||
detachPageViewControllerIfNeeded()
|
||||
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape
|
||||
let pageVC = createPageViewController(isDualPage: isDualPage)
|
||||
pageViewController = pageVC
|
||||
attachPageViewControllerIfNeeded()
|
||||
}
|
||||
|
||||
public override func didMoveToSuperview() {
|
||||
super.didMoveToSuperview()
|
||||
|
||||
if superview != nil {
|
||||
makeUI()
|
||||
}
|
||||
}
|
||||
|
||||
private func attachPageViewControllerIfNeeded() {
|
||||
guard let parentViewController = self.ss_superViewController else { return }
|
||||
|
||||
var didAddToParent = false
|
||||
if pageViewController.parent !== parentViewController {
|
||||
if pageViewController.parent != nil {
|
||||
pageViewController.willMove(toParent: nil)
|
||||
pageViewController.view.removeFromSuperview()
|
||||
pageViewController.removeFromParent()
|
||||
}
|
||||
parentViewController.addChild(pageViewController)
|
||||
didAddToParent = true
|
||||
}
|
||||
|
||||
pageViewController.view.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
|
||||
pageViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
if pageViewController.view.superview !== self {
|
||||
insertSubview(pageViewController.view, at: 0)
|
||||
}
|
||||
|
||||
if didAddToParent {
|
||||
pageViewController.didMove(toParent: parentViewController)
|
||||
}
|
||||
}
|
||||
|
||||
private func detachPageViewControllerIfNeeded() {
|
||||
if pageViewController.parent != nil {
|
||||
pageViewController.willMove(toParent: nil)
|
||||
}
|
||||
pageViewController.view.removeFromSuperview()
|
||||
if pageViewController.parent != nil {
|
||||
pageViewController.removeFromParent()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeUI() {
|
||||
guard !didBuildUI else {
|
||||
if currentDisplayType == .pageCurl {
|
||||
attachPageViewControllerIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
didBuildUI = true
|
||||
|
||||
collectionView.dataSource = self
|
||||
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: NSStringFromClass(UICollectionViewCell.self))
|
||||
collectionView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
|
||||
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
|
||||
if currentDisplayType == .pageCurl {
|
||||
attachPageViewControllerIfNeeded()
|
||||
}
|
||||
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
// 不取消底层触摸事件,确保工具栏按钮(返回等)的 touchUpInside 能正常触发
|
||||
tapGestureRecognizer.cancelsTouchesInView = false
|
||||
|
||||
}
|
||||
|
||||
@objc private func tapAction(tap: UITapGestureRecognizer) {
|
||||
let point = tap.location(in: tap.view)
|
||||
if isShowToolView {
|
||||
let hitView = hitTest(point, with: nil)
|
||||
if let top = topToolView, isHitView(hitView, inside: top, point: point) { return }
|
||||
if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) { return }
|
||||
}
|
||||
let viewFrame = tap.view!.frame
|
||||
let leftFrame = CGRect(x: 0, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
|
||||
let centerFrame = CGRect(x: viewFrame.width / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
|
||||
let rightFrame = CGRect(x: viewFrame.width * 2 / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
|
||||
if leftFrame.contains(point) {
|
||||
if isShowToolView {
|
||||
tapEvent = .center
|
||||
} else {
|
||||
tapEvent = .left
|
||||
}
|
||||
}
|
||||
|
||||
if centerFrame.contains(point) {
|
||||
tapEvent = .center
|
||||
}
|
||||
|
||||
if rightFrame.contains(point) {
|
||||
if isShowToolView {
|
||||
tapEvent = .center
|
||||
} else {
|
||||
tapEvent = .right
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tapCenter() {
|
||||
isShowToolView = !isShowToolView
|
||||
if isShowToolView {
|
||||
if let topToolView = topToolView {
|
||||
addSubview(topToolView)
|
||||
topToolView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
topToolView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
topToolView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
topToolView.topAnchor.constraint(equalTo: topAnchor)
|
||||
])
|
||||
layoutIfNeeded()
|
||||
topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height)
|
||||
UIView.animate(withDuration: toolViewAnimationDuration) {
|
||||
topToolView.transform = .identity
|
||||
}
|
||||
}
|
||||
|
||||
if let bottomToolView = bottomToolView {
|
||||
addSubview(bottomToolView)
|
||||
bottomToolView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
bottomToolView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
bottomToolView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
bottomToolView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
layoutIfNeeded()
|
||||
bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height)
|
||||
UIView.animate(withDuration: toolViewAnimationDuration) {
|
||||
bottomToolView.transform = .identity
|
||||
}
|
||||
}
|
||||
|
||||
collectionView.isUserInteractionEnabled = false
|
||||
pageViewController.view.isUserInteractionEnabled = false
|
||||
} else {
|
||||
if let topToolView = topToolView {
|
||||
UIView.animate(withDuration: toolViewAnimationDuration, animations: {
|
||||
topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height)
|
||||
}) { _ in
|
||||
topToolView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
if let bottomToolView = bottomToolView {
|
||||
UIView.animate(withDuration: toolViewAnimationDuration, animations: {
|
||||
bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height)
|
||||
}) { _ in
|
||||
bottomToolView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
collectionView.isUserInteractionEnabled = true
|
||||
pageViewController.view.isUserInteractionEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
private func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool {
|
||||
if toolView.frame.contains(point) {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let hitView else { return false }
|
||||
return hitView === toolView || hitView.isDescendant(of: toolView)
|
||||
}
|
||||
|
||||
/// 切换翻页模式(仿真/水平滚动/上下滚动)
|
||||
/// 会重建底层视图(PageViewController 或 CollectionView),并恢复到当前页
|
||||
public func switchReaderDisplayType(_ displayType: RDReaderView.DisplayType) {
|
||||
self.currentDisplayType = displayType
|
||||
if currentPage == -1 {
|
||||
currentPage = 0
|
||||
}
|
||||
// 同步横屏双页标记到布局
|
||||
layout.isLandscapeDualPage = landscapeDualPageEnabled && isLandscape
|
||||
layout.coverPageIndex = coverPageIndex
|
||||
switch displayType {
|
||||
case .pageCurl:
|
||||
self.collectionView.removeFromSuperview()
|
||||
self.collectionView.transform = .identity
|
||||
attachPageViewControllerIfNeeded()
|
||||
rebuildPageViewController()
|
||||
transitionToPage(pageNum: currentPage)
|
||||
default:
|
||||
detachPageViewControllerIfNeeded()
|
||||
// RTL 水平模式翻转 collectionView
|
||||
if pageDirection == .rightToLeft && displayType != .verticalScroll {
|
||||
collectionView.transform = CGAffineTransform(scaleX: -1, y: 1)
|
||||
} else {
|
||||
collectionView.transform = .identity
|
||||
}
|
||||
// 确保 collectionView frame 正确后再触发布局计算
|
||||
collectionView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
|
||||
insertSubview(self.collectionView, at: 0)
|
||||
layout.displayType = displayType
|
||||
transitionToPage(pageNum: currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
/// 跳转到指定页码
|
||||
/// - Parameters:
|
||||
/// - pageNum: 目标页码(item 索引)
|
||||
/// - animated: 是否动画过渡
|
||||
public func transitionToPage(pageNum: Int, animated: Bool = false) {
|
||||
switch currentDisplayType {
|
||||
case .pageCurl:
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape
|
||||
// RTL 时动画方向需要反转
|
||||
let direction: UIPageViewController.NavigationDirection
|
||||
if pageDirection == .rightToLeft {
|
||||
direction = pageNum > currentPage ? .reverse : .forward
|
||||
} else {
|
||||
direction = pageNum > currentPage ? .forward : .reverse
|
||||
}
|
||||
if isDualPage {
|
||||
let pair = dualPagePair(for: pageNum)
|
||||
let leftContent = dataSource?.pageContentView(readerView: self, pageNum: pair.left, containerView: nil)
|
||||
let leftVC = RDReaderPageChildViewController(contentView: leftContent, pageNum: pair.left)
|
||||
if let rightPage = pair.right {
|
||||
let rightContent = dataSource?.pageContentView(readerView: self, pageNum: rightPage, containerView: nil)
|
||||
let rightVC = RDReaderPageChildViewController(contentView: rightContent, pageNum: rightPage)
|
||||
pageViewController.setViewControllers([leftVC, rightVC], direction: animated ? direction : .forward, animated: animated, completion: nil)
|
||||
} else {
|
||||
// 封面页独占或奇数最后一页:右侧放空白页
|
||||
let blankNum = isFullScreenPage(pair.left) ? RDReaderView.blankPageNum : RDReaderView.blankEndPageNum
|
||||
let emptyVC = RDReaderPageChildViewController(contentView: UIView(), pageNum: blankNum)
|
||||
pageViewController.setViewControllers([leftVC, emptyVC], direction: animated ? direction : .forward, animated: animated, completion: nil)
|
||||
}
|
||||
currentPage = pair.left
|
||||
} else {
|
||||
let contentView = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: nil)
|
||||
let vc = RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum)
|
||||
pageViewController.setViewControllers([vc], direction: animated ? direction : .forward, animated: animated, completion: nil)
|
||||
currentPage = pageNum
|
||||
}
|
||||
default:
|
||||
collectionView.reloadData()
|
||||
collectionView.layoutIfNeeded()
|
||||
collectionView.setContentOffset(layout.currentContentOffset(count: pageNum), animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
public func reloadData() {
|
||||
switchReaderDisplayType(currentDisplayType)
|
||||
topToolView = self.dataSource?.topToolView?(readerView: self)
|
||||
bottomToolView = self.dataSource?.bottomToolView?(readerView: self)
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDelegate {
|
||||
|
||||
private func makeSinglePageChildVC(for pageNum: Int) -> RDReaderPageChildViewController {
|
||||
if pageNum == RDReaderView.blankPageNum || pageNum == RDReaderView.blankEndPageNum {
|
||||
return RDReaderPageChildViewController(contentView: UIView(), pageNum: pageNum)
|
||||
}
|
||||
let contentView = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: nil)
|
||||
return RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum)
|
||||
}
|
||||
|
||||
/// 计算某页的"下一页"页码(考虑封面页插入空白页)
|
||||
private func nextPageNum(after pageNum: Int, isDualPage: Bool) -> Int? {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
|
||||
// 末尾空白页之后没有更多页
|
||||
if pageNum == RDReaderView.blankEndPageNum {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isDualPage, let coverIndex = coverPageIndex {
|
||||
if pageNum == coverIndex {
|
||||
return RDReaderView.blankPageNum
|
||||
}
|
||||
if pageNum == RDReaderView.blankPageNum {
|
||||
let firstContent = coverIndex + 1
|
||||
return firstContent < totalPages ? firstContent : nil
|
||||
}
|
||||
}
|
||||
|
||||
let next = pageNum + 1
|
||||
if next < totalPages {
|
||||
return next
|
||||
}
|
||||
|
||||
// pageNum 是最后一页,检查在双页模式下是否需要空白页配对
|
||||
if isDualPage {
|
||||
if let coverIndex = coverPageIndex {
|
||||
// 有封面时:封面之后的页面两两配对,偶数偏移=左页需要配对
|
||||
let adjustedIndex = pageNum - (coverIndex + 1)
|
||||
if adjustedIndex >= 0 && adjustedIndex % 2 == 0 {
|
||||
return RDReaderView.blankEndPageNum
|
||||
}
|
||||
} else {
|
||||
// 无封面:偶数索引=左页需要配对
|
||||
if pageNum % 2 == 0 {
|
||||
return RDReaderView.blankEndPageNum
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 计算某页的"上一页"页码(考虑封面页插入空白页)
|
||||
private func prevPageNum(before pageNum: Int, isDualPage: Bool) -> Int? {
|
||||
// 末尾空白页的前一页是最后一个真实页
|
||||
if pageNum == RDReaderView.blankEndPageNum {
|
||||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
return totalPages > 0 ? totalPages - 1 : nil
|
||||
}
|
||||
if isDualPage, let coverIndex = coverPageIndex {
|
||||
if pageNum == RDReaderView.blankPageNum {
|
||||
return coverIndex
|
||||
}
|
||||
if pageNum == coverIndex + 1 {
|
||||
return RDReaderView.blankPageNum
|
||||
}
|
||||
}
|
||||
let prev = pageNum - 1
|
||||
return prev >= 0 ? prev : nil
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
|
||||
guard let vc = viewController as? RDReaderPageChildViewController else { return nil }
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape
|
||||
let isRTL = pageDirection == .rightToLeft
|
||||
|
||||
// RTL 时 before/after 语义互换:before(向右翻)= 下一页
|
||||
let targetNum = isRTL
|
||||
? nextPageNum(after: vc.pageNum, isDualPage: isDualPage)
|
||||
: prevPageNum(before: vc.pageNum, isDualPage: isDualPage)
|
||||
|
||||
guard let num = targetNum else { return nil }
|
||||
let targetVC = makeSinglePageChildVC(for: num)
|
||||
willPreviousTransitionToViewController = targetVC
|
||||
return targetVC
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
|
||||
guard let vc = viewController as? RDReaderPageChildViewController else { return nil }
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape
|
||||
let isRTL = pageDirection == .rightToLeft
|
||||
|
||||
let targetNum = isRTL
|
||||
? prevPageNum(before: vc.pageNum, isDualPage: isDualPage)
|
||||
: nextPageNum(after: vc.pageNum, isDualPage: isDualPage)
|
||||
|
||||
guard let num = targetNum else { return nil }
|
||||
let targetVC = makeSinglePageChildVC(for: num)
|
||||
willNextTransitionToViewController = targetVC
|
||||
return targetVC
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||
if completed, let firstVC = pageViewController.viewControllers?.first as? RDReaderPageChildViewController {
|
||||
let pn = firstVC.pageNum
|
||||
if pn != RDReaderView.blankPageNum && pn != RDReaderView.blankEndPageNum {
|
||||
currentPage = pn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
||||
willTransitionToViewController = pendingViewControllers.first
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, RDReaderFlowLayoutDataSoure {
|
||||
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
|
||||
|
||||
if let identifer = self.dataSource?.pageIdentifier(readerView: self, pageNum: indexPath.row) {
|
||||
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifer, for: IndexPath(item: indexPath.row, section: 0)) as! RDReaderContentCell
|
||||
let conttainerView = self.dataSource?.pageContentView(readerView: self, pageNum: indexPath.row, containerView: cell.containerView)
|
||||
if let conttainerView = conttainerView {
|
||||
cell.containerView = conttainerView
|
||||
}
|
||||
// RTL 水平模式:翻转 cell 内容使文字方向正常(collectionView 已整体翻转)
|
||||
if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll {
|
||||
cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1)
|
||||
} else {
|
||||
cell.contentView.transform = .identity
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NSStringFromClass(UICollectionViewCell.self), for: indexPath)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
return self.dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||||
}
|
||||
|
||||
public func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) {
|
||||
currentPage = pageIndex
|
||||
|
||||
}
|
||||
|
||||
public func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension RDReaderView {
|
||||
public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String) {
|
||||
contentViews[identifier] = contentView
|
||||
collectionView.register(RDReaderContentCell.self, forCellWithReuseIdentifier: identifier)
|
||||
}
|
||||
|
||||
|
||||
public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView {
|
||||
if self.currentDisplayType != .pageCurl, let cell = self.collectionView.cellForItem(at: IndexPath(row: pageNum, section: 0)) as? RDReaderContentCell, let containerView = cell.containerView {
|
||||
return containerView
|
||||
}
|
||||
let contentViewClass = contentViews[identifier]
|
||||
assert(contentViewClass != nil, "请调用register(contentView:contentViewWithReuseIdentifier:)")
|
||||
var contentView = contentViewClass!.init()
|
||||
return contentView
|
||||
}
|
||||
|
||||
public func pageContentView(pageNum: Int) -> UIView? {
|
||||
if currentDisplayType == .pageCurl {
|
||||
return (self.pageViewController.viewControllers?.first as? RDReaderPageChildViewController)?.contentView
|
||||
} else {
|
||||
let cell = collectionView.cellForItem(at: IndexPath(item: pageNum, section: 0)) as? RDReaderContentCell
|
||||
return cell?.containerView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cellViewKey: Int8 = 0
|
||||
extension UIView {
|
||||
var ss_superViewController: UIViewController? {
|
||||
var next = self.next
|
||||
while next != nil {
|
||||
if next is UIViewController {
|
||||
return next as? UIViewController
|
||||
} else {
|
||||
next = next!.next
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import UIKit
|
||||
|
||||
public final class RDURLReaderController: UIViewController {
|
||||
private let bookURL: URL
|
||||
private let epubConfiguration: RDEPUBReaderConfiguration
|
||||
private var embeddedController: UIViewController?
|
||||
|
||||
public init(
|
||||
bookURL: URL,
|
||||
epubConfiguration: RDEPUBReaderConfiguration = RDEPUBReaderConfiguration()
|
||||
) {
|
||||
self.bookURL = bookURL
|
||||
self.epubConfiguration = epubConfiguration
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
title = bookURL.deletingPathExtension().lastPathComponent
|
||||
embedReaderController()
|
||||
}
|
||||
|
||||
private func embedReaderController() {
|
||||
let controller: UIViewController
|
||||
if bookURL.pathExtension.lowercased() == "epub" {
|
||||
controller = RDEPUBReaderController(
|
||||
epubURL: bookURL,
|
||||
configuration: epubConfiguration
|
||||
)
|
||||
} else {
|
||||
let bookIdentifier = bookURL.lastPathComponent
|
||||
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
|
||||
let pageSize = currentTextPageSize()
|
||||
let renderStyle = currentTextRenderStyle()
|
||||
let builder = RDPlainTextBookBuilder()
|
||||
if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) {
|
||||
controller = RDEPUBReaderController(
|
||||
textBook: textBook,
|
||||
bookIdentifier: bookIdentifier,
|
||||
title: bookTitle,
|
||||
textFileURL: bookURL,
|
||||
configuration: epubConfiguration
|
||||
)
|
||||
} else {
|
||||
// 分页失败时回退到纯文本展示
|
||||
let fallback = UIViewController()
|
||||
let textView = UITextView()
|
||||
textView.isEditable = false
|
||||
textView.text = rd_decodeTextFile(url: bookURL)
|
||||
fallback.view = textView
|
||||
controller = fallback
|
||||
}
|
||||
}
|
||||
embeddedController = controller
|
||||
addChild(controller)
|
||||
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(controller.view)
|
||||
NSLayoutConstraint.activate([
|
||||
controller.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
controller.didMove(toParent: self)
|
||||
}
|
||||
|
||||
private func currentTextPageSize() -> CGSize {
|
||||
let viewportSize = UIScreen.main.bounds.size
|
||||
let insets = epubConfiguration.reflowableContentInsets
|
||||
return CGSize(
|
||||
width: max(viewportSize.width - insets.left - insets.right, 1),
|
||||
height: max(viewportSize.height - insets.top - insets.bottom, 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
let font = UIFont.systemFont(ofSize: epubConfiguration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (epubConfiguration.lineHeightMultiple - 1), 4)
|
||||
return RDEPUBTextRenderStyle(
|
||||
font: font,
|
||||
lineSpacing: lineSpacing,
|
||||
textColor: epubConfiguration.theme.contentTextColor,
|
||||
backgroundColor: epubConfiguration.theme.contentBackgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
private func rd_decodeTextFile(url: URL) -> String {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
}
|
||||
if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String {
|
||||
return content
|
||||
}
|
||||
if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String {
|
||||
return content
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
(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.textDecorationColor = item.color || '#F8E16C';
|
||||
} else {
|
||||
mark.style.background = item.color || '#F8E16C';
|
||||
}
|
||||
mark.dataset.highlightId = item.id || '';
|
||||
range.surroundContents(mark);
|
||||
} 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>
|
||||
Reference in New Issue
Block a user