源码注释: - 为 ~60 个 Swift 文件补充缺失的 doc comment(file header、类型、属性、方法) - 修正 4 处错误注释:翻页模式数量、搜索行为描述、手势识别器描述、悬空文档块 文档维护: - 删除重复文档:WXRead/读书EPUB阅读器实现架构.md(与微信读书版完全一致) - 合并重叠文档:阅读器规划.md → 阅读器功能开发计划.md(单一真值) - 修正过时内容:所有文档中"四种翻页模式"→"三种",移除 horizontalCoverScroll - 更新架构图:补齐 EPUBUI/ReaderController、Paging/、Typesetter/ 等子目录 - 更新 index.md 索引:新增开发计划和架构对比文档引用
754 lines
29 KiB
Swift
754 lines
29 KiB
Swift
import UIKit
|
|
import RDReaderView
|
|
|
|
final class ViewController: UIViewController {
|
|
private struct DemoBook: Equatable {
|
|
let title: String
|
|
let detail: String
|
|
let fileURL: URL
|
|
}
|
|
|
|
private struct LaunchAutomationPlan {
|
|
let bookTitleQuery: String
|
|
let displayType: RDReaderView.DisplayType?
|
|
let pageNumber: Int?
|
|
let displaySequence: [RDReaderView.DisplayType]
|
|
let stepDelay: TimeInterval
|
|
|
|
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
|
|
switch rawValue.lowercased() {
|
|
case "pagecurl", "curl":
|
|
return .pageCurl
|
|
case "horizontalscroll", "horizontal", "scroll":
|
|
return .horizontalScroll
|
|
case "verticalscroll", "vertical":
|
|
return .verticalScroll
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
static func parse(arguments: [String]) -> LaunchAutomationPlan? {
|
|
func value(after flag: String) -> String? {
|
|
guard let index = arguments.firstIndex(of: flag), arguments.indices.contains(index + 1) else {
|
|
return nil
|
|
}
|
|
return arguments[index + 1]
|
|
}
|
|
|
|
guard let bookTitleQuery = value(after: "--demo-book-title"),
|
|
!bookTitleQuery.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
let displayType = value(after: "--demo-display-type").flatMap(parseDisplayType)
|
|
let pageNumber = value(after: "--demo-page").flatMap(Int.init)
|
|
let displaySequence = value(after: "--demo-display-sequence")
|
|
.map { raw in
|
|
raw.split(separator: ",").compactMap { parseDisplayType(String($0)) }
|
|
} ?? []
|
|
let stepDelay = value(after: "--demo-step-delay").flatMap(TimeInterval.init) ?? 1.0
|
|
|
|
return LaunchAutomationPlan(
|
|
bookTitleQuery: bookTitleQuery,
|
|
displayType: displayType,
|
|
pageNumber: pageNumber,
|
|
displaySequence: displaySequence,
|
|
stepDelay: stepDelay
|
|
)
|
|
}
|
|
}
|
|
|
|
private enum ValidationCategory: String, CaseIterable {
|
|
case textNovel = "TXT/小说"
|
|
case textRich = "复杂图文"
|
|
case webFixed = "Fixed/互动"
|
|
case plainText = "TXT"
|
|
|
|
var sortOrder: Int {
|
|
switch self {
|
|
case .textNovel: return 0
|
|
case .textRich: return 1
|
|
case .webFixed: return 2
|
|
case .plainText: return 3
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct BookValidationReport {
|
|
let title: String
|
|
let category: ValidationCategory
|
|
let profile: String
|
|
let passed: Bool
|
|
let notes: [String]
|
|
}
|
|
|
|
private struct ResourceValidationSummary {
|
|
let checkedCount: Int
|
|
let passedCount: Int
|
|
let failedBooks: [String]
|
|
let matrixLines: [String]
|
|
let diagnosticLines: [String]
|
|
let rerunHint: String
|
|
|
|
var statusText: String {
|
|
var lines: [String] = []
|
|
if checkedCount == 0 {
|
|
lines.append("样本验证:未发现可验证样本")
|
|
} else if failedBooks.isEmpty {
|
|
lines.append("样本验证:\(passedCount)/\(checkedCount) 通过")
|
|
} else {
|
|
let titles = failedBooks.prefix(2).joined(separator: "、")
|
|
lines.append("样本验证:\(passedCount)/\(checkedCount) 通过,失败样本:\(titles)")
|
|
}
|
|
lines.append(contentsOf: matrixLines.prefix(3))
|
|
lines.append(contentsOf: prioritizedDiagnosticLines())
|
|
if !rerunHint.isEmpty {
|
|
lines.append(rerunHint)
|
|
}
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
private func prioritizedDiagnosticLines() -> [String] {
|
|
var selected: [String] = []
|
|
if let semanticLine = diagnosticLines.first(where: { $0.contains("属性闭环诊断") }) {
|
|
selected.append(semanticLine)
|
|
}
|
|
if let paginationLine = diagnosticLines.first(where: { $0.contains("分页诊断") && !selected.contains($0) }) {
|
|
selected.append(paginationLine)
|
|
}
|
|
if selected.count < 2 {
|
|
for line in diagnosticLines where !selected.contains(line) {
|
|
selected.append(line)
|
|
if selected.count == 2 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return selected
|
|
}
|
|
}
|
|
|
|
private let statusLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.numberOfLines = 0
|
|
label.textAlignment = .left
|
|
label.textColor = .secondaryLabel
|
|
label.font = .systemFont(ofSize: 15)
|
|
label.text = "ReadViewSDK Demo"
|
|
label.accessibilityIdentifier = "demo.status"
|
|
return label
|
|
}()
|
|
|
|
private let tableView: UITableView = {
|
|
let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
|
tableView.accessibilityIdentifier = "demo.books.table"
|
|
tableView.rowHeight = 72
|
|
tableView.tableFooterView = UIView()
|
|
return tableView
|
|
}()
|
|
|
|
private lazy var emptyStateLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.numberOfLines = 0
|
|
label.textAlignment = .center
|
|
label.textColor = .secondaryLabel
|
|
label.font = .systemFont(ofSize: 15)
|
|
label.text = "book 目录下暂时没有可阅读的 txt 或 epub 文件"
|
|
label.accessibilityIdentifier = "demo.books.empty"
|
|
return label
|
|
}()
|
|
|
|
private var books: [DemoBook] = []
|
|
private var validationSummary: ResourceValidationSummary?
|
|
private var validationTask: Task<Void, Never>?
|
|
private let launchAutomationPlan = LaunchAutomationPlan.parse(arguments: ProcessInfo.processInfo.arguments)
|
|
private var didRunLaunchAutomation = false
|
|
|
|
deinit {
|
|
validationTask?.cancel()
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .systemBackground
|
|
view.accessibilityIdentifier = "demo.root"
|
|
title = "书库"
|
|
setupViews()
|
|
loadBooks()
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
runLaunchAutomationIfNeeded()
|
|
}
|
|
|
|
private func setupViews() {
|
|
tableView.dataSource = self
|
|
tableView.delegate = self
|
|
|
|
view.addSubview(statusLabel)
|
|
view.addSubview(tableView)
|
|
|
|
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
tableView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
NSLayoutConstraint.activate([
|
|
statusLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
|
|
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
|
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
|
|
|
tableView.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 12),
|
|
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
|
])
|
|
}
|
|
|
|
private func loadBooks() {
|
|
books = discoverBooks()
|
|
tableView.reloadData()
|
|
updateChrome()
|
|
validateSampleBooks()
|
|
}
|
|
|
|
private func updateChrome() {
|
|
if books.isEmpty {
|
|
statusLabel.text = "ReadViewSDK Demo\nbook 目录下未找到 txt 或 epub 文件"
|
|
tableView.backgroundView = emptyStateLabel
|
|
} else {
|
|
var lines = [
|
|
"ReadViewSDK Demo",
|
|
"已发现 \(books.count) 本本地图书,点击即可进入阅读器"
|
|
]
|
|
if let validationSummary {
|
|
lines.append(validationSummary.statusText)
|
|
}
|
|
statusLabel.text = lines.joined(separator: "\n")
|
|
tableView.backgroundView = nil
|
|
}
|
|
}
|
|
|
|
private func discoverBooks() -> [DemoBook] {
|
|
let fileManager = FileManager.default
|
|
let bookDirectoryURLs = [
|
|
Bundle.main.resourceURL?.appendingPathComponent("book", isDirectory: true),
|
|
Bundle.main.resourceURL
|
|
].compactMap { $0 }
|
|
|
|
let fileURLs = bookDirectoryURLs.flatMap { directoryURL in
|
|
(try? fileManager.contentsOfDirectory(
|
|
at: directoryURL,
|
|
includingPropertiesForKeys: [.isRegularFileKey],
|
|
options: [.skipsHiddenFiles]
|
|
)) ?? []
|
|
}
|
|
|
|
return fileURLs
|
|
.filter { url in
|
|
let ext = url.pathExtension.lowercased()
|
|
return ext == "epub" || ext == "txt"
|
|
}
|
|
.reduce(into: [URL]()) { result, url in
|
|
if !result.contains(url) {
|
|
result.append(url)
|
|
}
|
|
}
|
|
.sorted { lhs, rhs in
|
|
lhs.lastPathComponent.localizedStandardCompare(rhs.lastPathComponent) == .orderedAscending
|
|
}
|
|
.map { fileURL in
|
|
let ext = fileURL.pathExtension.uppercased()
|
|
let title = fileURL.deletingPathExtension().lastPathComponent
|
|
return DemoBook(
|
|
title: title,
|
|
detail: ext.isEmpty ? fileURL.lastPathComponent : "\(ext) · \(fileURL.lastPathComponent)",
|
|
fileURL: fileURL
|
|
)
|
|
}
|
|
}
|
|
|
|
private func validateSampleBooks() {
|
|
validationTask?.cancel()
|
|
guard !books.isEmpty else {
|
|
validationSummary = nil
|
|
updateChrome()
|
|
return
|
|
}
|
|
|
|
let pageSize = currentReaderPageSize()
|
|
let style = currentReaderTextStyle()
|
|
validationTask = Task.detached(priority: .utility) { [books] in
|
|
let summary = Self.validateBooks(books, pageSize: pageSize, style: style)
|
|
await MainActor.run {
|
|
print("[ReadViewDemo] \(summary.statusText)")
|
|
self.validationSummary = summary
|
|
self.updateChrome()
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated private static func validateBooks(
|
|
_ books: [DemoBook],
|
|
pageSize: CGSize,
|
|
style: RDEPUBTextRenderStyle
|
|
) -> ResourceValidationSummary {
|
|
var checkedCount = 0
|
|
var passedCount = 0
|
|
var failedBooks: [String] = []
|
|
var reports: [BookValidationReport] = []
|
|
var diagnosticLines: [String] = []
|
|
|
|
for book in books {
|
|
let result = validateBook(book, pageSize: pageSize, style: style)
|
|
checkedCount += 1
|
|
if result.report.passed {
|
|
passedCount += 1
|
|
} else {
|
|
failedBooks.append(book.title)
|
|
}
|
|
reports.append(result.report)
|
|
diagnosticLines.append(contentsOf: result.diagnostics)
|
|
}
|
|
|
|
return ResourceValidationSummary(
|
|
checkedCount: checkedCount,
|
|
passedCount: passedCount,
|
|
failedBooks: failedBooks,
|
|
matrixLines: makeMatrixLines(from: reports),
|
|
diagnosticLines: diagnosticLines,
|
|
rerunHint: "复现入口:启动 Demo 查看摘要,打开样本书后再验证搜索 / 高亮 / 主题字号切换"
|
|
)
|
|
}
|
|
|
|
nonisolated private static func validateBook(
|
|
_ book: DemoBook,
|
|
pageSize: CGSize,
|
|
style: RDEPUBTextRenderStyle
|
|
) -> (report: BookValidationReport, diagnostics: [String]) {
|
|
let fileExtension = book.fileURL.pathExtension.lowercased()
|
|
if fileExtension == "txt" {
|
|
let builder = RDPlainTextBookBuilder()
|
|
do {
|
|
let textBook = try builder.build(textFileURL: book.fileURL, pageSize: pageSize, style: style)
|
|
let passed = !textBook.pages.isEmpty && !textBook.chapters.isEmpty
|
|
let report = BookValidationReport(
|
|
title: book.title,
|
|
category: .plainText,
|
|
profile: "txt",
|
|
passed: passed,
|
|
notes: [
|
|
"章节 \(textBook.chapters.count)",
|
|
"页数 \(textBook.pages.count)"
|
|
]
|
|
)
|
|
let diagnostics = [
|
|
"TXT 验证:\(book.title) · chapters \(textBook.chapters.count) · pages \(textBook.pages.count)"
|
|
]
|
|
return (report, diagnostics)
|
|
} catch {
|
|
let report = BookValidationReport(
|
|
title: book.title,
|
|
category: .plainText,
|
|
profile: "txt",
|
|
passed: false,
|
|
notes: [error.localizedDescription]
|
|
)
|
|
return (report, ["TXT 验证失败:\(book.title) · \(error.localizedDescription)"])
|
|
}
|
|
}
|
|
|
|
let parser = RDEPUBParser()
|
|
do {
|
|
try parser.parse(epubURL: book.fileURL)
|
|
let publication = parser.makePublication()
|
|
switch publication.readingProfile {
|
|
case .textReflowable:
|
|
return validateReflowableBook(
|
|
book,
|
|
parser: parser,
|
|
publication: publication,
|
|
pageSize: pageSize,
|
|
style: style
|
|
)
|
|
case .webFixedLayout, .webInteractive:
|
|
return validateWebBook(
|
|
book,
|
|
publication: publication,
|
|
viewportSize: pageSize
|
|
)
|
|
}
|
|
} catch {
|
|
let report = BookValidationReport(
|
|
title: book.title,
|
|
category: .textRich,
|
|
profile: "parse-failed",
|
|
passed: false,
|
|
notes: [error.localizedDescription]
|
|
)
|
|
return (report, ["EPUB 验证失败:\(book.title) · \(error.localizedDescription)"])
|
|
}
|
|
}
|
|
|
|
nonisolated private static func validateReflowableBook(
|
|
_ book: DemoBook,
|
|
parser: RDEPUBParser,
|
|
publication: RDEPUBPublication,
|
|
pageSize: CGSize,
|
|
style: RDEPUBTextRenderStyle
|
|
) -> (report: BookValidationReport, diagnostics: [String]) {
|
|
let builder = RDEPUBTextBookBuilder()
|
|
do {
|
|
let textBook = try builder.build(
|
|
parser: parser,
|
|
publication: publication,
|
|
pageSize: pageSize,
|
|
style: style
|
|
)
|
|
let missingResources = builder.lastBuildResourceDiagnostics.filter { !$0.existsOnDisk }
|
|
let diagnostics = builder.lastBuildPaginationDiagnostics
|
|
let attachmentPages = diagnostics.reduce(0) { $0 + $1.attachmentPageCount }
|
|
let category: ValidationCategory = attachmentPages > 0 ? .textRich : .textNovel
|
|
let passed = !textBook.pages.isEmpty && missingResources.isEmpty
|
|
|
|
var reportNotes = [
|
|
"章节 \(textBook.chapters.count)",
|
|
"页数 \(textBook.pages.count)"
|
|
]
|
|
if attachmentPages > 0 {
|
|
reportNotes.append("attachment 页 \(attachmentPages)")
|
|
}
|
|
|
|
var summaryLines: [String] = []
|
|
if let paginationSummary = makePaginationSummary(diagnostics, title: book.title) {
|
|
summaryLines.append("分页诊断:\(paginationSummary)")
|
|
}
|
|
if let semanticSummary = builder.phase7SemanticSummary(title: book.title) {
|
|
summaryLines.append("属性闭环诊断:\(semanticSummary)")
|
|
}
|
|
if let restoreSummary = makeRestoreSummary(
|
|
parser: parser,
|
|
publication: publication,
|
|
textBook: textBook,
|
|
pageSize: pageSize,
|
|
style: style,
|
|
title: book.title
|
|
) {
|
|
summaryLines.append("恢复诊断:\(restoreSummary)")
|
|
}
|
|
|
|
let report = BookValidationReport(
|
|
title: book.title,
|
|
category: category,
|
|
profile: publication.readingProfile.rawValue,
|
|
passed: passed,
|
|
notes: reportNotes
|
|
)
|
|
return (report, summaryLines)
|
|
} catch {
|
|
let category: ValidationCategory = book.title.contains("凡人") ? .textNovel : .textRich
|
|
let report = BookValidationReport(
|
|
title: book.title,
|
|
category: category,
|
|
profile: publication.readingProfile.rawValue,
|
|
passed: false,
|
|
notes: [error.localizedDescription]
|
|
)
|
|
return (report, ["EPUB 验证失败:\(book.title) · \(error.localizedDescription)"])
|
|
}
|
|
}
|
|
|
|
nonisolated private static func validateWebBook(
|
|
_ book: DemoBook,
|
|
publication: RDEPUBPublication,
|
|
viewportSize: CGSize
|
|
) -> (report: BookValidationReport, diagnostics: [String]) {
|
|
let linearItems = publication.spine.filter(\.linear)
|
|
let missingFiles = linearItems.filter {
|
|
guard let normalizedHref = publication.resourceResolver.normalizedHref($0.href) else {
|
|
return true
|
|
}
|
|
return publication.resourceResolver.fileURL(forRelativePath: normalizedHref) == nil
|
|
}
|
|
let defaultConfiguration = RDEPUBReaderConfiguration.default
|
|
let preferences = RDEPUBPreferences(
|
|
fontSize: defaultConfiguration.fontSize,
|
|
lineHeightMultiple: defaultConfiguration.lineHeightMultiple,
|
|
reflowableContentInsets: defaultConfiguration.reflowableContentInsets,
|
|
fixedContentInset: defaultConfiguration.fixedContentInset,
|
|
fixedLayoutFit: defaultConfiguration.fixedLayoutFit,
|
|
fixedLayoutSpreadMode: defaultConfiguration.fixedLayoutSpreadMode
|
|
)
|
|
let spreadCount = publication.layout == .fixed
|
|
? publication.makeFixedSpreads(
|
|
preferences: preferences,
|
|
viewportSize: viewportSize
|
|
).count
|
|
: 0
|
|
let passed = !linearItems.isEmpty && missingFiles.isEmpty && (publication.layout != .fixed || spreadCount > 0)
|
|
let category: ValidationCategory = .webFixed
|
|
let report = BookValidationReport(
|
|
title: book.title,
|
|
category: category,
|
|
profile: publication.readingProfile.rawValue,
|
|
passed: passed,
|
|
notes: [
|
|
"spine \(linearItems.count)",
|
|
publication.layout == .fixed ? "spread \(spreadCount)" : "interactive"
|
|
]
|
|
)
|
|
let diagnostic = "Web 路径验证:\(book.title) · profile \(publication.readingProfile.rawValue) · spine \(linearItems.count) · missing \(missingFiles.count)"
|
|
return (report, [diagnostic])
|
|
}
|
|
|
|
nonisolated private static func makeMatrixLines(from reports: [BookValidationReport]) -> [String] {
|
|
let grouped = Dictionary(grouping: reports, by: \.category)
|
|
return ValidationCategory.allCases.compactMap { category in
|
|
guard let items = grouped[category], !items.isEmpty else { return nil }
|
|
let passed = items.filter(\.passed).count
|
|
let notes = items.prefix(2).map { "\($0.title)(\($0.profile))" }.joined(separator: "、")
|
|
return "矩阵[\(category.rawValue)] \(passed)/\(items.count) · \(notes)"
|
|
}
|
|
}
|
|
|
|
nonisolated private static func makePaginationSummary(
|
|
_ diagnostics: [RDEPUBTextChapterPaginationDiagnostic],
|
|
title: String
|
|
) -> String? {
|
|
guard !diagnostics.isEmpty else { return nil }
|
|
let attachmentPages = diagnostics.reduce(0) { $0 + $1.attachmentPageCount }
|
|
let semanticBreakPages = diagnostics.reduce(0) { $0 + $1.blockAdjustedPageCount }
|
|
let blockKinds = uniqueValues(diagnostics.flatMap(\.blockKinds))
|
|
let semanticHints = uniqueValues(diagnostics.flatMap(\.semanticHints))
|
|
let attachmentPlacements = uniqueValues(diagnostics.flatMap(\.attachmentPlacements))
|
|
let breakReasonCounts = diagnostics
|
|
.flatMap(\.breakReasons)
|
|
.reduce(into: [RDEPUBTextPageBreakReason: Int]()) { counts, reason in
|
|
counts[reason, default: 0] += 1
|
|
}
|
|
let orderedReasons = breakReasonCounts
|
|
.sorted { lhs, rhs in
|
|
if lhs.value == rhs.value {
|
|
return lhs.key.rawValue < rhs.key.rawValue
|
|
}
|
|
return lhs.value > rhs.value
|
|
}
|
|
.map { "\($0.key.rawValue):\($0.value)" }
|
|
.joined(separator: ", ")
|
|
let note = diagnostics
|
|
.flatMap(\.sampleNotes)
|
|
.first(where: { $0.contains("attachment") || $0.contains("block") || $0.contains("page break") })
|
|
var parts = [
|
|
"\(title)",
|
|
"章节 \(diagnostics.count)",
|
|
"attachment 页 \(attachmentPages)",
|
|
"semantic break 页 \(semanticBreakPages)",
|
|
blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]",
|
|
semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
|
|
attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]",
|
|
orderedReasons.isEmpty ? nil : "reasons [\(orderedReasons)]"
|
|
].compactMap { $0 }
|
|
if let note {
|
|
parts.append(note)
|
|
}
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
nonisolated private static func makeRestoreSummary(
|
|
parser: RDEPUBParser,
|
|
publication: RDEPUBPublication,
|
|
textBook: RDEPUBTextBook,
|
|
pageSize: CGSize,
|
|
style: RDEPUBTextRenderStyle,
|
|
title: String
|
|
) -> String? {
|
|
guard textBook.pages.count > 1 else { return nil }
|
|
let targetPageNumber = min(max(textBook.pages.count / 2, 1), textBook.pages.count)
|
|
guard let restoreLocation = textBook.location(forPageNumber: targetPageNumber, bookIdentifier: publication.metadata.identifier),
|
|
let baseResolvedPage = textBook.pageNumber(
|
|
for: restoreLocation,
|
|
resolver: publication.resourceResolver,
|
|
bookIdentifier: publication.metadata.identifier
|
|
) else {
|
|
return nil
|
|
}
|
|
|
|
let alternateFont = UIFont.systemFont(ofSize: style.font.pointSize + 2)
|
|
let alternateLineSpacing = style.lineSpacing + max(alternateFont.lineHeight * 0.15, 2)
|
|
let alternateStyle = RDEPUBTextRenderStyle(
|
|
font: alternateFont,
|
|
lineSpacing: alternateLineSpacing,
|
|
textColor: style.textColor,
|
|
backgroundColor: style.backgroundColor
|
|
)
|
|
let themeStyle = RDEPUBTextRenderStyle(
|
|
font: style.font,
|
|
lineSpacing: style.lineSpacing,
|
|
textColor: .white,
|
|
backgroundColor: .black
|
|
)
|
|
|
|
let builder = RDEPUBTextBookBuilder()
|
|
guard let alternateBook = try? builder.build(
|
|
parser: parser,
|
|
publication: publication,
|
|
pageSize: pageSize,
|
|
style: alternateStyle
|
|
), let alternatePageNumber = alternateBook.pageNumber(
|
|
for: restoreLocation,
|
|
resolver: publication.resourceResolver,
|
|
bookIdentifier: publication.metadata.identifier
|
|
), let alternateResolvedLocation = alternateBook.location(
|
|
forPageNumber: alternatePageNumber,
|
|
bookIdentifier: publication.metadata.identifier
|
|
) else {
|
|
return nil
|
|
}
|
|
|
|
let themeBuilder = RDEPUBTextBookBuilder()
|
|
let themeBook = try? themeBuilder.build(
|
|
parser: parser,
|
|
publication: publication,
|
|
pageSize: pageSize,
|
|
style: themeStyle
|
|
)
|
|
let themePageNumber = themeBook?.pageNumber(
|
|
for: restoreLocation,
|
|
resolver: publication.resourceResolver,
|
|
bookIdentifier: publication.metadata.identifier
|
|
)
|
|
|
|
let hrefStable = (publication.resourceResolver.normalizedHref(alternateResolvedLocation.href) ?? alternateResolvedLocation.href) ==
|
|
(publication.resourceResolver.normalizedHref(restoreLocation.href) ?? restoreLocation.href)
|
|
let progressionDelta = abs(alternateResolvedLocation.navigationProgression - restoreLocation.navigationProgression)
|
|
let themeStable = themePageNumber == baseResolvedPage
|
|
|
|
return [
|
|
title,
|
|
"base \(baseResolvedPage)",
|
|
"font-shift \(alternatePageNumber)",
|
|
"theme-stable \(themeStable ? "yes" : "no")",
|
|
"href-stable \(hrefStable ? "yes" : "no")",
|
|
String(format: "progression-delta %.3f", progressionDelta)
|
|
].joined(separator: " · ")
|
|
}
|
|
|
|
nonisolated private static func uniqueValues<T: Equatable>(_ values: [T]) -> [T] {
|
|
values.reduce(into: [T]()) { result, value in
|
|
if !result.contains(value) {
|
|
result.append(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func openBook(
|
|
_ book: DemoBook,
|
|
configuration: RDEPUBReaderConfiguration = .default,
|
|
automationPlan: LaunchAutomationPlan? = nil
|
|
) -> RDURLReaderController {
|
|
let controller = RDURLReaderController(bookURL: book.fileURL, epubConfiguration: configuration)
|
|
controller.title = book.title
|
|
|
|
if let navigationController {
|
|
navigationController.pushViewController(controller, animated: true)
|
|
} else {
|
|
let hostController = UINavigationController(rootViewController: controller)
|
|
hostController.modalPresentationStyle = .fullScreen
|
|
hostController.view.accessibilityIdentifier = "demo.reader.host"
|
|
present(hostController, animated: true)
|
|
}
|
|
|
|
if let automationPlan {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak controller] in
|
|
if let displayType = automationPlan.displayType {
|
|
controller?.applyDemoDisplayType(displayType)
|
|
}
|
|
if let pageNumber = automationPlan.pageNumber {
|
|
_ = controller?.goToDemoPage(pageNumber)
|
|
}
|
|
if !automationPlan.displaySequence.isEmpty {
|
|
controller?.runDemoDisplaySequence(
|
|
automationPlan.displaySequence,
|
|
initialPageNumber: nil,
|
|
stepDelay: automationPlan.stepDelay
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return controller
|
|
}
|
|
|
|
private func currentReaderPageSize() -> CGSize {
|
|
let viewportSize = UIScreen.main.bounds.size
|
|
let insets = RDEPUBReaderConfiguration.default.reflowableContentInsets
|
|
return CGSize(
|
|
width: max(viewportSize.width - insets.left - insets.right, 1),
|
|
height: max(viewportSize.height - insets.top - insets.bottom, 1)
|
|
)
|
|
}
|
|
|
|
private func currentReaderTextStyle() -> RDEPUBTextRenderStyle {
|
|
let configuration = RDEPUBReaderConfiguration.default
|
|
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
|
|
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
|
|
return RDEPUBTextRenderStyle(
|
|
font: font,
|
|
lineSpacing: lineSpacing,
|
|
textColor: configuration.theme.contentTextColor,
|
|
backgroundColor: configuration.theme.contentBackgroundColor
|
|
)
|
|
}
|
|
|
|
private func runLaunchAutomationIfNeeded() {
|
|
guard !didRunLaunchAutomation,
|
|
let launchAutomationPlan,
|
|
!books.isEmpty else {
|
|
return
|
|
}
|
|
didRunLaunchAutomation = true
|
|
|
|
guard let book = books.first(where: { $0.title.localizedCaseInsensitiveContains(launchAutomationPlan.bookTitleQuery) }) else {
|
|
print("[ReadViewDemo] automation skipped: missing book matching \(launchAutomationPlan.bookTitleQuery)")
|
|
return
|
|
}
|
|
|
|
var configuration = RDEPUBReaderConfiguration.default
|
|
if let displayType = launchAutomationPlan.displayType {
|
|
configuration.displayType = displayType
|
|
}
|
|
_ = openBook(book, configuration: configuration, automationPlan: launchAutomationPlan)
|
|
}
|
|
|
|
}
|
|
|
|
extension ViewController: UITableViewDataSource {
|
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
books.count
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
let cellIdentifier = "demo.book.cell"
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ??
|
|
UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
|
let book = books[indexPath.row]
|
|
|
|
cell.accessoryType = .disclosureIndicator
|
|
cell.textLabel?.text = book.title
|
|
cell.textLabel?.numberOfLines = 2
|
|
cell.detailTextLabel?.text = book.detail
|
|
cell.detailTextLabel?.textColor = .secondaryLabel
|
|
cell.accessibilityIdentifier = "demo.book.\(indexPath.row)"
|
|
return cell
|
|
}
|
|
}
|
|
|
|
extension ViewController: UITableViewDelegate {
|
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
tableView.deselectRow(at: indexPath, animated: true)
|
|
guard books.indices.contains(indexPath.row) else { return }
|
|
openBook(books[indexPath.row])
|
|
}
|
|
}
|