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:
@@ -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|
|
||||
|
||||
@@ -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.5s(RBCoreMediaLinkPaper.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:` 的 hoverColor),150ms 后自动移除。
|
||||
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)
|
||||
}
|
||||
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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user