docs: 补充注释、修正过时文档、清理重复内容
源码注释: - 为 ~60 个 Swift 文件补充缺失的 doc comment(file header、类型、属性、方法) - 修正 4 处错误注释:翻页模式数量、搜索行为描述、手势识别器描述、悬空文档块 文档维护: - 删除重复文档:WXRead/读书EPUB阅读器实现架构.md(与微信读书版完全一致) - 合并重叠文档:阅读器规划.md → 阅读器功能开发计划.md(单一真值) - 修正过时内容:所有文档中"四种翻页模式"→"三种",移除 horizontalCoverScroll - 更新架构图:补齐 EPUBUI/ReaderController、Paging/、Typesetter/ 等子目录 - 更新 index.md 索引:新增开发计划和架构对比文档引用
This commit is contained in:
@@ -0,0 +1,753 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user