PDF 阅读器新增点读热区、试读墙与阅读进度/书签持久化

宿主的点读书场景需要在 PDF 页面上叠加可点击热区(音频/视频/网页等),并支持
试读限制与阅读进度、书签的本地保存,这些能力此前 RDPDFReaderView 均不提供,
只能由宿主在阅读器之外自行叠加视图,无法跟随 SDK 的缩放、翻页和主题渲染同步。

新增 RDPDFReaderPageInteraction 描述热区(位置、图标、边框/填充样式、点击态、
闪烁与播放边框规则、内嵌视频),由新增的 RDPDFInteractionHotspotView 负责绘制,
SVG 图标经 RDPDFReaderSVGIconLoader(基于 SDWebImage/SDWebImageSVGCoder)异步
解码后回填,避免阻塞主线程;点击事件通过 interactionHandler 回传给宿主,媒体
播放、路由等业务仍留在宿主侧。底部工具栏新增点读提示与连播按钮,状态由宿主驱动。

试读墙对齐 EPUB 侧 RDEPUBReaderTrialPolicy 的设计:以页为限制单位,可读页数在
配置了 trialPolicy 且宿主经 delegate 提供墙视图时生效,原始总页数、缩略图、目录
换算不受影响,只有传给 RDPDFReaderView 的有效页数会追加一页试读墙;翻页、目录
跳转、大纲的上界都统一收敛到可读范围,避免用户绕过热区跳转或朗读高亮跳进未授权页。

阅读进度与书签复用 RDPDFReaderPersistenceStore 已有的标注状态目录,新增
reader-state.json 承载,不需要宿主额外提供数据库表;RDPDFReaderPersistenceStore
同时实现 RDPDFReaderPersistence 协议对接这部分读写。
This commit is contained in:
shenlei
2026-08-14 12:13:04 +09:00
parent 278072aca0
commit e59bad9abf
11 changed files with 1064 additions and 16 deletions
@@ -11,7 +11,10 @@ Pod::Spec.new do |s|
s.license = "MIT"
s.source_files = "{Sources,ReaderView}/**/*.swift"
s.dependency "SnapKit", "~> 5.7"
s.frameworks = "Vision", "CoreImage", "PDFKit"
# 点读热区的 SVG 图标与 GIF 背景图依赖 SDWebImage 解码链。
s.dependency "SDWebImageSVGCoder", "~> 1.8"
s.dependency "SDWebImage", "~> 5.17"
s.frameworks = "Vision", "CoreImage", "PDFKit", "AVKit"
s.requires_arc = true
s.subspec "Speech" do |speech|
+4
View File
@@ -56,3 +56,7 @@ navigationController?.pushViewController(RDPDFReaderDebugViewController(), anima
3. OCR 也不可用时,文字层会提供区域框选;区域标注只显示“高亮、注释”,不会错误地提供复制。
高亮与注释统一使用 `RDPDFReaderAnnotation`,坐标同样是相对图片的 `0...1` 比例,缩放、旋转或重新渲染页面后仍能对齐。使用 `RDPDFReaderPersistenceStore` 保存时,请传入宿主稳定的书籍 ID(可附账号和内容版本)对应的目录,并对 `addAnnotation``updateAnnotation``deleteAnnotation``throws` 结果做错误提示;文件损坏或版本不兼容时 SDK 会拒绝覆盖原数据。
## 点读热区
宿主可在 `RDPDFReaderPageDescriptor` 中传入 `interactions`,SDK 负责绘制 SVG 图标、背景图与播放反馈;点击事件通过 `RDPDFReaderViewController.interactionHandler` 回传,媒体播放、网页或路由仍由宿主处理。页面资源异步补齐后,调用 `reloadPageContent(at:)` 刷新指定页;播放状态可通过 `setActiveInteraction(identifier:)` 更新。
@@ -28,6 +28,15 @@ extension RDPDFReaderView {
}
func configureInteraction(for view: UIView) {
// UIView RDPDFReaderPageInteractable
// /
if let wall = view as? RDPDFTrialWallContainerView {
wall.contentTapHandler = { [weak self, weak view] point in
guard let self, let view else { return }
self.handleContentTap(view.convert(point, to: self))
}
return
}
guard let page = view as? RDPDFReaderPageInteractable else { return }
// ReaderView
// pinch/double-tap
@@ -0,0 +1,383 @@
import UIKit
import SDWebImage
import AVKit
///
///
/// OC
/// - + `RBCoreMediaLinkPaper` 0~100
/// opacity线****/
/// `flickerCount > 0` N×2
/// `flickerCount <= 0` "
/// "`flickerCount <= 0`
/// - SVG `RBCoreMediaIconController` bgIcon = `min(w, h) × iconRate`
/// aspectFit****
/// - bgImg aspectFit****
/// - `ShowEmbedVideo``RBCoreVideoViewController`
/// /// `setupEmbedVideoPoster`
/// + `RBCoreVideoView.xib`
/// `videoPlayView``darkTextColor`+ `videoCoverImageView` `alpha=1`
/// + 60×60 `videoPlay.png`
final class RDPDFInteractionHotspotView: UIView {
var onTap: (() -> Void)?
private let interaction: RDPDFReaderPageInteraction
private(set) var isActive: Bool
/// 宿 identifier
var interactionIdentifier: String { interaction.identifier }
/// OC CALayer link
/// 便
private let styleView = UIView()
/// OC `RBCoreAudioHoverPaper`150ms
private let hoverFlashView = UIView()
/// `SDAnimatedImageView` GIF > 1
/// OC bgImg `SDImageFormatGIF` `sd_imageWithGIFData:`
private let backgroundImageView = SDAnimatedImageView()
private let iconImageView = UIImageView()
private var flickerTimer: Timer?
private var remainingFlickerToggles = 0
/// `ShowEmbedVideo` OC `videoPlayView`/
/// `videoCoverImageView`
///
private let embedVideoBackdropView = UIView()
/// OC `RBCoreVideoViewController`
/// 60×60
private let embedVideoPlayButton = UIButton(type: .custom)
private var embedPlayerViewController: AVPlayerViewController?
private var isEmbedVideo: Bool { interaction.embedVideoURL != nil }
/// - Parameter skipEntranceFlicker: 宿
/// `configureInteractions` `true`
///
init(interaction: RDPDFReaderPageInteraction, isActive: Bool, skipEntranceFlicker: Bool = false) {
self.interaction = interaction
self.isActive = isActive
super.init(frame: .zero)
backgroundColor = .clear
// iconRate > 1 OC
clipsToBounds = false
if interaction.embedVideoURL != nil {
// //
// hitTest
setupEmbedVideoPoster()
return
}
styleView.isUserInteractionEnabled = false
addSubview(styleView)
hoverFlashView.isUserInteractionEnabled = false
hoverFlashView.layer.cornerRadius = 5
hoverFlashView.clipsToBounds = true
hoverFlashView.isHidden = true
addSubview(hoverFlashView)
backgroundImageView.contentMode = .scaleAspectFit
backgroundImageView.isUserInteractionEnabled = false
addSubview(backgroundImageView)
iconImageView.contentMode = .scaleAspectFit
iconImageView.isUserInteractionEnabled = false
addSubview(iconImageView)
applyStyle(skipEntranceFlicker: skipEntranceFlicker)
loadBackgroundImage()
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap)))
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
deinit {
flickerTimer?.invalidate()
if let embedPlayerViewController {
embedPlayerViewController.player?.pause()
embedPlayerViewController.willMove(toParent: nil)
embedPlayerViewController.view.removeFromSuperview()
embedPlayerViewController.removeFromParent()
}
}
// MARK: -
private func setupEmbedVideoPoster() {
embedVideoBackdropView.backgroundColor = .black
embedVideoBackdropView.isUserInteractionEnabled = false
addSubview(embedVideoBackdropView)
// 宿 bgImgInfo.bgImgUrl customData.embedBgCover.imgUrl
// OC
// OC `videoCoverImageView`aspectFit
// OC
// `RBCoreVideoView.xib` `videoCoverImageView`
// backgroundImageView/loadBackgroundImage
backgroundImageView.contentMode = .scaleAspectFit
backgroundImageView.isUserInteractionEnabled = false
addSubview(backgroundImageView)
loadBackgroundImage()
// OC videoPlay.png readoor/BookView/PDF/Core/Resources
// / App target 退
embedVideoPlayButton.setImage(UIImage(named: "videoPlay") ?? UIImage(systemName: "play.fill"), for: .normal)
embedVideoPlayButton.tintColor = .white
embedVideoPlayButton.addTarget(self, action: #selector(handleEmbedVideoTap), for: .touchUpInside)
addSubview(embedVideoPlayButton)
}
/// 宿 UIViewController沿
/// VC `AVPlayerViewController`
/// // `onTap` 线
/// ViewModel `EventNone` OC
/// /
@objc private func handleEmbedVideoTap() {
guard embedPlayerViewController == nil,
let url = interaction.embedVideoURL,
let hostViewController = nearestViewController() else { return }
let playerViewController = AVPlayerViewController()
playerViewController.player = AVPlayer(url: url)
playerViewController.view.frame = bounds
playerViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostViewController.addChild(playerViewController)
addSubview(playerViewController.view)
playerViewController.didMove(toParent: hostViewController)
embedPlayerViewController = playerViewController
embedVideoBackdropView.isHidden = true
backgroundImageView.isHidden = true
embedVideoPlayButton.isHidden = true
playerViewController.player?.play()
onTap?()
}
private func nearestViewController() -> UIViewController? {
var responder: UIResponder? = self
while let current = responder {
if let viewController = current as? UIViewController { return viewController }
responder = current.next
}
return nil
}
// MARK: -
/// /
/// "" OC `flashLink`
/// `setActive(_:)`
/// - Parameter skipEntranceFlicker: 宿
/// / `true`
/// `isStyleHiddenAtRest`
private func applyStyle(skipEntranceFlicker: Bool = false) {
applyBorderAndFillColors()
if isActive {
applyActivePlayNotice()
} else if interaction.flickerCount > 0 && !skipEntranceFlicker {
// flickerCount > 0 N×2 OC isNew==2
styleView.isHidden = true
startFlicker(times: interaction.flickerCount * 2)
} else {
styleView.isHidden = isStyleHiddenAtRest
}
}
private func applyBorderAndFillColors() {
if let fillColor = interaction.fillColorHex, interaction.fillOpacity > 0 {
styleView.backgroundColor = UIColor.pdfReaderColor(
hex: fillColor,
alpha: CGFloat(interaction.fillOpacity) / 100
)
} else {
styleView.backgroundColor = .clear
}
let activeBorderHex = isActive ? Self.strictHexOnly(interaction.activeBorderColorHex) : nil
let borderHex = activeBorderHex ?? interaction.borderColorHex
if let borderHex {
styleView.layer.borderColor = UIColor.pdfReaderColor(
hex: borderHex,
alpha: CGFloat(interaction.borderOpacity) / 100
).cgColor
styleView.layer.borderWidth = 1 / UIScreen.main.scale
} else {
styleView.layer.borderWidth = 0
}
}
/// isNew==2 playBorderColor iPad 3pt / iPhone 2pt
/// OC `showMediaPaper:`playNoticeEnable == false
/// noticeStatus == "bright" "flicker"
/// 0.7s 0.3s `applyBorderAndFillColors`
/// 线
private func applyActivePlayNotice() {
guard interaction.playNoticeEnable else {
styleView.isHidden = true
return
}
styleView.layer.borderWidth = UIDevice.current.userInterfaceIdiom == .pad ? 3 : 2
styleView.isHidden = false
styleView.alpha = 1
if interaction.noticeStatus != "bright" {
UIView.animate(withDuration: 0.3, delay: 0.7, options: [], animations: { [weak self] in
self?.styleView.alpha = 0
})
}
}
/// 宿/
/// `configureInteractions`
/// link OC
/// `setActiveInteraction` /
func setActive(_ active: Bool) {
guard isActive != active else { return }
isActive = active
flickerTimer?.invalidate()
flickerTimer = nil
styleView.layer.removeAllAnimations()
applyBorderAndFillColors()
if active {
applyActivePlayNotice()
} else {
styleView.alpha = 1
styleView.isHidden = isStyleHiddenAtRest
}
}
/// 150ms OC `showHoverFlash:`
private func playHoverFlash() {
guard let hoverHex = Self.strictHexOnly(interaction.hoverColorHex) else { return }
hoverFlashView.backgroundColor = UIColor.pdfReaderColor(hex: hoverHex)
hoverFlashView.isHidden = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
self?.hoverFlashView.isHidden = true
}
}
/// / `flickerCount` OC
/// `flashLink``RBCoreMediaLinkPaper.m``isNew==2` `linkBlink`
/// 0 `hidden=YES``linkBlink>0` `linkBlink×2`
/// `hidden=YES``flickerCount` "
/// """
private var isStyleHiddenAtRest: Bool { true }
private func startFlicker(times: Int) {
remainingFlickerToggles = times
flickerTimer?.invalidate()
// OC 0.5sRBCoreMediaLinkPaper.blinkTime
flickerTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
guard let self else {
timer.invalidate()
return
}
self.styleView.isHidden.toggle()
self.remainingFlickerToggles -= 1
if self.remainingFlickerToggles < 1 {
timer.invalidate()
self.flickerTimer = nil
self.styleView.isHidden = self.isStyleHiddenAtRest
}
}
}
/// "" OC
/// `RBCoreToolbarController.linkFlashButton` `RBCoreMediaLinkPaper.flashLink`
/// `flickerCount<=0`
/// OC `linkBlink==0` timer "
/// "
func flash() {
guard !isActive, interaction.flickerCount > 0 else { return }
// OC
styleView.isHidden = true
startFlicker(times: interaction.flickerCount * 2)
}
/// OC `colorFromLinkStrKey:``RBCoreBookDataVariables.m:271-302`
/// `hoverColor`/`playBorderColor` `#RRGGBB`/`#RRGGBBAA`
/// `rgba(...)` `borderColor`/`bgColor` `parseColor:`
///
/// rgba`UIColor.pdfReaderColor(hex:alpha:)` border/fill
///
private static func strictHexOnly(_ hex: String?) -> String? {
guard let hex, hex.hasPrefix("#"), hex.count == 7 || hex.count == 9 else { return nil }
return hex
}
private func loadBackgroundImage() {
guard let path = interaction.backgroundImagePath else { return }
backgroundImageView.image = SDAnimatedImage(contentsOfFile: path) ?? UIImage(contentsOfFile: path)
}
// MARK: -
/// bounds/center
func applyRotationAndLayout() {
let size = bounds.size
// SVG OC
transform = interaction.rotation == 0
? .identity
: CGAffineTransform(rotationAngle: interaction.rotation * .pi / 180)
if isEmbedVideo {
embedVideoBackdropView.frame = CGRect(origin: .zero, size: size)
backgroundImageView.frame = CGRect(origin: .zero, size: size)
// OC viewDidLayoutSubviews = min(, 60pt)
let buttonSide = min(min(size.width, size.height), 60)
embedVideoPlayButton.bounds = CGRect(x: 0, y: 0, width: buttonSide, height: buttonSide)
embedVideoPlayButton.center = CGPoint(x: size.width / 2, y: size.height / 2)
embedPlayerViewController?.view.frame = CGRect(origin: .zero, size: size)
return
}
styleView.frame = CGRect(origin: .zero, size: size)
hoverFlashView.frame = CGRect(origin: .zero, size: size)
backgroundImageView.frame = CGRect(origin: .zero, size: size)
// OC icon transform = identity
iconImageView.transform = interaction.rotation == 0
? .identity
: CGAffineTransform(rotationAngle: -interaction.rotation * .pi / 180)
let side = min(size.width, size.height) * max(interaction.iconRate, 0)
iconImageView.bounds = CGRect(x: 0, y: 0, width: side, height: side)
iconImageView.center = CGPoint(x: size.width / 2, y: size.height / 2)
loadIconIfNeeded(side: side)
}
private var loadedIconSide: CGFloat = 0
private func loadIconIfNeeded(side: CGFloat) {
guard let iconURL = interaction.iconURL, side > 0 else { return }
//
guard abs(side - loadedIconSide) > 0.5 else { return }
loadedIconSide = side
RDPDFReaderSVGIconLoader.shared.load(
url: iconURL,
localPath: interaction.iconLocalPath,
colorHex: interaction.iconColorHex,
size: CGSize(width: side, height: side)
) { [weak iconImageView] image in
iconImageView?.image = image
}
}
@objc private func handleTap() {
playHoverFlash()
onTap?()
}
// MARK: -
/// AABB `angle` OC
/// `RBCoreMediaContentView` AABB
/// `frame` identity transform
/// /
/// `self`
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard isUserInteractionEnabled, !isHidden, alpha > 0.01 else { return nil }
if isEmbedVideo { return super.hitTest(point, with: event) }
guard let superview else { return super.hitTest(point, with: event) }
let pointInSuperview = convert(point, to: superview)
return frame.contains(pointInSuperview) ? self : nil
}
}
@@ -93,6 +93,12 @@ public final class RDPDFReaderKitBottomToolView: UIView {
public var onShowAnnotations: (() -> Void)?
public var onStartDrawing: (() -> Void)?
public var onShowSettings: (() -> Void)?
///
public var onFlashInteractions: (() -> Void)?
/// OC loop `isShowLoopPlayerBtnState:` loop
/// 3.0 `ShowEmbedImage` OC img/loop img
/// false loop
public var onToggleSequentialPlay: (() -> Void)?
private let stackView: UIStackView = {
let view = UIStackView()
@@ -102,6 +108,8 @@ public final class RDPDFReaderKitBottomToolView: UIView {
return view
}()
private let catalogButton = UIButton(type: .system)
private let interactionFlashButton = UIButton(type: .system)
private let loopButton = UIButton(type: .system)
private let annotationsButton = UIButton(type: .system)
private let drawingButton = UIButton(type: .system)
private let settingsButton = UIButton(type: .system)
@@ -115,6 +123,9 @@ public final class RDPDFReaderKitBottomToolView: UIView {
addSubview(separatorLine)
separatorLine.backgroundColor = UIColor(white: 0, alpha: 0.12)
stackView.addArrangedSubview(catalogButton)
// OC
stackView.addArrangedSubview(interactionFlashButton)
stackView.addArrangedSubview(loopButton)
stackView.addArrangedSubview(annotationsButton)
stackView.addArrangedSubview(drawingButton)
stackView.addArrangedSubview(settingsButton)
@@ -128,16 +139,26 @@ public final class RDPDFReaderKitBottomToolView: UIView {
make.height.equalTo(0.5)
}
configure(catalogButton, image: "list.bullet")
configure(interactionFlashButton, image: "hand.tap")
configure(loopButton, image: "repeat")
configure(annotationsButton, image: "note.text")
configure(drawingButton, image: "pencil.tip")
configure(settingsButton, image: "gearshape")
catalogButton.accessibilityIdentifier = "epub.reader.toc"
interactionFlashButton.accessibilityIdentifier = "pdf.reader.interactionFlash"
interactionFlashButton.accessibilityLabel = "点读提示"
interactionFlashButton.isHidden = true
loopButton.accessibilityIdentifier = "pdf.reader.loop"
loopButton.accessibilityLabel = "连续播放"
loopButton.isHidden = true
annotationsButton.accessibilityIdentifier = "pdf.reader.annotations"
annotationsButton.accessibilityLabel = "笔记"
drawingButton.accessibilityIdentifier = "pdf.reader.drawing"
drawingButton.accessibilityLabel = "画笔"
settingsButton.accessibilityIdentifier = "epub.reader.settings"
catalogButton.addTarget(self, action: #selector(catalogAction), for: .touchUpInside)
interactionFlashButton.addTarget(self, action: #selector(interactionFlashAction), for: .touchUpInside)
loopButton.addTarget(self, action: #selector(loopAction), for: .touchUpInside)
annotationsButton.addTarget(self, action: #selector(annotationsAction), for: .touchUpInside)
drawingButton.addTarget(self, action: #selector(drawingAction), for: .touchUpInside)
settingsButton.addTarget(self, action: #selector(settingsAction), for: .touchUpInside)
@@ -145,13 +166,30 @@ public final class RDPDFReaderKitBottomToolView: UIView {
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
/// "" OC
/// `isNew == 2 && multimediaMtime > 0`
public func setInteractionFlashHidden(_ hidden: Bool) {
interactionFlashButton.isHidden = hidden
}
/// loop autoPlay OC
/// `isShowLoopPlayerBtnState:` loop img 3.0
public func setLoopButtonHidden(_ hidden: Bool) {
loopButton.isHidden = hidden
}
/// loop `repeat.circle.fill`
public func setLoopButtonActive(_ active: Bool) {
loopButton.setImage(UIImage(systemName: active ? "repeat.circle.fill" : "repeat"), for: .normal)
}
public func apply(
backgroundColor: UIColor,
tintColor: UIColor,
separatorColor: UIColor
) {
self.backgroundColor = backgroundColor
[catalogButton, annotationsButton, drawingButton, settingsButton].forEach { $0.tintColor = tintColor }
[catalogButton, interactionFlashButton, loopButton, annotationsButton, drawingButton, settingsButton].forEach { $0.tintColor = tintColor }
separatorLine.backgroundColor = separatorColor
}
@@ -162,6 +200,8 @@ public final class RDPDFReaderKitBottomToolView: UIView {
}
@objc private func catalogAction() { onShowTableOfContents?() }
@objc private func interactionFlashAction() { onFlashInteractions?() }
@objc private func loopAction() { onToggleSequentialPlay?() }
@objc private func annotationsAction() { onShowAnnotations?() }
@objc private func drawingAction() { onStartDrawing?() }
@objc private func settingsAction() { onShowSettings?() }
@@ -21,17 +21,110 @@ public struct RDPDFReaderPageDescriptor {
/// 宿使 0...1
/// `nil` 宿SDK 使 OCR
public let textRuns: [RDPDFReaderTextRun]?
public let interactions: [RDPDFReaderPageInteraction]
public init(index: Int, image: UIImage?) {
self.index = index
self.image = image
textRuns = nil
interactions = []
}
public init(index: Int, image: UIImage?, textRuns: [RDPDFReaderTextRun]?) {
self.index = index
self.image = image
self.textRuns = textRuns
interactions = []
}
public init(index: Int, image: UIImage?, textRuns: [RDPDFReaderTextRun]?, interactions: [RDPDFReaderPageInteraction]) {
self.index = index
self.image = image
self.textRuns = textRuns
self.interactions = interactions
}
}
/// SDK
public struct RDPDFReaderPageInteraction: Equatable {
public let identifier: String
public let normalizedRect: CGRect
public let rotation: CGFloat
public let eventType: Int
public let iconName: String?
public let iconURL: URL?
/// 宿 SVG
public let iconLocalPath: String?
public let iconColorHex: String?
public let iconRate: CGFloat
/// 宿SDK
public let borderColorHex: String?
public let fillColorHex: String?
public let activeBorderColorHex: String?
/// nil SDK 使退
public let backgroundImagePath: String?
/// 0~100 OC link_border_opacity
public let borderOpacity: Int
/// 0~100 OC link_fill_opacity
public let fillOpacity: Int
/// /> 0 /
/// 0
public let flickerCount: Int
/// OC `showHoverFlash:` hoverColor150ms
public let hoverColorHex: String?
/// false `isActive` OC playNoticeEnable
public let playNoticeEnable: Bool
/// `"bright"` `"flicker"` 0.7s
/// 0.3s OC `noticeStatus`
public let noticeStatus: String
/// OC `ShowEmbedVideo`宿
/// 线 nil
/// `eventType` `eventType` 0
/// //
public let embedVideoURL: URL?
public init(
identifier: String,
normalizedRect: CGRect,
rotation: CGFloat = 0,
eventType: Int,
iconName: String? = nil,
iconURL: URL? = nil,
iconLocalPath: String? = nil,
iconColorHex: String? = nil,
iconRate: CGFloat = 1,
borderColorHex: String? = nil,
fillColorHex: String? = nil,
activeBorderColorHex: String? = nil,
backgroundImagePath: String? = nil,
borderOpacity: Int = 100,
fillOpacity: Int = 0,
flickerCount: Int = 0,
hoverColorHex: String? = nil,
playNoticeEnable: Bool = true,
noticeStatus: String = "flicker",
embedVideoURL: URL? = nil
) {
self.identifier = identifier
self.normalizedRect = normalizedRect
self.rotation = rotation
self.eventType = eventType
self.iconName = iconName
self.iconURL = iconURL
self.iconLocalPath = iconLocalPath
self.iconColorHex = iconColorHex
self.iconRate = iconRate
self.borderColorHex = borderColorHex
self.fillColorHex = fillColorHex
self.activeBorderColorHex = activeBorderColorHex
self.backgroundImagePath = backgroundImagePath
self.borderOpacity = borderOpacity
self.fillOpacity = fillOpacity
self.flickerCount = flickerCount
self.hoverColorHex = hoverColorHex
self.playNoticeEnable = playNoticeEnable
self.noticeStatus = noticeStatus
self.embedVideoURL = embedVideoURL
}
}
@@ -27,11 +27,13 @@ public protocol RDPDFReaderPageViewDelegate: AnyObject {
didRequestHighlightMenuAction action: RDPDFReaderExistingHighlightMenuAction,
highlight: RDPDFReaderAnnotation
)
func pageView(_ pageView: RDPDFReaderPageView, didTap interaction: RDPDFReaderPageInteraction)
}
public extension RDPDFReaderPageViewDelegate {
func pageView(_ pageView: RDPDFReaderPageView, didChangeSelection selection: RDPDFReaderImageTextSelection?) {}
func pageView(_ pageView: RDPDFReaderPageView, didCopyText text: String) {}
func pageView(_ pageView: RDPDFReaderPageView, didTap interaction: RDPDFReaderPageInteraction) {}
}
/// PDF
@@ -45,7 +47,15 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
public weak var delegate: RDPDFReaderPageViewDelegate?
public var image: UIImage? { didSet { zoomView.image = image } }
public var image: UIImage? {
didSet {
zoomView.image = image
// contentView aspectFit
// //
//
setNeedsLayout()
}
}
/// `configureTextLayer`
public var pageIndex: Int { textLayer.pageIndex }
@@ -58,6 +68,17 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
private let zoomView = RDPDFZoomablePageView()
private let textLayer = RDPDFReaderImageTextLayerView()
private let drawingCanvas = RDPDFReaderDrawingCanvasView()
private let interactionOverlay = UIView()
private var pageInteractions: [RDPDFReaderPageInteraction] = []
private var activeInteractionIdentifier: String?
/// page view `configureInteractions`
/// /
/// `flickerCount > 0`
/// `setActiveInteraction` "
/// link "
/// `flickerTrackedPageIndex` page view
private var flickeredInteractionIdentifiers: Set<String> = []
private var flickerTrackedPageIndex: Int?
private let contentAccessibilityView = UIView()
private let selectionLoupe = RDPDFReaderSelectionLoupeView()
private let pageLoadFailureButton = UIButton(type: .system)
@@ -84,6 +105,7 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
// 退
drawingCanvas.isHidden = false
drawingCanvas.isUserInteractionEnabled = isDrawingMode
interactionOverlay.isUserInteractionEnabled = !isDrawingMode
zoomView.isDrawingMode = isDrawingMode
}
}
@@ -150,6 +172,59 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
updateAccessibilityViewport()
}
public func configureInteractions(_ interactions: [RDPDFReaderPageInteraction]) {
pageInteractions = interactions
// page view ""
if flickerTrackedPageIndex != pageIndex {
flickerTrackedPageIndex = pageIndex
flickeredInteractionIdentifiers.removeAll()
}
interactionOverlay.subviews.forEach { $0.removeFromSuperview() }
for interaction in interactions {
//
// configureInteractions
//
let skipEntranceFlicker = flickeredInteractionIdentifiers.contains(interaction.identifier)
let hotspot = RDPDFInteractionHotspotView(
interaction: interaction,
isActive: interaction.identifier == activeInteractionIdentifier,
skipEntranceFlicker: skipEntranceFlicker
)
flickeredInteractionIdentifiers.insert(interaction.identifier)
hotspot.accessibilityIdentifier = "pdf.interaction.\(interaction.identifier)"
hotspot.onTap = { [weak self] in
guard let self, !self.isDrawingMode else { return }
self.delegate?.pageView(self, didTap: interaction)
}
interactionOverlay.addSubview(hotspot)
}
// contentView
// contentView
layoutInteractionButtons()
setNeedsLayout()
}
/// ""
public func flashInteractions() {
interactionOverlay.subviews.forEach { ($0 as? RDPDFInteractionHotspotView)?.flash() }
}
/// 宿/
/// `RDPDFInteractionHotspotView`
/// `init` link
public func setActiveInteraction(identifier: String?) {
guard activeInteractionIdentifier != identifier else { return }
let previousIdentifier = activeInteractionIdentifier
activeInteractionIdentifier = identifier
for case let hotspot as RDPDFInteractionHotspotView in interactionOverlay.subviews {
if hotspot.interactionIdentifier == previousIdentifier {
hotspot.setActive(false)
} else if hotspot.interactionIdentifier == identifier {
hotspot.setActive(true)
}
}
}
/// Shows a non-persistent highlight while the speech engine reads text.
public func setSpeechHighlightRects(_ rects: [CGRect]) {
textLayer.speechHighlightRects = rects
@@ -237,6 +312,10 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
}
zoomView.viewportChanged = { [weak self] _, _ in self?.updateAccessibilityViewport() }
addSubview(zoomView)
interactionOverlay.frame = zoomView.contentView.bounds
interactionOverlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
interactionOverlay.backgroundColor = .clear
zoomView.contentView.addSubview(interactionOverlay)
textLayer.frame = zoomView.contentView.bounds
textLayer.autoresizingMask = [.flexibleWidth, .flexibleHeight]
@@ -258,6 +337,7 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
drawingCanvas.isHidden = true
drawingCanvas.isUserInteractionEnabled = false
zoomView.contentView.addSubview(drawingCanvas)
zoomView.contentView.bringSubviewToFront(interactionOverlay)
contentAccessibilityView.frame = bounds
contentAccessibilityView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
@@ -296,12 +376,32 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
zoomView.layoutIfNeeded()
textLayer.frame = zoomView.contentView.bounds
drawingCanvas.frame = zoomView.contentView.bounds
interactionOverlay.frame = zoomView.contentView.bounds
layoutInteractionButtons()
pageLoadFailureButton.sizeToFit()
pageLoadFailureButton.bounds.size.width += 32
pageLoadFailureButton.bounds.size.height += 24
pageLoadFailureButton.center = CGPoint(x: bounds.midX, y: bounds.midY)
}
private func layoutInteractionButtons() {
let bounds = interactionOverlay.bounds
for (index, interaction) in pageInteractions.enumerated() where index < interactionOverlay.subviews.count {
let hotspot = interactionOverlay.subviews[index]
// transform frame frame
hotspot.transform = .identity
hotspot.bounds.size = CGSize(
width: bounds.width * interaction.normalizedRect.width,
height: bounds.height * interaction.normalizedRect.height
)
hotspot.center = CGPoint(
x: bounds.width * interaction.normalizedRect.midX,
y: bounds.height * interaction.normalizedRect.midY
)
(hotspot as? RDPDFInteractionHotspotView)?.applyRotationAndLayout()
}
}
// MARK: -
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
@@ -495,3 +595,41 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
)
}
}
extension UIColor {
/// `#RRGGBB` / `#RRGGBBAA` / `rgba(r,g,b,a)`
/// OC `parseColor:outColor:outOpacity:`8 hex rgba alpha
/// `alpha` 6 hex alpha 使 `alpha`
/// border/fill alpha opacity
static func pdfReaderColor(hex: String, alpha: CGFloat = 1) -> UIColor {
var value = hex.trimmingCharacters(in: .whitespacesAndNewlines)
// `#` " hex "`RDPDFLoadBookUseCase.parseColor`
// border/fill 6 hex `#`opacity borderOpacity/
// fillOpacity bug `#` hex `#`
// border/fill /
//
if value.hasPrefix("#") { value.removeFirst() }
if value.count >= 6, let rgb = UInt32(String(value.prefix(6)), radix: 16) {
var resolvedAlpha = alpha
if value.count >= 8, let alphaVal = UInt8(String(value.dropFirst(6).prefix(2)), radix: 16) {
resolvedAlpha = CGFloat(alphaVal) / 255
}
return UIColor(
red: CGFloat((rgb >> 16) & 0xFF) / 255,
green: CGFloat((rgb >> 8) & 0xFF) / 255,
blue: CGFloat(rgb & 0xFF) / 255,
alpha: resolvedAlpha
)
}
if value.hasPrefix("rgba("), value.hasSuffix(")") {
let inner = String(value.dropFirst(5).dropLast())
let parts = inner.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }
if parts.count >= 4,
let r = Double(parts[0]), let g = Double(parts[1]), let b = Double(parts[2]),
let a = Double(parts[3]) {
return UIColor(red: r / 255, green: g / 255, blue: b / 255, alpha: a)
}
}
return systemBlue.withAlphaComponent(alpha)
}
}
@@ -32,12 +32,19 @@ public enum RDPDFReaderAnnotationPersistenceError: LocalizedError {
}
/// SDK 宿
public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting {
public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting, RDPDFReaderPersistence {
private struct AnnotationDocument: Codable {
let version: Int
let annotations: [RDPDFReaderAnnotation]
}
/// SDK 宿
///
private struct ReaderStateDocument: Codable {
var readingPages: [String: Int] = [:]
var bookmarks: [String: [Int]] = [:]
}
private static let annotationDocumentVersion = 1
///
///
@@ -46,12 +53,50 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting
private let drawingsURL: URL
private let highlightsURL: URL
private let annotationsURL: URL
private let readerStateURL: URL
public init(rootURL: URL) {
self.rootURL = rootURL
drawingsURL = rootURL.appendingPathComponent("drawings", isDirectory: true)
highlightsURL = rootURL.appendingPathComponent("highlights.json")
annotationsURL = rootURL.appendingPathComponent("annotations.json")
readerStateURL = rootURL.appendingPathComponent("reader-state.json")
}
// MARK: - RDPDFReaderPersistence
public func restoreReadingPage(for bookIdentifier: String) -> Int? {
Self.annotationPersistenceLock.lock()
defer { Self.annotationPersistenceLock.unlock() }
return loadReaderStateLocked().readingPages[bookIdentifier]
}
public func saveReadingPage(_ pageIndex: Int, for bookIdentifier: String) {
guard pageIndex >= 0 else { return }
Self.annotationPersistenceLock.lock()
defer { Self.annotationPersistenceLock.unlock() }
var state = loadReaderStateLocked()
state.readingPages[bookIdentifier] = pageIndex
saveReaderStateLocked(state)
}
public func loadBookmarks(for bookIdentifier: String) -> [RDPDFReaderBookmark] {
Self.annotationPersistenceLock.lock()
defer { Self.annotationPersistenceLock.unlock() }
return (loadReaderStateLocked().bookmarks[bookIdentifier] ?? [])
.sorted()
.map { RDPDFReaderBookmark(pageIndex: $0, title: "\($0 + 1)") }
}
public func setBookmark(_ isBookmarked: Bool, pageIndex: Int, for bookIdentifier: String) {
guard pageIndex >= 0 else { return }
Self.annotationPersistenceLock.lock()
defer { Self.annotationPersistenceLock.unlock() }
var state = loadReaderStateLocked()
var pages = Set(state.bookmarks[bookIdentifier] ?? [])
if isBookmarked { pages.insert(pageIndex) } else { pages.remove(pageIndex) }
state.bookmarks[bookIdentifier] = pages.sorted()
saveReaderStateLocked(state)
}
public func drawingPaths(pageNo: Int) -> [RDPDFReaderDrawingPath] {
@@ -302,6 +347,19 @@ public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting
}
}
private func loadReaderStateLocked() -> ReaderStateDocument {
guard let data = try? Data(contentsOf: readerStateURL),
let document = try? JSONDecoder().decode(ReaderStateDocument.self, from: data) else {
return ReaderStateDocument()
}
return document
}
private func saveReaderStateLocked(_ state: ReaderStateDocument) {
guard let data = try? JSONEncoder().encode(state) else { return }
write(data, to: readerStateURL)
}
private func saveAnnotationsLocked(_ annotations: [RDPDFReaderAnnotation]) throws {
let document = AnnotationDocument(version: Self.annotationDocumentVersion, annotations: annotations)
let data: Data
@@ -0,0 +1,101 @@
import UIKit
import SDWebImageSVGCoder
/// OC SVGIconRenderer Swift 宿
/// 退 + alpha mask
/// `SDImageSVGCoder` nil OC `decodeQueue`
///
/// 1 AGENTS.md
/// OC 3
///
/// `public`宿/
/// OC
public final class RDPDFReaderSVGIconLoader {
public static let shared = RDPDFReaderSVGIconLoader()
private let cache = NSCache<NSString, UIImage>()
private let decodeQueue = DispatchQueue(label: "com.readoor.pdf.svgicon.decode")
private init() {
// OC `RBCoreSVGIconRenderer`
// `RBCoreSVGIconRenderer.m:36-37`
cache.totalCostLimit = 10 * 1024 * 1024
cache.countLimit = 200
}
/// - Parameter localPath: 宿
public func load(url: URL, localPath: String? = nil, colorHex: String?, size: CGSize, completion: @escaping (UIImage?) -> Void) {
let key = "\(url.absoluteString)|\(colorHex ?? "")|\(Int(size.width))" as NSString
if let image = cache.object(forKey: key) { completion(image); return }
if let localPath {
// 线线 I/O
decodeQueue.async { [weak self] in
guard let self else { DispatchQueue.main.async { completion(nil) }; return }
if let data = FileManager.default.contents(atPath: localPath) {
self.decodeOnQueue(data: data, key: key, colorHex: colorHex, size: size, completion: completion)
} else {
DispatchQueue.main.async {
self.fetchAndDecode(url: url, key: key, colorHex: colorHex, size: size, retriesLeft: 1, completion: completion)
}
}
}
return
}
fetchAndDecode(url: url, key: key, colorHex: colorHex, size: size, retriesLeft: 1, completion: completion)
}
private func fetchAndDecode(url: URL, key: NSString, colorHex: String?, size: CGSize, retriesLeft: Int, completion: @escaping (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let self else { DispatchQueue.main.async { completion(nil) }; return }
guard let data else {
guard retriesLeft > 0 else { DispatchQueue.main.async { completion(nil) }; return }
// 2s OC 1
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.fetchAndDecode(url: url, key: key, colorHex: colorHex, size: size, retriesLeft: retriesLeft - 1, completion: completion)
}
return
}
self.decode(data: data, key: key, colorHex: colorHex, size: size, completion: completion)
}.resume()
}
private func decode(data: Data, key: NSString, colorHex: String?, size: CGSize, completion: @escaping (UIImage?) -> Void) {
decodeQueue.async { [weak self] in
self?.decodeOnQueue(data: data, key: key, colorHex: colorHex, size: size, completion: completion)
}
}
/// `decodeQueue` `SDImageSVGCoder` nil
private func decodeOnQueue(data: Data, key: NSString, colorHex: String?, size: CGSize, completion: @escaping (UIImage?) -> Void) {
let options: [SDImageCoderOption: Any] = [
.decodeThumbnailPixelSize: NSValue(cgSize: size),
.decodePreserveAspectRatio: true
]
let decoded = SDImageSVGCoder.shared.decodedImage(with: data, options: options)
let image = decoded.flatMap { tinted($0, hex: colorHex) }
if let image {
// cost × × 4 × scale² totalCostLimit
let cost = Int(image.size.width * image.size.height * 4 * image.scale * image.scale)
cache.setObject(image, forKey: key, cost: max(cost, 1))
}
DispatchQueue.main.async { completion(image) }
}
private func tinted(_ image: UIImage, hex: String?) -> UIImage {
guard let hex, let color = UIColor.pdfReaderColorOrNil(hex: hex) else { return image }
let renderer = UIGraphicsImageRenderer(size: image.size)
return renderer.image { _ in
color.setFill()
image.draw(in: CGRect(origin: .zero, size: image.size), blendMode: .normal, alpha: 1)
UIRectFillUsingBlendMode(CGRect(origin: .zero, size: image.size), .sourceIn)
}
}
}
private extension UIColor {
static func pdfReaderColorOrNil(hex: String) -> UIColor? {
var value = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex
guard value.count >= 6, let number = UInt32(String(value.prefix(6)), radix: 16) else { return nil }
return UIColor(red: CGFloat((number >> 16) & 255) / 255, green: CGFloat((number >> 8) & 255) / 255, blue: CGFloat(number & 255) / 255, alpha: 1)
}
}
@@ -0,0 +1,132 @@
import UIKit
/// PDF
///
/// EPUB `RDEPUBReaderTrialPolicy` """"
/// PDF pageMap
public struct RDPDFReaderTrialPolicy: Equatable {
/// 0..<readablePageCount
/// nil <= 0
public var readablePageCount: Int?
public init(readablePageCount: Int? = nil) {
self.readablePageCount = readablePageCount
}
}
///
///
/// EPUB `book.totalPages`
/// `RDPDFReaderView` +1
extension RDPDFReaderViewController {
/// 访 delegate
var trialWallView: UIView? {
if let cached = cachedTrialWallView {
return cached
}
guard configuration.trialPolicy != nil else { return nil }
let view = delegate?.pdfReaderTrialWallView(self)
cachedTrialWallView = view
return view
}
/// 宿
var isTrialWallEnabled: Bool {
configuration.trialPolicy != nil && trialWallView != nil
}
///
var readableContentPageCount: Int {
guard let policy = configuration.trialPolicy,
let readablePageCount = policy.readablePageCount,
readablePageCount > 0 else {
return book.totalPages
}
return min(readablePageCount, book.totalPages)
}
/// 0-based nil
var trialWallPageIndex: Int? {
guard isTrialWallEnabled else { return nil }
//
return readableContentPageCount
}
/// RDPDFReaderView 使 + 1
var effectivePageCount: Int {
guard isTrialWallEnabled else { return book.totalPages }
return readableContentPageCount + 1
}
///
func isTrialWallPage(_ pageIndex: Int) -> Bool {
trialWallPageIndex == pageIndex
}
///
func makeTrialWallPageView(reusableView: UIView?) -> UIView {
let container = (reusableView as? RDPDFTrialWallContainerView) ?? RDPDFTrialWallContainerView()
container.setWallView(trialWallView)
return container
}
}
/// 宿
///
/// 宿 View View
/// ""
final class RDPDFTrialWallContainerView: UIView, UIGestureRecognizerDelegate {
private weak var wallView: UIView?
/// `RDPDFReaderPageView.readerContentTapHandler`
/// UIView
/// `RDPDFReaderPageInteractable`
///
var contentTapHandler: ((CGPoint) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tap.delegate = self
addGestureRecognizer(tap)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func setWallView(_ view: UIView?) {
// 使 View
if wallView === view, view?.superview === self {
return
}
wallView?.removeFromSuperview()
guard let view else { return }
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: leadingAnchor),
view.trailingAnchor.constraint(equalTo: trailingAnchor),
view.topAnchor.constraint(equalTo: topAnchor),
view.bottomAnchor.constraint(equalTo: bottomAnchor)
])
wallView = view
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
contentTapHandler?(gesture.location(in: self))
}
/// /
/// 沿 touch.view self `UIControl`
///
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
var view = touch.view
while let current = view, current !== self {
if current is UIControl { return false }
view = current.superview
}
return true
}
}
@@ -30,11 +30,15 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public var ocrMemoryCacheRadius: Int?
/// 宿 Provider
public var pageImageTransform: ((UIImage, RDPDFReaderThemeOption) -> UIImage)?
/// nil 宿 delegate
public var trialPolicy: RDPDFReaderTrialPolicy?
public init() {}
}
public weak var delegate: RDPDFReaderViewControllerDelegate?
/// SDK //宿
public var interactionHandler: ((RDPDFReaderPageInteraction, Int) -> Void)?
public let pageProvider: RDPDFReaderPageProvider
public weak var persistence: RDPDFReaderPersistence?
public let annotationPersistence: RDPDFReaderAnnotationPersisting?
@@ -42,10 +46,63 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public var currentPageIndex: Int { max(0, readerView.currentPage) }
/// 宿 RDPDFReaderTrial.swift
var cachedTrialWallView: UIView?
/// SDK 宿
public func setActiveInteraction(identifier: String?) {
visiblePageViews().forEach { $0.setActiveInteraction(identifier: identifier) }
}
/// ""宿 true
/// OC `linkFlashButton`
public var showsInteractionFlashButton = false {
didSet {
guard showsInteractionFlashButton != oldValue else { return }
bottomToolbar?.setInteractionFlashHidden(!showsInteractionFlashButton)
}
}
///
public func flashInteractions() {
visiblePageViews().forEach { $0.flashInteractions() }
}
/// 宿
public var onToggleSequentialPlay: (() -> Void)?
/// loop autoPlay 宿 true OC
/// `isShowLoopPlayerBtnState:` loop 3.0 `ShowEmbedImage`
/// img `RDPDFReaderKitBottomToolView`
public var showsLoopButton = false {
didSet {
guard showsLoopButton != oldValue else { return }
bottomToolbar?.setLoopButtonHidden(!showsLoopButton)
}
}
/// loop
public var isLoopActive = false {
didSet {
guard isLoopActive != oldValue else { return }
bottomToolbar?.setLoopButtonActive(isLoopActive)
}
}
/// 宿使
public func reloadPageContent(at pageIndex: Int) {
guard pageIndex >= 0, pageIndex < book.totalPages else { return }
clearPageDescriptorRequest(for: pageIndex)
pageDescriptors.removeValue(forKey: pageIndex)
pageLoadFailedPages.remove(pageIndex)
pageDescriptorRequestAttempts[pageIndex] = 0
refreshPageViews(at: pageIndex)
}
private let readerView = RDPDFReaderView()
private let recognizer: RDPDFReaderImageTextRecognizer
private let ocrDiskCache: RDPDFReaderTextRunDiskCache?
private var book: RDPDFReaderBookDescriptor
private(set) var book: RDPDFReaderBookDescriptor
private var currentTheme: RDPDFReaderThemeOption
private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:]
/// UIPageViewController UICollectionView
@@ -165,7 +222,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
view.addSubview(readerView)
readerView.reloadData()
applyDisplayTypeForCurrentInterface()
if let page = persistence?.restoreReadingPage(for: book.identifier), page >= 0, page < book.totalPages {
if let page = persistence?.restoreReadingPage(for: book.identifier), page >= 0, page < readableContentPageCount {
readerView.transitionToPage(pageNum: page, animated: false)
}
bookmarks = Set(persistence?.loadBookmarks(for: book.identifier).map(\.pageIndex) ?? [])
@@ -218,7 +275,8 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
}
public func goToPage(_ pageIndex: Int, animated: Bool = false) {
guard pageIndex >= 0, pageIndex < book.totalPages else { return }
// /
guard pageIndex >= 0, pageIndex < readableContentPageCount else { return }
readerView.transitionToPage(pageNum: pageIndex, animated: animated)
}
@@ -269,11 +327,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public func showSpeechHighlight(pageIndex: Int, normalizedRects: [CGRect], animated: Bool = true) {
guard pageIndex >= 0, pageIndex < book.totalPages else { return }
speechHighlight = normalizedRects.isEmpty ? nil : (pageIndex, normalizedRects)
// `willSpeakRangeOfSpeechString`
// 仿
if currentPageIndex != pageIndex {
goToPage(pageIndex, animated: animated)
}
refreshPageViews(at: pageIndex)
}
@@ -295,9 +349,12 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
updateDrawingInteractionState()
}
public func pageCountOfReaderView(readerView: RDPDFReaderView) -> Int { book.totalPages }
public func pageCountOfReaderView(readerView: RDPDFReaderView) -> Int { effectivePageCount }
public func pageContentView(readerView: RDPDFReaderView, pageNum: Int) -> UIView {
if isTrialWallPage(pageNum) {
return makeTrialWallPageView(reusableView: nil)
}
let page = RDPDFReaderPageView()
page.delegate = self
// ReaderView tag
@@ -326,6 +383,12 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
toolbar.onShowAnnotations = { [weak self] in self?.showAnnotations() }
toolbar.onStartDrawing = { [weak self] in self?.setDrawingMode(true) }
toolbar.onShowSettings = { [weak self] in self?.showSettings() }
toolbar.onFlashInteractions = { [weak self] in self?.flashInteractions() }
toolbar.onToggleSequentialPlay = { [weak self] in self?.onToggleSequentialPlay?() }
// /
toolbar.setInteractionFlashHidden(!showsInteractionFlashButton)
toolbar.setLoopButtonHidden(!showsLoopButton)
toolbar.setLoopButtonActive(isLoopActive)
bottomToolbar = toolbar
applyChromeTheme()
return toolbar
@@ -333,6 +396,14 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public func pageNum(readerView: RDPDFReaderView, pageNum: Int) {
guard pageNum >= 0 else { return }
if isTrialWallPage(pageNum) {
// OCR
title = book.title
topToolbar?.setTitle(book.title)
topToolbar?.setBookmarkSelected(false)
delegate?.pdfReaderDidReachTrialWall(self)
return
}
// OCR
cancelOutstandingOCRRequests()
//
@@ -357,7 +428,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
around: pageNum,
radius: configuration.pageDescriptorCacheRadius
)
title = "PDF · \(pageNum + 1) / \(book.totalPages)"
title = "PDF · \(pageNum + 1) / \(readableContentPageCount)"
topToolbar?.setTitle(title ?? book.title)
topToolbar?.setBookmarkSelected(bookmarks.contains(pageNum))
persistence?.saveReadingPage(pageNum, for: book.identifier)
@@ -438,6 +509,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
let runs = descriptor?.textRuns ?? ocrRuns[index] ?? []
let source: RDPDFReaderAnnotationSource = descriptor?.textRuns != nil ? .text : (configuration.enablesOCR ? .ocr : configuration.missingTextSource)
page.configureTextLayer(pageIndex: index, textRuns: runs, textSource: source, annotations: annotations(for: index))
page.configureInteractions(descriptor?.interactions ?? [])
page.setSpeechHighlightRects(speechHighlight?.pageIndex == index ? speechHighlight?.rects ?? [] : [])
page.isDrawingSessionActive = isDrawingMode
page.isDrawingMode = isDrawingMode && currentDrawingTool != nil
@@ -650,13 +722,15 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
return
}
let outline = (pageProvider as? RDPDFReaderOutlineProviding)?.readerOutlineItems()
?? (0..<book.totalPages).map { .init(title: "\($0 + 1)", pageIndex: $0) }
?? (0..<readableContentPageCount).map { .init(title: "\($0 + 1)", pageIndex: $0) }
presentNavigation(outline: outline)
}
private func presentNavigation(outline: [RDPDFReaderOutlineItem]) {
let marks = bookmarks.sorted().map { RDPDFReaderBookmark(pageIndex: $0, title: "\($0 + 1)") }
let panel = RDPDFReaderNavigationPanelViewController(outlineItems: outline, bookmarks: marks, totalPages: book.totalPages, thumbnailProvider: { [weak self] index, size, completion in
//
let readableOutline = outline.filter { $0.pageIndex < readableContentPageCount }
let panel = RDPDFReaderNavigationPanelViewController(outlineItems: readableOutline, bookmarks: marks, totalPages: readableContentPageCount, thumbnailProvider: { [weak self] index, size, completion in
guard let self else {
DispatchQueue.main.async { completion(nil) }
return
@@ -881,6 +955,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public func pageView(_ pageView: RDPDFReaderPageView, didCopyText text: String) {
delegate?.pdfReaderViewController(self, didCopyText: text)
}
public func pageView(_ pageView: RDPDFReaderPageView, didTap interaction: RDPDFReaderPageInteraction) {
interactionHandler?(interaction, pageView.pageIndex)
}
public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlight selection: RDPDFReaderImageTextSelection, color: String) { add(selection, page: pageView.pageIndex, color: color, note: nil) }
public func pageView(_ pageView: RDPDFReaderPageView, didRequestAnnotation selection: RDPDFReaderImageTextSelection) { presentEditor(selection: selection, page: pageView.pageIndex) }
public func pageView(_ pageView: RDPDFReaderPageView, didOpenAnnotation annotation: RDPDFReaderAnnotation) { presentEditor(annotation: annotation) }
@@ -920,6 +997,14 @@ public protocol RDPDFReaderViewControllerDelegate: AnyObject {
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error)
/// 宿
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didCopyText text: String)
/// configuration.trialPolicy nil
/// nil nil
/// / 宿
func pdfReaderTrialWallView(_ controller: RDPDFReaderViewController) -> UIView?
/// 宿 /
func pdfReaderDidReachTrialWall(_ controller: RDPDFReaderViewController)
}
public extension RDPDFReaderViewControllerDelegate {
@@ -927,4 +1012,6 @@ public extension RDPDFReaderViewControllerDelegate {
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didChangePage pageIndex: Int) {}
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error) {}
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didCopyText text: String) {}
func pdfReaderTrialWallView(_ controller: RDPDFReaderViewController) -> UIView? { nil }
func pdfReaderDidReachTrialWall(_ controller: RDPDFReaderViewController) {}
}