Files
ReadViewSDK/Sources/RDPDFReaderView/Sources/RDPDFReaderContracts.swift
T
2026-07-27 21:43:13 +08:00

319 lines
12 KiB
Swift

import UIKit
/// SDK 与主程序之间的阅读书籍摘要。下载与解析仍由主程序负责。
public struct RDPDFReaderBookDescriptor: Equatable {
public let identifier: String
public let title: String
public let totalPages: Int
public init(identifier: String, title: String, totalPages: Int) {
self.identifier = identifier
self.title = title
self.totalPages = totalPages
}
}
/// 宿主解析 PDF 后交给 SDK 的单页数据。后续链接、标注、文本区域均从这里扩展,
/// SDK 不直接触碰 PDF 文件、数据库模型或网络层。
public struct RDPDFReaderPageDescriptor {
public let index: Int
public let image: UIImage?
/// 宿主已解析的文本区域。坐标使用页面图片左上角为原点的 0...1 比例;
/// `nil` 表示宿主没有提供文本数据,SDK 可按需使用 OCR 作为兜底。
public let textRuns: [RDPDFReaderTextRun]?
public init(index: Int, image: UIImage?) {
self.index = index
self.image = image
textRuns = nil
}
public init(index: Int, image: UIImage?, textRuns: [RDPDFReaderTextRun]?) {
self.index = index
self.image = image
self.textRuns = textRuns
}
}
/// 页面中的一段可选择文本。一个 run 可以由多个不连续的矩形组成,方便宿主表达
/// 断行、双栏或 OCR 合并后的文字块。
///
/// 所有矩形均以页面图片左上角为原点,x/y/width/height 都是 0...1 的比例值。
public struct RDPDFReaderTextRun: Equatable, Codable, Sendable {
public let text: String
public let normalizedRects: [CGRect]
/// 页内阅读顺序。SDK 在相同页的文本选择中依此排序。
public let readingOrder: Int
/// 逐字符外接矩形,与 `text` 的组合字符(`Character`)序列一一对应。
/// 为 `nil` 或数量不匹配时,SDK 会按行矩形宽度均分估算每个字符的位置,
/// 仍可进行字符级选择,只是边界略有偏差。
public let characterRects: [CGRect]?
public init(text: String, normalizedRects: [CGRect], readingOrder: Int, characterRects: [CGRect]? = nil) {
self.text = text
self.normalizedRects = normalizedRects
self.readingOrder = readingOrder
self.characterRects = characterRects
}
/// 单行/单区域文本的便捷初始化方式。
public init(text: String, normalizedRect: CGRect, readingOrder: Int, characterRects: [CGRect]? = nil) {
self.init(text: text, normalizedRects: [normalizedRect], readingOrder: readingOrder, characterRects: characterRects)
}
/// 覆盖全部文本区域的最小外接矩形,便于命中测试和滚动定位。
public var normalizedRect: CGRect {
normalizedRects.reduce(into: CGRect.null) { result, rect in
result = result.union(rect)
}
}
}
/// 标注的文字来源,用于让 UI 清楚地展示“宿主文本”“OCR”或“区域标注”的能力差异。
public enum RDPDFReaderAnnotationSource: String, Codable, Equatable, Sendable {
/// 宿主直接提供的 PDF 解析文字坐标。
case text
/// SDK 从页面图片识别出的 OCR 文字坐标。
case ocr
/// 没有文字坐标时由用户框选出的图片区域。
case region
/// 标注的来源仅影响交互与展示;遇到未来版本新增的来源时,按区域标注安全降级,
/// 避免一条新记录导致整本书的历史标注都无法读取。
public init(from decoder: Decoder) throws {
let rawValue = try decoder.singleValueContainer().decode(String.self)
self = Self(rawValue: rawValue) ?? .region
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(rawValue)
}
}
/// 统一表示 PDF 的高亮和笔记。`note == nil` 时是纯高亮;存在 `note` 时是带注释的标注。
/// 坐标与 `RDPDFReaderTextRun` 相同,使用页面图片左上角为原点的 0...1 比例。
public struct RDPDFReaderAnnotation: Equatable, Codable {
public let id: String
public var pageIndex: Int
public var selectedText: String?
public var normalizedRects: [CGRect]
public var color: String
public var note: String?
public var source: RDPDFReaderAnnotationSource
public var createdAt: Date
public var updatedAt: Date
private enum CodingKeys: String, CodingKey {
case id
case pageIndex
case selectedText
case normalizedRects
case color
case note
case source
case createdAt
case updatedAt
}
public init(
id: String = UUID().uuidString,
pageIndex: Int,
selectedText: String? = nil,
normalizedRects: [CGRect],
color: String = "#F8E16C",
note: String? = nil,
source: RDPDFReaderAnnotationSource = .region,
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.id = id
self.pageIndex = pageIndex
self.selectedText = selectedText
self.normalizedRects = normalizedRects
self.color = color
self.note = note
self.source = source
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// 为演进中的标注文件提供向后兼容的默认值。字段缺失时保留可恢复的内容,
/// 而不会令整组历史标注都解码失败。
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString
pageIndex = try container.decodeIfPresent(Int.self, forKey: .pageIndex) ?? 0
selectedText = try container.decodeIfPresent(String.self, forKey: .selectedText)
normalizedRects = try container.decodeIfPresent([CGRect].self, forKey: .normalizedRects) ?? []
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
note = try container.decodeIfPresent(String.self, forKey: .note)
source = try container.decodeIfPresent(RDPDFReaderAnnotationSource.self, forKey: .source) ?? .region
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(pageIndex, forKey: .pageIndex)
try container.encodeIfPresent(selectedText, forKey: .selectedText)
try container.encode(normalizedRects, forKey: .normalizedRects)
try container.encode(color, forKey: .color)
try container.encodeIfPresent(note, forKey: .note)
try container.encode(source, forKey: .source)
try container.encode(createdAt, forKey: .createdAt)
try container.encode(updatedAt, forKey: .updatedAt)
}
/// 单区域标注的便捷初始化方式。
public init(
id: String = UUID().uuidString,
pageIndex: Int,
selectedText: String? = nil,
normalizedRect: CGRect,
color: String = "#F8E16C",
note: String? = nil,
source: RDPDFReaderAnnotationSource = .region,
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.init(
id: id,
pageIndex: pageIndex,
selectedText: selectedText,
normalizedRects: [normalizedRect],
color: color,
note: note,
source: source,
createdAt: createdAt,
updatedAt: updatedAt
)
}
/// 覆盖全部标注区域的最小外接矩形,适合用于页内定位。
public var normalizedRect: CGRect {
normalizedRects.reduce(into: CGRect.null) { result, rect in
result = result.union(rect)
}
}
}
public struct RDPDFReaderOutlineItem: Equatable {
public let title: String
public let pageIndex: Int
public let level: Int
public init(title: String, pageIndex: Int, level: Int = 0) {
self.title = title
self.pageIndex = pageIndex
self.level = level
}
}
public struct RDPDFReaderBookmark: Equatable {
public let pageIndex: Int
public let title: String?
public init(pageIndex: Int, title: String? = nil) {
self.pageIndex = pageIndex
self.title = title
}
}
/// 与存储实现无关的 PDF 文字标注。坐标均为相对于页面宽高的 0...1 比例。
public struct RDPDFReaderHighlight: Equatable, Codable {
public let id: Int
public let pageIndex: Int
public let selectedText: String
public let color: String
public let normalizedRect: CGRect
public init(id: Int, pageIndex: Int, selectedText: String, color: String, normalizedRect: CGRect) {
self.id = id
self.pageIndex = pageIndex
self.selectedText = selectedText
self.color = color
self.normalizedRect = normalizedRect
}
}
/// 主程序实现此协议,把“已经解析好的页面”提供给 SDK。页面和缩略图回调可在任意
/// 队列触发;SDK 会在接收结果后自行切回主线程更新界面。
public protocol RDPDFReaderPageProvider: AnyObject {
func readerBookDescriptor() -> RDPDFReaderBookDescriptor
func readerPage(at index: Int, completion: @escaping (RDPDFReaderPageDescriptor) -> Void)
func readerThumbnail(at index: Int, targetSize: CGSize, completion: @escaping (UIImage?) -> Void)
}
/// 进度、书签、笔记和画笔数据由宿主保存,SDK 只通过此协议读写。
public protocol RDPDFReaderPersistence: AnyObject {
func restoreReadingPage(for bookIdentifier: String) -> Int?
func saveReadingPage(_ pageIndex: Int, for bookIdentifier: String)
func loadBookmarks(for bookIdentifier: String) -> [RDPDFReaderBookmark]
func setBookmark(_ isBookmarked: Bool, pageIndex: Int, for bookIdentifier: String)
}
/// 可选的标注存储能力。成品阅读器仅在提供此协议时启用标注列表、创建与编辑功能;
/// 宿主可使用 SDK 的文件库,也可以接入自己的数据库实现。
public protocol RDPDFReaderAnnotationPersisting: AnyObject {
func loadAnnotations() throws -> [RDPDFReaderAnnotation]
@discardableResult func addAnnotation(_ annotation: RDPDFReaderAnnotation) throws -> RDPDFReaderAnnotation
@discardableResult func updateAnnotation(_ annotation: RDPDFReaderAnnotation) throws -> RDPDFReaderAnnotation?
@discardableResult func deleteAnnotation(id: String) throws -> Bool
}
/// 目录是可选的页面提供扩展;未实现时成品阅读器自动按页生成目录。
public protocol RDPDFReaderOutlineProviding: AnyObject {
func readerOutlineItems() -> [RDPDFReaderOutlineItem]
}
/// 可选的异步目录提供协议。适用于目录解析可能耗时的文件格式;SDK 会在加载完成后
/// 再展示导航面板,避免阻塞主线程。未实现时继续兼容 `RDPDFReaderOutlineProviding`。
public protocol RDPDFReaderAsyncOutlineProviding: AnyObject {
func readerOutlineItems(completion: @escaping ([RDPDFReaderOutlineItem]) -> Void)
}
/// 媒体、购买、外链等业务行为由宿主处理,SDK 不引用路由或播放器实现。
public enum RDPDFReaderHostAction: Equatable {
case back
case openLink(identifier: String)
case playMedia(identifier: String)
case requestPurchase
}
/// SDK 内部使用的翻页方式,宿主负责映射到具体阅读容器的实现。
public enum RDPDFReaderDisplayMode: Int {
case pageCurl
case horizontalScroll
case verticalScroll
}
/// 画笔工具的 UI 语义;实际笔迹模型和存储仍由宿主决定。
public enum RDPDFReaderDrawingTool: Int, Codable {
// 保持和既有 PDF 笔迹 JSON 的取值一致,升级后可无损读取旧数据。
case pen = 1
case highlighter = 2
case eraser = 3
}
/// 选区菜单的动作(在文本选择时显示)。
public enum RDPDFReaderAnnotationMenuAction {
case copy
case highlight
case annotate
}
/// 已有划线的菜单动作(点击已有划线时显示)。
public enum RDPDFReaderExistingHighlightMenuAction {
case copy
case createUnderline
case deleteUnderline
case annotate
case deleteAnnotation
}
public protocol RDPDFReaderHostActionHandling: AnyObject {
func readerHandle(action: RDPDFReaderHostAction)
}