diff --git a/Sources/RDPDFReaderView/RDPDFReaderView.podspec b/Sources/RDPDFReaderView/RDPDFReaderView.podspec index e8dc663..c35ca40 100644 --- a/Sources/RDPDFReaderView/RDPDFReaderView.podspec +++ b/Sources/RDPDFReaderView/RDPDFReaderView.podspec @@ -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| diff --git a/Sources/RDPDFReaderView/README.md b/Sources/RDPDFReaderView/README.md index 0a7599e..2e0e1f2 100644 --- a/Sources/RDPDFReaderView/README.md +++ b/Sources/RDPDFReaderView/README.md @@ -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:)` 更新。 diff --git a/Sources/RDPDFReaderView/ReaderView/RDPDFReaderView+Paging.swift b/Sources/RDPDFReaderView/ReaderView/RDPDFReaderView+Paging.swift index 077d032..ef488a6 100644 --- a/Sources/RDPDFReaderView/ReaderView/RDPDFReaderView+Paging.swift +++ b/Sources/RDPDFReaderView/ReaderView/RDPDFReaderView+Paging.swift @@ -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,避免横屏左右页出现两套缩放坐标。 diff --git a/Sources/RDPDFReaderView/Sources/RDPDFInteractionHotspotView.swift b/Sources/RDPDFReaderView/Sources/RDPDFInteractionHotspotView.swift new file mode 100644 index 0000000..5c056ba --- /dev/null +++ b/Sources/RDPDFReaderView/Sources/RDPDFInteractionHotspotView.swift @@ -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 + } +} diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderChromeViews.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderChromeViews.swift index 8a2d190..f5fdbec 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFReaderChromeViews.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderChromeViews.swift @@ -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?() } diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderContracts.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderContracts.swift index 25d5a21..fc1add0 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFReaderContracts.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderContracts.swift @@ -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 } } diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderPageView.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderPageView.swift index 83e558d..80c409b 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFReaderPageView.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderPageView.swift @@ -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 = [] + 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) + } +} diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderPersistenceStore.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderPersistenceStore.swift index f79e3f6..952de57 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFReaderPersistenceStore.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderPersistenceStore.swift @@ -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 diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderSVGIconLoader.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderSVGIconLoader.swift new file mode 100644 index 0000000..74ef493 --- /dev/null +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderSVGIconLoader.swift @@ -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() + 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) + } +} diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderTrial.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderTrial.swift new file mode 100644 index 0000000..6612f8e --- /dev/null +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderTrial.swift @@ -0,0 +1,132 @@ +import UIKit + +/// PDF 试读策略。 +/// +/// 与 EPUB 的 `RDEPUBReaderTrialPolicy` 对齐,只是限制单位从"章节"换成"页": +/// PDF 是固定版式,页码本身就是稳定的切分单位,不需要 pageMap 换算。 +public struct RDPDFReaderTrialPolicy: Equatable { + + /// 可读页数(可读页为 0.. 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 + } +} diff --git a/Sources/RDPDFReaderView/Sources/RDPDFReaderViewController.swift b/Sources/RDPDFReaderView/Sources/RDPDFReaderViewController.swift index 41b9cdd..480295f 100644 --- a/Sources/RDPDFReaderView/Sources/RDPDFReaderViewController.swift +++ b/Sources/RDPDFReaderView/Sources/RDPDFReaderViewController.swift @@ -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.. 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) {} }