Files
ReadViewSDK/ReadViewDemo/ReadViewDemo/PDFDemoReaderViewController.swift
T
2026-07-27 21:43:13 +08:00

374 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()) }
}