import UIKit import RDEpubReaderView 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: RDEpubReaderView.DisplayType? let pageNumber: Int? let displaySequence: [RDEpubReaderView.DisplayType] let stepDelay: TimeInterval nonisolated private static func parseDisplayType(_ rawValue: String) -> RDEpubReaderView.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? 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 = RDEpubPlainTextBookBuilder() 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(_ 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 ) -> RDEpubURLReaderController { let controller = RDEpubURLReaderController(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]) } }