refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs

- Rename source module from RDReaderView to RDEpubReaderView
- Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/
- Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec
- Update Podfile, demo project, and CocoaPods config for new pod name
- Delete old RDReaderView pod support files from ReadViewDemo/Pods
- Add new RDEpubReaderView pod support files
- Update documentation (API ref, architecture, UML, conventions, etc.)
- Add FixedLayoutRotationTests
- Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
This commit is contained in:
shenlei
2026-07-10 19:44:53 +09:00
parent d5a7755702
commit d7fcda345d
460 changed files with 38358 additions and 2300 deletions
@@ -1,9 +1,9 @@
import Foundation
import UIKit
public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.RDEpubReader.highlight")
public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.RDEpubReader.underline")
public struct RDEPUBSelection: Codable, Equatable {
@@ -60,9 +60,11 @@ enum RDEPUBAssetRepository {
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
for bundleName in ["RDEpubReaderViewAssets", "RDReaderViewAssets"] {
if let url = hostBundle.url(forResource: bundleName, withExtension: "bundle"),
let bundle = Bundle(url: url) {
return bundle
}
}
}
return nil
@@ -0,0 +1,379 @@
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 = RDEPUBWebViewDebug.isInspectableEnabled
}
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 var measurementPassValues: [Int] = []
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
}
let finalValue = max(1, pendingMeasurementValue)
if measurementPassValues.count >= 2 {
let minVal = measurementPassValues.min() ?? 1
let maxVal = measurementPassValues.max() ?? 1
if minVal > 0 && Double(maxVal - minVal) / Double(minVal) > 0.2 {
RDEPUBWebViewDebug.log(debugScope, message: "measurement instability: passes=\(measurementPassValues) spine=\(currentSpineIndex)")
}
}
pageCounts[currentSpineIndex] = finalValue
currentMeasurementOffset += 1
measurementPassValues = []
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)
self.measurementPassValues.append(number.intValue)
} else if let intValue = value as? Int {
self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue)
self.measurementPassValues.append(intValue)
}
RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)")
self.scheduleMeasurementPass()
}
}
}
extension RDEPUBPaginator: WKNavigationDelegate {
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
let scheme = url.scheme?.lowercased() ?? ""
if scheme == "file" || scheme == RDEPUBResourceURLSchemeHandler.scheme {
decisionHandler(.allow)
} else {
RDEPUBWebViewDebug.log(debugScope, message: "blocked navigation to non-local scheme: \(scheme)")
decisionHandler(.cancel)
}
}
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
}
}
@@ -111,11 +111,18 @@ final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
updatedItem.title = manifestTitleByID[item.id]
return updatedItem
}
// EPUB OPF manifest id`uniqueKeysWithValues`
// spine OPF
//
let manifestByID = manifest.reduce(into: [String: RDEPUBManifestItem]()) { result, item in
guard !item.id.isEmpty, result[item.id] == nil else { return }
result[item.id] = item
}
return OPFPackageDocument(
metadata: metadata,
manifest: manifest,
manifestByID: Dictionary(uniqueKeysWithValues: manifest.map { ($0.id, $0) }),
manifestByID: manifestByID,
spineReferences: spineReferences,
ncxItem: manifest.first(where: { $0.id == ncxID || $0.isNCX }),
navigationItem: manifest.first(where: { $0.isNavigationDocument })
@@ -174,7 +174,8 @@ public final class RDEPUBParser {
let packageDocument = delegate.packageDocument()
self.opfURL = opfURL
self.metadata = packageDocument.metadata
self.manifest = Dictionary(uniqueKeysWithValues: packageDocument.manifest.map { ($0.id, $0) })
// packageDocument 使 manifest id
self.manifest = packageDocument.manifestByID
self.spine = try buildSpine(from: packageDocument, opfURL: opfURL)
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
}
@@ -206,4 +207,4 @@ private extension URL {
}
return totalSize
}
}
}
@@ -0,0 +1,132 @@
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()
let messageHandlerProxy = RDEPUBWeakScriptMessageHandler(target: self)
RDEPUBJavaScriptBridge.messageNames.forEach {
userContentController.add(messageHandlerProxy, 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)
}
// M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController
if #available(iOS 16.0, *) {
let interaction = UIEditMenuInteraction(delegate: webView)
webView.addInteraction(interaction)
webView._editMenuInteraction = interaction
} else {
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
// Web 59pt 21pt
// /
// fixedContentInset / CSS
webView.scrollView.contentInsetAdjustmentBehavior = .never
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 = RDEPUBWebViewDebug.isInspectableEnabled
}
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))")
}
// M-15: Clean up UIEditMenuInteraction on iOS 16+
if #available(iOS 16.0, *), let interaction = (webView as? RDEPUBAnnotationWebView)?._editMenuInteraction as? UIEditMenuInteraction {
webView?.removeInteraction(interaction)
}
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
}
}
/// M-08: WKUserContentController script message handler
/// RDEPUBWebView
/// webView userContentController RDEPUBWebView webView
/// WebView handler
/// RDEPUBWebView deinit
private final class RDEPUBWeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
private weak var target: WKScriptMessageHandler?
init(target: WKScriptMessageHandler) {
self.target = target
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
target?.userContentController(userContentController, didReceive: message)
}
}
@@ -0,0 +1,243 @@
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)
func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?)
func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?)
}
public extension RDEPUBWebViewDelegate {
func epubWebView(_ webView: RDEPUBWebView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction) {}
func epubWebView(_ webView: RDEPUBWebView, didTapImageWithSource src: String, sourceRect: CGRect?) {}
func epubWebView(_ webView: RDEPUBWebView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?) {}
}
final class RDEPUBAnnotationWebView: WKWebView {
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
// Stored as Any? to avoid @available on stored property restriction.
var _editMenuInteraction: Any?
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)
}
}
// MARK: - UIEditMenuInteractionDelegate (iOS 16+)
@available(iOS 16.0, *)
extension RDEPUBAnnotationWebView: UIEditMenuInteractionDelegate {
func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
menuFor configuration: UIEditMenuConfiguration
) -> UIMenu {
UIMenu(children: [
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
])
}
func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
targetRectFor configuration: UIEditMenuConfiguration
) -> CGRect {
bounds
}
}
public final class RDEPUBWebView: UIView {
public weak var delegate: RDEPUBWebViewDelegate?
public var onRendered: (() -> Void)?
var onDecorationsResolved: (([RDEPUBTextOverlayDecoration]) -> 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")
}
// M-08: script message handler RDEPUBWeakScriptMessageHandler
// configureWebViewIfNeededwebView userContentController self
// deinit
// reset() WKWebView dealloc
deinit {
teardownWebView()
}
public override func layoutSubviews() {
super.layoutSubviews()
webView?.frame = bounds
}
public func reset() {
delegate = nil
onRendered = nil
onDecorationsResolved = 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?()
}
}
}
@@ -41,7 +41,7 @@ enum RDEPUBWebViewDebug {
static func log(_ scope: String, message: String) {
guard isEnabled else { return }
print("[RDReaderWK][\(scope)] \(message)")
print("[RDEpubReaderWK][\(scope)] \(message)")
}
static func logNavigationEvent(_ scope: String, webView: WKWebView?, event: String, url: URL? = nil, error: Error? = nil) {
@@ -2,7 +2,7 @@
if (window.WeReadApi) { return; }
function bridge() {
return window.RDReaderBridge || null;
return window.RDEpubReaderBridge || null;
}
function sharedThemeCSS(theme) {
@@ -1,5 +1,5 @@
(function() {
if (window.RDReaderBridge) { return; }
if (window.RDEpubReaderBridge) { return; }
window.addEventListener('error', function(event) {
try {
var message = event && (event.message || (event.error && event.error.message)) || 'Unknown JavaScript error';
@@ -478,7 +478,7 @@
node.addEventListener('error', scheduleRelayout, { passive: true });
});
}
window.RDReaderBridge = {
window.RDEpubReaderBridge = {
applyPagination: function(styleText) {
if (isFixedLayoutDocument()) { return; }
if (window.RDInjectedCSS) {
@@ -0,0 +1,266 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<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 parseCssPixelValue(value) {
if (!value) { return 0; }
var match = String(value).match(/([0-9]+(?:\.[0-9]+)?)px?/);
return match ? Number.parseFloat(match[1]) : 0;
}
function sizeFromElement(element) {
if (!element) { return null; }
var width = parseCssPixelValue(element.style && element.style.width);
var height = parseCssPixelValue(element.style && element.style.height);
if (!width || !height) {
width = parseCssPixelValue(element.getAttribute && element.getAttribute('width'));
height = parseCssPixelValue(element.getAttribute && element.getAttribute('height'));
}
if (!width || !height) {
var rect = element.getBoundingClientRect ? element.getBoundingClientRect() : null;
if (rect) {
width = width || rect.width;
height = height || rect.height;
}
}
if (width > 1 && height > 1) {
return { width: width, height: height };
}
return null;
}
function parsePageSizeFromDocumentBox(iframe) {
try {
var doc = iframe.contentWindow.document;
return sizeFromElement(doc.body) ||
sizeFromElement(doc.documentElement) ||
sizeFromElement(doc.querySelector('[id$="_hype_container"]')) ||
sizeFromElement(doc.querySelector('iframe')) ||
null;
} catch (error) {
return null;
}
}
function parsePageSizeFromNestedFrame(iframe) {
try {
var nestedFrame = iframe.contentWindow.document.querySelector('iframe');
if (!nestedFrame || !nestedFrame.contentWindow || !nestedFrame.contentWindow.document) {
return null;
}
return parsePageSizeFromViewportMetaTag(nestedFrame) ||
parsePageSizeFromDocumentBox(nestedFrame) ||
parsePageSizeFromEmbeddedImage(nestedFrame);
} 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) ||
parsePageSizeFromDocumentBox(iframe) ||
parsePageSizeFromNestedFrame(iframe) ||
parsePageSizeFromEmbeddedImage(iframe) ||
pageViewportSize(iframe);
layoutPage(iframe);
window.setTimeout(function() {
layoutPage(iframe);
}, 250);
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>
@@ -106,7 +106,7 @@ public final class RDEPUBTextBookCache {
public var schemaVersion: Int = 14
private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility)
private let queue = DispatchQueue(label: "com.RDEpubReader.textbookcache", qos: .utility)
private let cacheDirectory: URL
@@ -2,19 +2,19 @@ import UIKit
public extension NSAttributedString.Key {
static let rdPageBlockRange = NSAttributedString.Key("com.rdreader.epub.pageBlockRange")
static let rdPageBlockRange = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockRange")
static let rdPageBlockIndex = NSAttributedString.Key("com.rdreader.epub.pageBlockIndex")
static let rdPageBlockIndex = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockIndex")
static let rdPageFragmentID = NSAttributedString.Key("com.rdreader.epub.pageFragmentID")
static let rdPageFragmentID = NSAttributedString.Key("com.RDEpubReader.epub.pageFragmentID")
static let rdPageAttachmentKind = NSAttributedString.Key("com.rdreader.epub.pageAttachmentKind")
static let rdPageAttachmentKind = NSAttributedString.Key("com.RDEpubReader.epub.pageAttachmentKind")
static let rdPageBlockKind = NSAttributedString.Key("com.rdreader.epub.pageBlockKind")
static let rdPageBlockKind = NSAttributedString.Key("com.RDEpubReader.epub.pageBlockKind")
static let rdPageSemanticHints = NSAttributedString.Key("com.rdreader.epub.pageSemanticHints")
static let rdPageSemanticHints = NSAttributedString.Key("com.RDEpubReader.epub.pageSemanticHints")
static let rdPageAttachmentPlacement = NSAttributedString.Key("com.rdreader.epub.pageAttachmentPlacement")
static let rdPageAttachmentPlacement = NSAttributedString.Key("com.RDEpubReader.epub.pageAttachmentPlacement")
}
public enum RDEPUBTextBlockKind: String, Codable, Equatable, CaseIterable {
@@ -1,6 +1,6 @@
import UIKit
public final class RDPlainTextBookBuilder {
public final class RDEpubPlainTextBookBuilder {
private let renderer: RDEPUBTextRenderer
private let layoutConfig: RDEPUBTextLayoutConfig
@@ -34,6 +34,16 @@ final class RDEPUBDecryptingImageAttachment: DTImageTextAttachment {
decryptImageIfNeeded()
}
/// DTCoreText / UIKit HTML NSTextAttachment
/// `init(data:ofType:)` Foundation
/// Use of unimplemented initializer
override init(data contentData: Data?, ofType uti: String?) {
super.init(data: contentData, ofType: uti)
if image == nil, let contentData {
image = UIImage(data: contentData)
}
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}

Some files were not shown because too many files have changed in this diff Show More