374 lines
18 KiB
Swift
374 lines
18 KiB
Swift
import UIKit
|
||
import PDFKit
|
||
import CryptoKit
|
||
import RDPDFReaderView
|
||
import RDAIReaderView
|
||
import RDSpeechReaderView
|
||
|
||
/// Demo 仅选择文字来源;正式宿主通常直接提供 PDF 解析结果或不提供以启用 OCR。
|
||
enum PDFDemoImageTextSource: String {
|
||
case hostFixture = "host-fixture"
|
||
case vision
|
||
case none
|
||
}
|
||
|
||
/// 模拟“宿主只能提供页面图片”的适配层,用于验证自定义加密 PDF 接入方式。
|
||
private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOutlineProviding, RDPDFReaderAsyncOutlineProviding {
|
||
private let document: PDFDocument
|
||
private let identifier: String
|
||
private let title: String
|
||
private let textSource: PDFDemoImageTextSource
|
||
private let renderQueue = DispatchQueue(label: "com.readviewsdk.demo-pdf-render", qos: .userInitiated)
|
||
private let images = NSCache<NSNumber, UIImage>()
|
||
private var outlineItemsCache: [RDPDFReaderOutlineItem]?
|
||
|
||
init(document: PDFDocument, identifier: String, title: String, textSource: PDFDemoImageTextSource) {
|
||
self.document = document
|
||
self.identifier = identifier
|
||
self.title = title
|
||
self.textSource = textSource
|
||
images.countLimit = 6
|
||
images.totalCostLimit = 96 * 1024 * 1024
|
||
}
|
||
|
||
func readerBookDescriptor() -> RDPDFReaderBookDescriptor {
|
||
.init(identifier: identifier, title: title, totalPages: document.pageCount)
|
||
}
|
||
|
||
func readerPage(at index: Int, completion: @escaping (RDPDFReaderPageDescriptor) -> Void) {
|
||
let textRuns: [RDPDFReaderTextRun]?
|
||
switch textSource {
|
||
case .hostFixture: textRuns = fixtureTextRuns(for: index)
|
||
case .vision, .none: textRuns = nil
|
||
}
|
||
renderQueue.async { [weak self] in
|
||
guard let self else { return }
|
||
let image = self.image(at: index)
|
||
DispatchQueue.main.async { completion(.init(index: index, image: image, textRuns: textRuns)) }
|
||
}
|
||
}
|
||
|
||
func readerThumbnail(at index: Int, targetSize: CGSize, completion: @escaping (UIImage?) -> Void) {
|
||
renderQueue.async { [weak self] in
|
||
guard let self, let page = self.document.page(at: index) else {
|
||
DispatchQueue.main.async { completion(nil) }
|
||
return
|
||
}
|
||
let box = page.bounds(for: .mediaBox)
|
||
let scale = min(targetSize.width / box.width, targetSize.height / box.height) * UIScreen.main.scale
|
||
let size = CGSize(width: box.width * scale, height: box.height * scale)
|
||
let image = self.render(page, size: size, scale: scale)
|
||
DispatchQueue.main.async { completion(image) }
|
||
}
|
||
}
|
||
|
||
func readerOutlineItems() -> [RDPDFReaderOutlineItem] {
|
||
renderQueue.sync { outlineItemsLocked() }
|
||
}
|
||
|
||
func readerOutlineItems(completion: @escaping ([RDPDFReaderOutlineItem]) -> Void) {
|
||
renderQueue.async { [weak self] in
|
||
guard let self else { return }
|
||
let items = self.outlineItemsLocked()
|
||
DispatchQueue.main.async { completion(items) }
|
||
}
|
||
}
|
||
|
||
private func outlineItemsLocked() -> [RDPDFReaderOutlineItem] {
|
||
if let outlineItemsCache { return outlineItemsCache }
|
||
guard let root = document.outlineRoot else {
|
||
let items = pageOutlineItems()
|
||
outlineItemsCache = items
|
||
return items
|
||
}
|
||
var items: [RDPDFReaderOutlineItem] = []
|
||
func append(_ outline: PDFOutline, level: Int) {
|
||
for index in 0..<outline.numberOfChildren {
|
||
guard let child = outline.child(at: index) else { continue }
|
||
if let page = child.destination?.page {
|
||
let pageIndex = document.index(for: page)
|
||
items.append(.init(title: child.label ?? "第 \(pageIndex + 1) 页", pageIndex: pageIndex, level: level))
|
||
}
|
||
append(child, level: level + 1)
|
||
}
|
||
}
|
||
append(root, level: 0)
|
||
let resolved = items.isEmpty ? pageOutlineItems() : items
|
||
outlineItemsCache = resolved
|
||
return resolved
|
||
}
|
||
|
||
func transformed(_ image: UIImage, for theme: RDPDFReaderThemeOption) -> UIImage {
|
||
// 图片缓存属于 PDFKit 适配层;SDK 只声明当前主题,不拥有原图或缓存策略。
|
||
guard theme.identifier != 0 else { return image }
|
||
if theme.identifier == 5, let ciImage = CIImage(image: image), let filter = CIFilter(name: "CIColorInvert") {
|
||
filter.setValue(ciImage, forKey: kCIInputImageKey)
|
||
if let output = filter.outputImage, let cgImage = CIContext().createCGImage(output, from: output.extent) {
|
||
return UIImage(cgImage: cgImage, scale: image.scale, orientation: image.imageOrientation)
|
||
}
|
||
}
|
||
let renderer = UIGraphicsImageRenderer(size: image.size)
|
||
return renderer.image { _ in
|
||
theme.contentBackgroundColor.setFill()
|
||
UIBezierPath(rect: CGRect(origin: .zero, size: image.size)).fill()
|
||
image.draw(in: CGRect(origin: .zero, size: image.size), blendMode: .multiply, alpha: 1)
|
||
}
|
||
}
|
||
|
||
private func image(at index: Int) -> UIImage? {
|
||
if let image = images.object(forKey: NSNumber(value: index)) { return image }
|
||
guard let page = document.page(at: index) else { return nil }
|
||
let box = page.bounds(for: .mediaBox)
|
||
let scale = max(2, UIScreen.main.scale)
|
||
let image = render(page, size: CGSize(width: box.width * scale, height: box.height * scale), scale: scale)
|
||
let cost = image.cgImage.map { $0.bytesPerRow * $0.height }
|
||
?? Int(image.size.width * image.size.height * image.scale * image.scale * 4)
|
||
images.setObject(image, forKey: NSNumber(value: index), cost: cost)
|
||
return image
|
||
}
|
||
|
||
private func render(_ page: PDFPage, size: CGSize, scale: CGFloat) -> UIImage {
|
||
UIGraphicsImageRenderer(size: size).image { context in
|
||
UIColor.white.setFill()
|
||
context.fill(CGRect(origin: .zero, size: size))
|
||
context.cgContext.translateBy(x: 0, y: size.height)
|
||
context.cgContext.scaleBy(x: scale, y: -scale)
|
||
page.draw(with: .mediaBox, to: context.cgContext)
|
||
}
|
||
}
|
||
|
||
private func fixtureTextRuns(for page: Int) -> [RDPDFReaderTextRun] {
|
||
[
|
||
.init(text: page == 0 ? "PDF 图像文本选择测试" : "第 \(page + 1) 页图像文本", normalizedRect: CGRect(x: 0.12, y: 0.14, width: 0.76, height: 0.14), readingOrder: 0),
|
||
.init(text: "可复制、高亮与注释", normalizedRect: CGRect(x: 0.12, y: 0.30, width: 0.70, height: 0.08), readingOrder: 1)
|
||
]
|
||
}
|
||
|
||
private func pageOutlineItems() -> [RDPDFReaderOutlineItem] {
|
||
(0..<document.pageCount).map { .init(title: "第 \($0 + 1) 页", pageIndex: $0) }
|
||
}
|
||
}
|
||
|
||
/// 成品阅读器的 Demo 宿主:存储位置、返回导航和自动化状态是唯一的应用职责。
|
||
final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewControllerDelegate {
|
||
private static let annotationStorageFolderName = "RDPDFImageReaderAnnotations"
|
||
private let reader: RDPDFReaderViewController
|
||
private lazy var speechSession: RDPDFSpeechSession = {
|
||
let session = reader.makeSpeechSession()
|
||
session.onStateChange = { [weak self] state in
|
||
guard let self else { return }
|
||
self.speechControls.update(state: state, rate: self.speechSession.controller.configuration.rate)
|
||
}
|
||
return session
|
||
}()
|
||
private let speechControls = RDSpeechReaderControlView()
|
||
private var navigationBarHiddenBeforeReader: Bool?
|
||
private var display = "pagecurl"
|
||
private var lastPageIndex = 0
|
||
private var lastCopiedText: String?
|
||
private let stateLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.accessibilityIdentifier = "demo.reader.state"
|
||
label.isAccessibilityElement = true
|
||
label.alpha = 0.01
|
||
return label
|
||
}()
|
||
|
||
init(url: URL, bookIdentifier: String? = nil, textSource: PDFDemoImageTextSource = .vision) {
|
||
let identifier = bookIdentifier ?? url.lastPathComponent
|
||
let store = RDPDFReaderPersistenceStore(rootURL: Self.annotationStorageURL(for: identifier, legacyBookURL: url))
|
||
var configuration = RDPDFReaderViewController.Configuration()
|
||
configuration.enablesOCR = textSource == .vision
|
||
configuration.missingTextSource = .region
|
||
configuration.recognitionLanguages = ["zh-Hans", "en-US"]
|
||
|
||
if textSource != .vision {
|
||
guard let document = PDFDocument(url: url) else { fatalError("无法打开 PDF") }
|
||
let provider = PDFDemoPageProvider(
|
||
document: document,
|
||
identifier: identifier,
|
||
title: url.deletingPathExtension().lastPathComponent,
|
||
textSource: textSource
|
||
)
|
||
configuration.pageImageTransform = { [weak provider] image, theme in
|
||
provider?.transformed(image, for: theme) ?? image
|
||
}
|
||
reader = RDPDFReaderViewController(
|
||
pageProvider: provider,
|
||
annotationPersistence: store,
|
||
configuration: configuration
|
||
)
|
||
} else {
|
||
do {
|
||
reader = try RDPDFReaderViewController(
|
||
pdfURL: url,
|
||
bookIdentifier: identifier,
|
||
annotationPersistence: store,
|
||
configuration: configuration
|
||
)
|
||
} catch {
|
||
fatalError("无法打开 PDF:\(error.localizedDescription)")
|
||
}
|
||
}
|
||
super.init(nibName: nil, bundle: nil)
|
||
reader.delegate = self
|
||
title = url.deletingPathExtension().lastPathComponent
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
addChild(reader)
|
||
reader.view.frame = view.bounds
|
||
reader.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||
view.addSubview(reader.view)
|
||
reader.didMove(toParent: self)
|
||
configureSpeechControls()
|
||
let assistantButton = UIButton(type: .system)
|
||
assistantButton.setTitle("AI", for: .normal)
|
||
assistantButton.titleLabel?.font = .preferredFont(forTextStyle: .headline)
|
||
assistantButton.addTarget(self, action: #selector(presentAIAssistant), for: .touchUpInside)
|
||
assistantButton.accessibilityLabel = "打开阅读助手"
|
||
view.addSubview(assistantButton)
|
||
assistantButton.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([assistantButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), assistantButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), assistantButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44), assistantButton.heightAnchor.constraint(equalToConstant: 44)])
|
||
stateLabel.translatesAutoresizingMaskIntoConstraints = false
|
||
view.addSubview(stateLabel)
|
||
NSLayoutConstraint.activate([stateLabel.widthAnchor.constraint(equalToConstant: 1), stateLabel.heightAnchor.constraint(equalToConstant: 1), stateLabel.topAnchor.constraint(equalTo: view.topAnchor), stateLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor)])
|
||
updateState(page: 0)
|
||
}
|
||
|
||
override func viewWillAppear(_ animated: Bool) {
|
||
super.viewWillAppear(animated)
|
||
if navigationBarHiddenBeforeReader == nil { navigationBarHiddenBeforeReader = navigationController?.isNavigationBarHidden }
|
||
navigationController?.setNavigationBarHidden(true, animated: animated)
|
||
}
|
||
|
||
override func viewWillDisappear(_ animated: Bool) {
|
||
super.viewWillDisappear(animated)
|
||
if let hidden = navigationBarHiddenBeforeReader { navigationController?.setNavigationBarHidden(hidden, animated: animated) }
|
||
}
|
||
|
||
func applyDemoDisplayType(_ type: RDPDFReaderView.DisplayType) {
|
||
reader.switchDisplayType(type)
|
||
display = type == .pageCurl ? "pagecurl" : (type == .verticalScroll ? "verticalscroll" : "horizontalscroll")
|
||
updateState(page: 0)
|
||
}
|
||
|
||
@objc private func presentAIAssistant() {
|
||
if #available(iOS 26.0, *) {
|
||
present(reader.makeAIReaderAssistant(scope: reader.aiCurrentReadScope(), generativeProvider: RDAIAppleFoundationModelsProvider()), animated: true)
|
||
} else {
|
||
present(reader.makeAIReaderAssistant(), animated: true)
|
||
}
|
||
}
|
||
func goToDemoPage(_ pageNumber: Int) { reader.goToPage(max(pageNumber - 1, 0)) }
|
||
|
||
func pdfReaderViewControllerDidRequestClose(_ controller: RDPDFReaderViewController) {
|
||
if let navigationController, navigationController.viewControllers.count > 1 { navigationController.popViewController(animated: true) }
|
||
else { dismiss(animated: true) }
|
||
}
|
||
|
||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didChangePage pageIndex: Int) { updateState(page: pageIndex) }
|
||
|
||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didCopyText text: String) {
|
||
lastCopiedText = text
|
||
updateState(page: lastPageIndex)
|
||
}
|
||
|
||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error) {
|
||
stateLabel.text = "reader=opened mode=pdf annotationStorage=error"
|
||
guard presentedViewController == nil else { return }
|
||
let alert = UIAlertController(title: "标注未保存", message: error.localizedDescription, preferredStyle: .alert)
|
||
alert.addAction(.init(title: "知道了", style: .default))
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
private func updateState(page: Int) {
|
||
lastPageIndex = page
|
||
var state = "reader=opened mode=pdf page=\(page + 1) display=\(display)"
|
||
if let lastCopiedText {
|
||
// 状态字段以空格分隔,文本需百分号编码;UI 测试端会解码还原。
|
||
let encoded = lastCopiedText.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? ""
|
||
state += " copy=success copiedText=\(encoded)"
|
||
}
|
||
stateLabel.text = state
|
||
}
|
||
|
||
private func configureSpeechControls() {
|
||
speechControls.translatesAutoresizingMaskIntoConstraints = false
|
||
view.addSubview(speechControls)
|
||
NSLayoutConstraint.activate([
|
||
speechControls.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
|
||
speechControls.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18),
|
||
speechControls.heightAnchor.constraint(equalToConstant: 48)
|
||
])
|
||
speechControls.onTogglePlayback = { [weak self] in self?.toggleSpeechPlayback() }
|
||
speechControls.onPreviousSentence = { [weak self] in self?.speechSession.controller.skipToPreviousSentence() }
|
||
speechControls.onNextSentence = { [weak self] in self?.speechSession.controller.skipToNextSentence() }
|
||
speechControls.onStop = { [weak self] in self?.speechSession.stop() }
|
||
speechControls.onChangeRate = { [weak self] in self?.cycleSpeechRate() }
|
||
}
|
||
|
||
private func toggleSpeechPlayback() {
|
||
switch speechSession.controller.state {
|
||
case .speaking:
|
||
speechSession.pause()
|
||
case .paused:
|
||
speechSession.resume()
|
||
case .preparing:
|
||
break
|
||
case .idle, .finished, .failed:
|
||
let pageIndex = max(lastPageIndex, 0)
|
||
Task { [weak self] in
|
||
guard let self else { return }
|
||
do {
|
||
try await self.speechSession.start(from: pageIndex)
|
||
} catch {
|
||
self.presentSpeechError(error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func cycleSpeechRate() {
|
||
let rates: [Float] = [0.38, 0.48, 0.58, 0.68]
|
||
let currentRate = speechSession.controller.configuration.rate
|
||
let nextIndex = (rates.firstIndex(where: { abs($0 - currentRate) < 0.01 }).map { ($0 + 1) % rates.count }) ?? 0
|
||
speechSession.controller.updateRate(rates[nextIndex])
|
||
speechControls.update(state: speechSession.controller.state, rate: rates[nextIndex])
|
||
}
|
||
|
||
private func presentSpeechError(_ error: Error) {
|
||
guard presentedViewController == nil else { return }
|
||
let alert = UIAlertController(title: "无法开始朗读", message: error.localizedDescription, preferredStyle: .alert)
|
||
alert.addAction(.init(title: "知道了", style: .default))
|
||
present(alert, animated: true)
|
||
}
|
||
|
||
private static func annotationStorageURL(for bookIdentifier: String, legacyBookURL: URL) -> URL {
|
||
let root = annotationStorageRoot()
|
||
let hash = SHA256.hash(data: bookIdentifier.data(using: .utf8) ?? Data()).map { String(format: "%02x", $0) }.joined()
|
||
let destination = root.appendingPathComponent(hash, isDirectory: true)
|
||
migrateLegacyAnnotationsIfNeeded(from: legacyBookURL, to: destination)
|
||
return destination
|
||
}
|
||
|
||
private static func annotationStorageRoot() -> URL {
|
||
(FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory).appendingPathComponent(annotationStorageFolderName, isDirectory: true)
|
||
}
|
||
|
||
private static func migrateLegacyAnnotationsIfNeeded(from url: URL, to destination: URL) {
|
||
let manager = FileManager.default
|
||
guard !manager.fileExists(atPath: destination.path) else { return }
|
||
let legacyName = (url.standardizedFileURL.path.data(using: .utf8) ?? Data()).base64EncodedString().replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "=", with: "")
|
||
let legacy = annotationStorageRoot().appendingPathComponent(legacyName, isDirectory: true)
|
||
guard let data = try? Data(contentsOf: legacy.appendingPathComponent("annotations.json")), (try? JSONDecoder().decode([RDPDFReaderAnnotation].self, from: data)) != nil else { return }
|
||
try? manager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||
try? manager.copyItem(at: legacy, to: destination)
|
||
}
|
||
|
||
static func resetPersistedAnnotations() { try? FileManager.default.removeItem(at: annotationStorageRoot()) }
|
||
}
|