feat: complete large-book pagination runtime and UI coverage

This commit is contained in:
shenlei 2026-06-03 17:47:16 +08:00
parent 584976ac5f
commit feb05eaf87
53 changed files with 6307 additions and 5126 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351

View File

@ -27,9 +27,15 @@
"ZIPFoundation": [
"~> 0.9"
],
"DTCoreText": [],
"SnapKit": [],
"SSAlertSwift": []
"DTCoreText": [
],
"SnapKit": [
],
"SSAlertSwift": [
]
},
"requires_arc": true,
"swift_version": "5.10"

View File

@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,11 @@
objects = {
/* Begin PBXBuildFile section */
1A2B3C4D00000001AABBCC01 /* BookmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */; };
1A2B3C4D00000003AABBCC01 /* TableOfContentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */; };
1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */; };
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */; };
1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */; };
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
@ -16,11 +21,6 @@
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
1A2B3C4D00000001AABBCC01 /* BookmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */; };
1A2B3C4D00000003AABBCC01 /* TableOfContentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */; };
1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */; };
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */; };
1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -35,6 +35,11 @@
/* Begin PBXFileReference section */
00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccessibilityIdentifiers.swift; sourceTree = "<group>"; };
1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookmarkTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TableOfContentsTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsExtendedTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PageNavigationTests.swift; sourceTree = "<group>"; };
1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationExtendedTests.swift; sourceTree = "<group>"; };
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = "<group>"; };
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = "<group>"; };
3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = "<group>"; };
@ -47,11 +52,6 @@
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookmarkTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TableOfContentsTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsExtendedTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PageNavigationTests.swift; sourceTree = "<group>"; };
1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationExtendedTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -364,6 +364,7 @@
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
@ -388,6 +389,7 @@
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;

View File

@ -62,76 +62,6 @@ final class ViewController: UIViewController {
}
}
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
@ -163,15 +93,9 @@ final class ViewController: UIViewController {
}()
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
@ -212,7 +136,6 @@ final class ViewController: UIViewController {
books = discoverBooks()
tableView.reloadData()
updateChrome()
validateSampleBooks()
}
private func updateChrome() {
@ -220,14 +143,7 @@ final class ViewController: UIViewController {
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")
statusLabel.text = "ReadViewSDK Demo\n已发现 \(books.count) 本本地图书,点击即可进入阅读器"
tableView.backgroundView = nil
}
}
@ -271,379 +187,6 @@ final class ViewController: UIViewController {
}
}
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,
@ -683,27 +226,6 @@ final class ViewController: UIViewController {
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,

View File

@ -0,0 +1,71 @@
import XCTest
struct DemoReaderState {
let rawValue: String
let fields: [String: String]
init(rawValue: String) {
self.rawValue = rawValue
var parsed: [String: String] = [:]
let separators = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ";"))
for token in rawValue.components(separatedBy: separators) where !token.isEmpty {
guard let delimiterIndex = token.firstIndex(of: "=") else { continue }
let key = String(token[..<delimiterIndex])
let value = String(token[token.index(after: delimiterIndex)...])
parsed[key] = value
}
fields = parsed
}
subscript(key: String) -> String? {
fields[key]
}
var isOpened: Bool { fields["reader"] == "opened" }
var page: Int? { fields["page"].flatMap(Int.init) }
var display: String? { fields["display"] }
var toolbar: String? { fields["toolbar"] }
var highlights: Int? { fields["highlights"].flatMap(Int.init) }
var selection: Int? { fields["selection"].flatMap(Int.init) }
var href: String? { fields["href"] }
var progression: Double? { fields["progression"].flatMap(Double.init) }
var mode: String? { fields["mode"] }
var pagination: String? { fields["pagination"] }
var knownPages: Int? { fields["knownPages"].flatMap(Int.init) }
var knownChapters: Int? { fields["knownChapters"].flatMap(Int.init) }
var buildableChapters: Int? { fields["buildableChapters"].flatMap(Int.init) }
var avoidWidows: Int? { fields["avoidWidows"].flatMap(Int.init) }
var avoidOrphans: Int? { fields["avoidOrphans"].flatMap(Int.init) }
}
extension XCUIApplication {
func currentDemoReaderState() -> DemoReaderState? {
let state = staticTexts[IDs.demoReaderState]
guard state.exists else { return nil }
return DemoReaderState(rawValue: state.label)
}
@discardableResult
func waitForDemoReaderState(
timeout: TimeInterval = 8,
description: String,
where predicate: (DemoReaderState) -> Bool
) -> DemoReaderState {
let stateElement = staticTexts[IDs.demoReaderState]
let deadline = Date().addingTimeInterval(timeout)
var lastState = DemoReaderState(rawValue: "<missing>")
while Date() < deadline {
if stateElement.exists {
lastState = DemoReaderState(rawValue: stateElement.label)
if predicate(lastState) {
return lastState
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTFail("阅读器状态未满足条件: \(description),当前:\(lastState.rawValue)")
return lastState
}
}

View File

@ -25,12 +25,15 @@ extension XCUIApplication {
func waitForReader(timeout: TimeInterval = 12) -> XCUIElement {
let state = staticTexts[IDs.demoReaderState]
XCTAssertTrue(state.waitForExistence(timeout: timeout), "阅读器状态标签未出现")
XCTAssertTrue(state.label.contains("reader=opened"), "阅读器未进入 opened 状态:\(state.label)")
let parsed = DemoReaderState(rawValue: state.label)
XCTAssertTrue(parsed.isOpened, "阅读器未进入 opened 状态:\(state.label)")
return state
}
func waitForReaderPage(_ pageNumber: Int, timeout: TimeInterval = 12) {
waitForReaderState(containing: "page=\(pageNumber)", timeout: timeout)
_ = waitForDemoReaderState(timeout: timeout, description: "page=\(pageNumber)") { state in
state.page == pageNumber
}
}
func showReaderChromeIfNeeded() {

View File

@ -0,0 +1,95 @@
import XCTest
final class BookmarkManagementTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testBookmarksPanelShowsBookmarkAfterAdding() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
let bookmarkButton = app.buttons[IDs.readerBookmark]
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
bookmarkButton.tap()
app.waitForReaderState(containing: "bookmarks=1", timeout: 5)
app.showReaderChromeIfNeeded()
let bookmarksButton = app.buttons[IDs.readerBookmarks]
XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 3), "书签列表按钮不存在")
bookmarksButton.tap()
let bookmarksTable = app.tables[IDs.readerBookmarksTable]
XCTAssertTrue(bookmarksTable.waitForExistence(timeout: 5), "书签表格未出现")
XCTAssertTrue(waitForCellCount(in: bookmarksTable, minimum: 1, timeout: 5), "添加书签后面板应有至少 1 行数据")
}
func testBookmarksPanelEmptyState() throws {
app.launchAndOpenSampleBook(resetsReaderState: true)
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
let bookmarksButton = app.buttons[IDs.readerBookmarks]
XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 3), "书签列表按钮不存在")
bookmarksButton.tap()
XCTAssertTrue(app.tables[IDs.readerBookmarksTable].waitForExistence(timeout: 5), "书签面板未出现")
XCTAssertTrue(app.staticTexts[IDs.readerBookmarksEmptyLabel].waitForExistence(timeout: 5), "空状态 label 应存在")
XCTAssertTrue(app.staticTexts[IDs.readerBookmarksEmptyLabel].label.contains("暂无"), "空状态应显示暂无书签")
}
func testDeleteBookmarkFromPanel() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerBookmark].tap()
app.waitForReaderState(containing: "bookmarks=1", timeout: 5)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerBookmarks].tap()
let bookmarksTable = app.tables[IDs.readerBookmarksTable]
XCTAssertTrue(bookmarksTable.waitForExistence(timeout: 5))
XCTAssertTrue(waitForCellCount(in: bookmarksTable, minimum: 1, timeout: 5))
bookmarksTable.cells.element(boundBy: 0).tap()
let deleteButton = app.buttons["删除书签"]
if deleteButton.waitForExistence(timeout: 3) { deleteButton.tap() }
XCTAssertTrue(app.staticTexts[IDs.readerBookmarksEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
}
func testBookmarkIconStateToggle() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
let bookmarkButton = app.buttons[IDs.readerBookmark]
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
bookmarkButton.tap()
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "添加书签后按钮应仍然存在")
bookmarkButton.tap()
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "移除书签后按钮应仍然存在")
}
private func waitForCellCount(
in table: XCUIElement,
minimum: Int,
timeout: TimeInterval
) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if table.cells.count >= minimum {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return table.cells.count >= minimum
}
}

View File

@ -0,0 +1,69 @@
import XCTest
final class ErrorAndEdgeCaseTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testVerticalScrollSwipeNavigation() throws {
app.launchAndOpenSampleBook(displayType: "verticalscroll")
app.waitForReader(timeout: 12)
let paging = app.collectionViews[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) { paging.swipeUp() }
app.waitForReaderState(containing: "reader=opened", timeout: 5)
}
func testOpenAndCloseMultipleTimes() throws {
for iteration in 1...3 {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
let backButton = app.buttons[IDs.readerBack]
XCTAssertTrue(backButton.waitForExistence(timeout: 3), "返回按钮不存在")
backButton.tap()
let booksTable = app.tables[IDs.demoBooksTable]
XCTAssertTrue(booksTable.waitForExistence(timeout: 5), "书列表未出现")
}
}
func testRapidPageNavigation() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.waitForReaderPage(1, timeout: 5)
let paging = app.collectionViews[IDs.readerPaging]
guard paging.waitForExistence(timeout: 5) else { throw XCTSkip("分页视图不存在") }
for _ in 1...5 { paging.swipeLeft() }
app.waitForReaderState(containing: "reader=opened", timeout: 8)
}
func testSettingsPanelScrollReachesAllControls() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let settingsScroll = app.scrollViews[IDs.settingsScroll]
XCTAssertTrue(settingsScroll.waitForExistence(timeout: 5), "设置面板未出现")
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3))
XCTAssertTrue(app.buttons[IDs.settingsBrightness].waitForExistence(timeout: 3))
settingsScroll.swipeUp()
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 5))
settingsScroll.swipeUp()
XCTAssertTrue(app.buttons[IDs.settingsTheme(5)].waitForExistence(timeout: 5))
app.buttons[IDs.settingsDone].tap()
}
}

View File

@ -0,0 +1,142 @@
import XCTest
final class HighlightsManagementTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testHighlightsPanelOpens() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
guard selectionText.waitForExistence(timeout: 5) else {
throw XCTSkip("文本选区元素不存在")
}
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end)
let highlightMenuItem = app.menuItems["高亮"]
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
else if app.buttons["高亮"].waitForExistence(timeout: 2) { app.buttons["高亮"].tap() }
app.waitForReaderState(containing: "highlights=", timeout: 5)
app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerHighlights].waitForExistence(timeout: 3), "高亮列表按钮不存在")
app.buttons[IDs.readerHighlights].tap()
XCTAssertTrue(app.tables[IDs.readerHighlightsTable].waitForExistence(timeout: 5), "高亮管理面板未出现")
}
func testHighlightsPanelShowsCreatedHighlight() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end)
let highlightMenuItem = app.menuItems["高亮"]
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerHighlights].tap()
let highlightsTable = app.tables[IDs.readerHighlightsTable]
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5), "高亮表格未出现")
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5), "创建高亮后面板应至少有 1 行数据")
}
func testHighlightsPanelFilterAll() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end)
let highlightMenuItem = app.menuItems["高亮"]
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerHighlights].tap()
let filterControl = app.segmentedControls[IDs.readerHighlightsFilter]
XCTAssertTrue(filterControl.waitForExistence(timeout: 5), "筛选控件未出现")
filterControl.buttons.element(boundBy: 0).tap()
let highlightsTable = app.tables[IDs.readerHighlightsTable]
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 3))
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5), "筛选全部后应有高亮数据")
}
func testHighlightsPanelEmptyState() throws {
app.launchAndOpenSampleBook(resetsReaderState: true)
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
let highlightsButton = app.buttons[IDs.readerHighlights]
XCTAssertTrue(highlightsButton.waitForExistence(timeout: 3), "高亮列表按钮不存在")
highlightsButton.tap()
XCTAssertTrue(app.tables[IDs.readerHighlightsTable].waitForExistence(timeout: 5), "高亮管理面板未出现")
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "空状态 label 应存在")
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].label.contains("暂无"), "空状态应显示暂无标注")
}
func testDeleteHighlightFromPanel() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end)
let highlightMenuItem = app.menuItems["高亮"]
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerHighlights].tap()
let highlightsTable = app.tables[IDs.readerHighlightsTable]
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5))
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5))
highlightsTable.cells.element(boundBy: 0).tap()
let deleteButton = app.buttons["删除标注"].firstMatch
let deleteHighlightButton = app.buttons["删除高亮"].firstMatch
if deleteButton.waitForExistence(timeout: 3) {
deleteButton.tap()
} else if deleteHighlightButton.waitForExistence(timeout: 1) {
deleteHighlightButton.tap()
}
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
}
private func waitForCellCount(
in table: XCUIElement,
minimum: Int,
timeout: TimeInterval
) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if table.cells.count >= minimum {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return table.cells.count >= minimum
}
}

View File

@ -0,0 +1,104 @@
import XCTest
final class LargeBookOnDemandTests: XCTestCase {
private let app = XCUIApplication()
private let largeBookQuery = "凡人修仙传"
override func setUpWithError() throws {
continueAfterFailure = false
}
func testLargeBookInitialOpenIsReadableWhilePaginationIsPartial() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true)
app.waitForReader(timeout: 20)
let partialState = app.waitForDemoReaderState(timeout: 15, description: "大书首开进入局部分页") { state in
state.mode == "bookPageMap" && state.pagination == "partial" && (state.page ?? 0) >= 1
}
XCTAssertEqual(partialState.mode, "bookPageMap")
XCTAssertEqual(partialState.pagination, "partial")
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "首开局部分页阶段应已可阅读")
}
func testLargeBookBackgroundPaginationProgresses() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true)
app.waitForReader(timeout: 20)
let initialState = app.waitForDemoReaderState(timeout: 15, description: "获取初始分页进度") { state in
state.mode == "bookPageMap" && state.knownChapters != nil && state.buildableChapters != nil
}
let initialKnownChapters = initialState.knownChapters ?? 0
let initialKnownPages = initialState.knownPages ?? 0
let progressedState = app.waitForDemoReaderState(timeout: 45, description: "后台分页推进") { state in
guard state.mode == "bookPageMap" else { return false }
if state.pagination == "full" {
return true
}
return (state.knownChapters ?? 0) > initialKnownChapters || (state.knownPages ?? 0) > initialKnownPages
}
XCTAssertEqual(progressedState.mode, "bookPageMap")
XCTAssertTrue(
(progressedState.knownChapters ?? 0) >= initialKnownChapters ||
(progressedState.knownPages ?? 0) >= initialKnownPages
)
}
func testLargeBookSecondOpenRestoresFromCache() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 60, description: "首次打开完成全书缓存") { state in
state.mode == "bookPageMap" && state.pagination == "full" && (state.buildableChapters ?? 0) > 0
}
app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5), "返回按钮不存在")
app.buttons[IDs.readerBack].tap()
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5), "书架列表未出现")
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: false)
app.waitForReader(timeout: 15)
let reopenedState = app.waitForDemoReaderState(timeout: 10, description: "二开直接恢复完整缓存") { state in
state.mode == "bookPageMap" && state.pagination == "full" && (state.knownChapters ?? 0) == (state.buildableChapters ?? -1)
}
XCTAssertEqual(reopenedState.pagination, "full")
XCTAssertEqual(reopenedState.knownChapters, reopenedState.buildableChapters)
}
func testLargeBookContinuousPagingExtendsKnownPageMap() throws {
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, displayType: "scroll", resetsReaderState: true)
app.waitForReader(timeout: 20)
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 8)
let initialState = app.waitForDemoReaderState(timeout: 15, description: "初始局部分页") { state in
state.mode == "bookPageMap" && state.knownPages != nil && state.page != nil
}
let initialKnownPages = initialState.knownPages ?? 0
let initialPage = initialState.page ?? 0
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
for _ in 0..<4 {
paging.swipeLeft()
}
let extendedState = app.waitForDemoReaderState(timeout: 20, description: "连续翻页触发扩窗") { state in
guard state.mode == "bookPageMap" else { return false }
let pageAdvanced = (state.page ?? 0) > initialPage
let pageMapExtended = (state.knownPages ?? 0) > initialKnownPages || state.pagination == "full"
return pageAdvanced && pageMapExtended
}
XCTAssertTrue((extendedState.page ?? 0) > initialPage, "连续翻页后当前页应向后推进")
XCTAssertTrue(
(extendedState.knownPages ?? 0) > initialKnownPages || extendedState.pagination == "full",
"连续翻页后已知页图应扩窗或直接完成全量分页"
)
}
}

View File

@ -0,0 +1,75 @@
import XCTest
final class LocationPersistenceTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testReadingPositionRestoredOnReopen() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
_ = app.waitForDemoReaderState(timeout: 5, description: "存在页码") { $0.page != nil }
let paging = app.collectionViews[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) {
paging.swipeLeft()
}
let stateAfterSwipe = app.waitForDemoReaderState(timeout: 8, description: "翻页后页码变化") { state in
(state.page ?? 0) > 1
}
let pageAfterSwipe = stateAfterSwipe.page ?? 0
guard pageAfterSwipe > 1 else {
throw XCTSkip("当前翻页模式下未能翻到第 2 页")
}
let backButton = app.buttons[IDs.readerBack]
if backButton.waitForExistence(timeout: 3) { backButton.tap() }
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
let restoredState = app.waitForDemoReaderState(timeout: 8, description: "恢复阅读位置") { state in
state.page != nil
}
let restoredPage = restoredState.page ?? 0
XCTAssertEqual(restoredPage, pageAfterSwipe, "重新打开后应恢复到上次阅读页码")
}
func testResetStateLaunchesAtFirstPage() throws {
app.launchAndOpenSampleBook(resetsReaderState: true)
app.waitForReader(timeout: 12)
let state = app.waitForDemoReaderState(timeout: 5, description: "首开第 1 页") { state in
state.page != nil
}
let page = state.page ?? 0
XCTAssertEqual(page, 1, "重置状态后应从第 1 页开始")
}
func testPositionSurvivesFontChange() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
let paging = app.collectionViews[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) { paging.swipeLeft() }
app.waitForReaderState(containing: "page=", timeout: 5)
app.showReaderChromeIfNeeded()
if app.buttons[IDs.readerSettings].waitForExistence(timeout: 3) { app.buttons[IDs.readerSettings].tap() }
if app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 5) { app.buttons[IDs.settingsFontIncrease].tap() }
if app.buttons[IDs.settingsDone].waitForExistence(timeout: 3) { app.buttons[IDs.settingsDone].tap() }
let backButton = app.buttons[IDs.readerBack]
if backButton.waitForExistence(timeout: 3) { backButton.tap() }
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.waitForReaderState(containing: "reader=opened", timeout: 8)
}
}

View File

@ -41,15 +41,14 @@ final class PageNavigationTests: XCTestCase {
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5))
let state = app.staticTexts[IDs.demoReaderState]
let beforeLabel = state.label
let beforeState = app.waitForDemoReaderState(timeout: 5, description: "滑动前页码") { $0.page != nil }
let beforePage = beforeState.page ?? 0
paging.swipeLeft()
let deadline = Date().addingTimeInterval(5)
while Date() < deadline {
if state.exists && state.label != beforeLabel { break }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
let afterState = app.waitForDemoReaderState(timeout: 8, description: "水平滑动后页码变化") { state in
guard let page = state.page else { return false }
return page != beforePage
}
XCTAssertNotEqual(beforeLabel, state.label, "水平滑动后页码未变化")
XCTAssertNotEqual(beforePage, afterState.page, "水平滑动后页码未变化")
}
}

View File

@ -0,0 +1,76 @@
import XCTest
final class SelectionMenuTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testSelectionMenuShowsCopyOption() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end)
XCTAssertTrue(app.menuItems["拷贝"].waitForExistence(timeout: 3), "选区菜单应包含拷贝选项")
}
func testSelectionMenuShowsAnnotateOption() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end)
XCTAssertTrue(app.menuItems["批注"].waitForExistence(timeout: 3), "选区菜单应包含批注选项")
}
func testTapBlankAreaClearsSelection() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end)
XCTAssertTrue(app.menuItems["拷贝"].waitForExistence(timeout: 3), "选区菜单应出现")
let contentArea = app.otherElements[IDs.readerContentView].firstMatch
if contentArea.waitForExistence(timeout: 3) {
contentArea.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap()
}
let menuGone = !app.menuItems["拷贝"].waitForExistence(timeout: 2)
XCTAssertTrue(menuGone, "点击空白区域后选区菜单应消失")
}
func testCopySelectedText() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
start.press(forDuration: 0.8, thenDragTo: end)
let copyMenuItem = app.menuItems["拷贝"]
if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() }
app.waitForReaderState(containing: "reader=opened", timeout: 5)
}
}

View File

@ -0,0 +1,144 @@
import XCTest
final class SettingsEffectTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testFontSizeChangeUpdatesContent() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.waitForReaderState(containing: "page=", timeout: 5)
app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3), "设置按钮不存在")
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现")
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3), "字号增大按钮不存在")
app.buttons[IDs.settingsFontIncrease].tap()
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
app.buttons[IDs.settingsDone].tap()
app.waitForReaderState(containing: "reader=opened", timeout: 8)
}
func testThemeSwitchChangesBackground() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现")
app.scrollViews[IDs.settingsScroll].swipeUp()
let darkThemeButton = app.buttons[IDs.settingsTheme(5)]
if darkThemeButton.waitForExistence(timeout: 3) { darkThemeButton.tap() }
app.buttons[IDs.settingsDone].tap()
let contentView = app.otherElements[IDs.readerContentView]
XCTAssertTrue(contentView.waitForExistence(timeout: 5), "主题切换后内容区应存在")
}
func testColumnCountChangeUpdatesLayout() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
app.scrollViews[IDs.settingsScroll].swipeUp()
let columnsControl = app.segmentedControls[IDs.settingsColumns]
if columnsControl.waitForExistence(timeout: 3) && columnsControl.buttons.count > 1 {
columnsControl.buttons.element(boundBy: 1).tap()
}
app.buttons[IDs.settingsDone].tap()
app.waitForReaderState(containing: "reader=opened", timeout: 8)
}
func testLineHeightChangeUpdatesContent() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
let lineHeightControl = app.segmentedControls[IDs.settingsLineHeight]
if lineHeightControl.waitForExistence(timeout: 3) && lineHeightControl.buttons.count > 1 {
lineHeightControl.buttons.element(boundBy: 1).tap()
}
app.buttons[IDs.settingsDone].tap()
app.waitForReaderState(containing: "reader=opened", timeout: 8)
}
func testDisplayTypeChangeRePaginates() throws {
app.launchAndOpenSampleBook(displayType: "horizontalscroll")
app.waitForReader(timeout: 12)
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
let displayTypeControl = app.segmentedControls[IDs.settingsDisplayType]
if displayTypeControl.waitForExistence(timeout: 3) && displayTypeControl.buttons.count > 0 {
displayTypeControl.buttons.element(boundBy: 0).tap()
}
app.buttons[IDs.settingsDone].tap()
app.waitForReaderState(containing: "display=", timeout: 8)
}
func testDefaultBodyPaginationDoesNotEnableWidowOrphanCompaction() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
let state = app.waitForDemoReaderState(timeout: 8, description: "默认正文分页策略") { state in
state.avoidWidows != nil && state.avoidOrphans != nil
}
XCTAssertEqual(state.avoidWidows, 0, "默认正文分页不应启用 widow compaction避免页尾明显留白")
XCTAssertEqual(state.avoidOrphans, 0, "默认正文分页不应启用 orphan compaction避免提前换页")
}
func testSettingsPersistAfterReopen() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 5))
let fontValueLabel = app.staticTexts[IDs.settingsFontValue]
XCTAssertTrue(fontValueLabel.waitForExistence(timeout: 3))
let originalFontValue = fontValueLabel.label
app.buttons[IDs.settingsFontIncrease].tap()
let newFontValue = fontValueLabel.label
XCTAssertNotEqual(newFontValue, originalFontValue, "点击增大后字号 label 应变化")
app.buttons[IDs.settingsDone].tap()
app.buttons[IDs.readerBack].tap()
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let persistedFontValue = app.staticTexts[IDs.settingsFontValue]
if persistedFontValue.waitForExistence(timeout: 5) {
XCTAssertEqual(persistedFontValue.label, newFontValue, "重新打开后字号应持久化为之前的值")
}
app.buttons[IDs.settingsDone].tap()
}
}

View File

@ -0,0 +1,63 @@
import XCTest
final class TOCInteractionTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testTOCPanelShowsChapterList() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
let tocButton = app.buttons[IDs.readerToc]
XCTAssertTrue(tocButton.waitForExistence(timeout: 3), "目录按钮不存在")
tocButton.tap()
let tocTable = app.tables[IDs.readerTocTable]
XCTAssertTrue(tocTable.waitForExistence(timeout: 5), "目录表格未出现")
XCTAssertTrue(tocTable.cells.count > 0, "目录应至少有 1 个章节")
}
func testTOCNavigatesToChapter() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
let initialState = app.waitForDemoReaderState(timeout: 5, description: "初始页码") { state in
state.page != nil
}
let initialPage = initialState.page ?? 0
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerToc].tap()
let tocTable = app.tables[IDs.readerTocTable]
XCTAssertTrue(tocTable.waitForExistence(timeout: 5), "目录表格未出现")
if tocTable.cells.count > 1 {
tocTable.cells.element(boundBy: 1).tap()
let updatedState = app.waitForDemoReaderState(timeout: 8, description: "目录跳转后页码变化") { state in
guard let page = state.page else { return false }
return page != initialPage
}
let updatedPage = updatedState.page ?? 0
XCTAssertNotEqual(updatedPage, initialPage, "目录跳转后页码应变化")
} else {
tocTable.cells.element(boundBy: 0).tap()
app.waitForReaderState(containing: "reader=opened", timeout: 8)
}
}
func testTOCEmptyHandling() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerToc].tap()
XCTAssertTrue(app.otherElements[IDs.readerTocPanel].waitForExistence(timeout: 5), "目录面板应可打开")
}
}

View File

@ -0,0 +1,71 @@
import XCTest
final class ToolbarStateTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testTapContentHidesToolbar() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
XCTAssertTrue(app.otherElements[IDs.readerTopToolbar].waitForExistence(timeout: 3), "顶部工具栏应可见")
XCTAssertTrue(app.otherElements[IDs.readerBottomToolbar].waitForExistence(timeout: 3), "底部工具栏应可见")
app.hideReaderChromeIfNeeded()
let backButton = app.buttons[IDs.readerBack]
let hidden = !backButton.waitForExistence(timeout: 2)
XCTAssertTrue(hidden, "点击内容区后工具栏应隐藏")
}
func testToolbarShowHideToggle() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 3))
app.hideReaderChromeIfNeeded()
app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 3))
app.waitForReaderState(containing: "reader=opened", timeout: 5)
}
func testAddHighlightButtonDisabledWithoutSelection() throws {
app.launchAndOpenSampleBook()
app.waitForReader(timeout: 12)
app.showReaderChromeIfNeeded()
let addHighlightButton = app.buttons[IDs.readerAddHighlight]
XCTAssertTrue(addHighlightButton.waitForExistence(timeout: 3), "标注按钮应存在")
let beforeState = app.waitForDemoReaderState(timeout: 3, description: "初始高亮数") { $0.highlights != nil }
let highlightsBefore = beforeState.highlights ?? 0
addHighlightButton.tap()
let afterState = app.waitForDemoReaderState(timeout: 3, description: "点击后的高亮数") { $0.highlights != nil }
let highlightsAfter = afterState.highlights ?? 0
XCTAssertEqual(highlightsAfter, highlightsBefore, "无选区时点击标注按钮不应创建标注")
}
func testPageCurlSwipeNavigation() throws {
app.launchAndOpenSampleBook(displayType: "pagecurl")
app.waitForReader(timeout: 12)
app.waitForReaderPage(1, timeout: 5)
let paging = app.collectionViews[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) { paging.swipeLeft() }
app.waitForReaderState(containing: "reader=opened", timeout: 5)
}
}

View File

@ -146,6 +146,10 @@ struct RDEPUBChapterPageCounter {
var frames: [RDEPUBTextLayoutFrame] = []
var location = 0
let pageRect = dtLayoutRect
let isDebug = ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug")
if isDebug {
print("[PAGINATION-DEBUG] pageRect=\(pageRect) totalLength=\(attributedString.length)")
}
while location < attributedString.length {
guard let layoutFrame = layouter.layoutFrame(with: pageRect, range: NSRange(location: location, length: 0)) else {
@ -165,6 +169,16 @@ struct RDEPUBChapterPageCounter {
proposed: lineAdjusted,
lineRanges: lineRanges
)
if isDebug {
let pageCount = frames.count + 1
let previewText = (attributedString.string as NSString).substring(with: NSRange(location: location, length: min(20, attributedString.length - location)))
let avoidRemoved = proposedRange.length - avoidAdjusted.length
let kwNextRemoved = avoidAdjusted.length - lineAdjusted.length
let widowRemoved = lineAdjusted.length - widowOrphanAdjusted.length
let totalRemoved = proposedRange.length - widowOrphanAdjusted.length
print("[PAGINATION-DEBUG] page#\(pageCount) loc=\(location) proposed=\(proposedRange.length) avoid(-\(avoidRemoved)) kwNext(-\(kwNextRemoved)) widowOrphan(-\(widowRemoved)) total(-\(totalRemoved)) lastLine=\"\(previewText)\"")
}
let effectiveLineRanges = lineRangesWithinRange(lineRanges, range: widowOrphanAdjusted)
let adjusted = pageBreakPolicy.adjustedRange(
from: widowOrphanAdjusted,

View File

@ -146,6 +146,12 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
let endLocation = lastValidLine.location + lastValidLine.length
let adjustedLength = endLocation - proposed.location
guard adjustedLength > 0 else { return proposed }
if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40)))
print("[PAGINATION-DEBUG] avoidPageBreakInside removed \(linesToRemove) lines: \"\(removedText)\"")
}
return NSRange(location: proposed.location, length: adjustedLength)
}
@ -180,6 +186,12 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
let endLocation = lastValidLine.location + lastValidLine.length
let adjustedLength = endLocation - proposed.location
guard adjustedLength > 0 else { return proposed }
if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
let removedText = (attributedString.string as NSString).substring(with: NSRange(location: endLocation, length: min(proposed.length - adjustedLength, 40)))
print("[PAGINATION-DEBUG] keepWithNext removed \(linesToRemove) lines: \"\(removedText)\"")
}
return NSRange(location: proposed.location, length: adjustedLength)
}
@ -317,6 +329,12 @@ struct RDEPUBCoreTextPageFrameFactory: RDEPUBPageFrameBuilding {
let keptLine = lineRanges[lineRanges.count - 2]
let adjustedLength = NSMaxRange(keptLine) - proposed.location
guard adjustedLength > 0 else { return nil }
if ProcessInfo.processInfo.arguments.contains("--demo-pagination-debug") {
let removedText = (attributedString.string as NSString).substring(with: NSRange(location: NSMaxRange(keptLine), length: min(proposed.length - adjustedLength, 40)))
print("[PAGINATION-DEBUG] widow control removed 1 line: \"\(removedText)\" paragraphRange=\(NSStringFromRange(paragraphRange))")
}
return NSRange(location: proposed.location, length: adjustedLength)
}

View File

@ -107,6 +107,31 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
/// EPUB
func pageNumber(for location: RDEPUBLocation) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
let spineIndex = readerContext.normalizedSpineIndex(for: location),
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
let localPageIndex: Int
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry)
localPageIndex = summary.pageRanges.firstIndex {
let range = $0.nsRange
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
} ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
} else {
localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
}
return bookPageMap.absolutePageIndex(
spineIndex: spineIndex,
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
).map { $0 + 1 }
}
if let textBook, let publication {
if let anchor = location.rangeAnchor?.start {
if let page = textBook.indexTable.pageNumber(for: anchor, in: textBook) {
@ -135,6 +160,47 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
///
func resolvedTextLocation(forPageNumber pageNumber: Int) -> RDEPUBLocation? {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
let chapterLength = max(resolvedPage.chapter.typesetAttributedString.length, 1)
let startOffset = resolvedPage.page.pageStartOffset
let endOffset = max(startOffset, resolvedPage.page.pageEndOffset)
let fragmentID = nearestFragmentID(
beforeOrAt: startOffset,
fragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets
)
let location = RDEPUBLocation(
bookIdentifier: currentBookIdentifier,
href: resolvedPage.page.href,
progression: Double(startOffset) / Double(chapterLength),
lastProgression: Double(endOffset) / Double(chapterLength),
fragment: fragmentID,
rangeAnchor: RDEPUBTextRangeAnchor(
start: RDEPUBTextAnchor(
fileIndex: resolvedPage.page.spineIndex,
row: 0,
column: 0,
chapterOffset: startOffset,
fragmentID: fragmentID
),
end: RDEPUBTextAnchor(
fileIndex: resolvedPage.page.spineIndex,
row: 0,
column: 0,
chapterOffset: endOffset,
fragmentID: fragmentID
)
)
)
if let publication {
return publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
}
return location
}
guard let textBook,
let publication,
let page = textBook.page(at: pageNumber) else {
@ -154,6 +220,17 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
/// spine
func synchronizeTextReadingState(pageNumber: Int, location: RDEPUBLocation) {
if let resolvedPage = resolvedRuntimePage(forPageNumber: pageNumber) {
readingSession?.updateReadingContext(
pageNumber: pageNumber,
location: location,
spineIndex: resolvedPage.page.spineIndex,
chapterIndex: resolvedPage.chapterIndex,
bookIdentifier: currentBookIdentifier
)
return
}
guard let textBook,
let page = textBook.page(at: pageNumber) else {
readingSession?.transition(to: .idle)
@ -184,4 +261,37 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
}
return (pages, chapters)
}
private func resolvedRuntimePage(forPageNumber pageNumber: Int) -> RDEPUBResolvedPage? {
runtime.pageResolver.resolvePage(absolutePageIndex: pageNumber - 1)
}
func chapterOffset(for location: RDEPUBLocation, fallbackEntry: RDEPUBBookPageMapEntry) -> Int {
if let anchor = location.rangeAnchor?.start {
return anchor.chapterOffset
}
if let fragment = location.fragment,
let offset = fallbackEntry.fragmentOffsets[fragment] {
return offset
}
return 0
}
func fallbackLocalPageIndex(for location: RDEPUBLocation, pageCount: Int) -> Int {
guard pageCount > 1 else { return 0 }
return min(
pageCount - 1,
max(0, Int(round(location.navigationProgression * Double(pageCount - 1))))
)
}
private func nearestFragmentID(beforeOrAt offset: Int, fragmentOffsets: [String: Int]) -> String? {
var bestID: String?
var bestOffset = Int.min
for (fragmentID, fragmentOffset) in fragmentOffsets where fragmentOffset <= offset && fragmentOffset > bestOffset {
bestOffset = fragmentOffset
bestID = fragmentID
}
return bestID
}
}

View File

@ -15,11 +15,28 @@ import UIKit
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
///
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
textBook?.pages.count ?? activePages.count
readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
}
/// 退 Web
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
if let resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) {
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
contentView.configure(
page: resolvedPage.page,
pageNumber: pageNum + 1,
totalPages: pageCountOfReaderView(readerView: readerView),
configuration: configuration,
highlights: textHighlights(for: resolvedPage.page),
searchState: searchState
)
return contentView
}
}
if let textBook, let page = textBook.page(at: pageNum + 1) {
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
contentView.delegate = self
@ -53,7 +70,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
/// Web
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
textBook == nil
(textBook == nil && readerContext.bookPageMap == nil)
? NSStringFromClass(RDEPUBWebContentView.self)
: NSStringFromClass(RDEPUBTextContentView.self)
}
@ -94,13 +111,17 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
readerContext.markUserNavigationActivity()
updateCurrentSelection(nil)
reconcileTextPaginationSizeIfNeeded(for: pageNum)
if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: pageNum + 1)
}
let totalPages = pageCountOfReaderView(readerView: readerView)
if totalPages > 0, pageNum == totalPages - 1 {
delegate?.epubReaderDidReachEnd(self)
}
if textBook != nil,
if (textBook != nil || readerContext.bookPageMap != nil),
let location = resolvedTextLocation(forPageNumber: pageNum + 1) {
persist(location: location)
synchronizeTextReadingState(pageNumber: pageNum + 1, location: location)
@ -123,7 +144,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
///
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
guard textBook != nil,
guard textBook != nil || readerContext.bookPageMap != nil,
!isRepaginating,
!isReconcilingTextPaginationSize,
pageNum >= 0,
@ -147,7 +168,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.isReconcilingTextPaginationSize = false
guard self.textBook != nil, !self.isRepaginating else { return }
guard (self.textBook != nil || self.readerContext.bookPageMap != nil), !self.isRepaginating else { return }
self.repaginatePreservingCurrentLocation()
}
}

View File

@ -50,8 +50,18 @@ extension RDEPUBReaderController {
/// 访
/// - Returns: nil
public func nativeTextSemanticSummary() -> String? {
guard let textBook,
let page = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first else {
let resolvedPage: RDEPUBTextPage?
if let textBook {
resolvedPage = textBook.page(at: max(readerView.currentPage + 1, 1)) ?? textBook.pages.first
} else if readerContext.bookPageMap != nil {
let absolutePageIndex = max(readerView.currentPage, 0)
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: absolutePageIndex + 1)
resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: absolutePageIndex)?.page
} else {
resolvedPage = nil
}
guard let page = resolvedPage else {
return nil
}
@ -242,4 +252,3 @@ extension RDEPUBReaderController {
runtime.clearSearch()
}
}

View File

@ -233,6 +233,7 @@ extension RDEPUBReaderController {
hideLoading()
readerContext.clearActiveSnapshot()
textBook = nil
readerContext.bookPageMap = nil
readerView.reloadData()
errorLabel.text = error.localizedDescription
errorLabel.isHidden = false

View File

@ -13,18 +13,12 @@ import Foundation
extension RDEPUBReaderController {
/// href
func resolvedCurrentTableOfContentsItem() -> RDEPUBReaderTableOfContentsItem? {
let items = flattenedTableOfContents
let items = flattenedTableOfContentsItems(
from: publication?.tableOfContents ?? [],
includePageNumbers: false
)
guard !items.isEmpty else { return nil }
let currentPageNumber = max(readerView.currentPage + 1, 1)
let pageAnchoredMatch = items.last { item in
guard let pageNumber = item.pageNumber else { return false }
return pageNumber <= currentPageNumber
}
if let pageAnchoredMatch {
return pageAnchoredMatch
}
guard let publication,
let currentLocation = currentVisibleLocation(),
let normalizedCurrentHref = publication.resourceResolver.normalizedHref(currentLocation.href) else {
@ -60,28 +54,12 @@ extension RDEPUBReaderController {
/// 线
func flattenedTableOfContentsItems(
from items: [EPUBTableOfContentsItem],
depth: Int = 0
depth: Int = 0,
includePageNumbers: Bool = true
) -> [RDEPUBReaderTableOfContentsItem] {
items.flatMap { item in
let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
let pageNumber: Int?
if let textBook, let publication,
let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
bookIdentifier: currentBookIdentifier
) ?? location
pageNumber = chapterData.pageNumber(for: normalizedLocation)
?? textBook.pageNumber(
for: location,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
} else if let readingSession {
pageNumber = readingSession.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
} else {
pageNumber = nil
}
let pageNumber = includePageNumbers ? resolvedTableOfContentsPageNumber(for: location) : nil
let current = RDEPUBReaderTableOfContentsItem(
title: item.title,
@ -89,8 +67,53 @@ extension RDEPUBReaderController {
depth: depth,
pageNumber: pageNumber
)
return [current] + flattenedTableOfContentsItems(from: item.children, depth: depth + 1)
return [current] + flattenedTableOfContentsItems(
from: item.children,
depth: depth + 1,
includePageNumbers: includePageNumbers
)
}
}
}
private func resolvedTableOfContentsPageNumber(for location: RDEPUBLocation) -> Int? {
if let publication,
let bookPageMap = readerContext.bookPageMap,
let spineIndex = readerContext.normalizedSpineIndex(for: location),
let entry = bookPageMap.entry(forSpineIndex: spineIndex) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
bookIdentifier: currentBookIdentifier
) ?? location
let localPageIndex: Int
if let summary = readerContext.chapterSummary(forSpineIndex: spineIndex) {
let offset = chapterOffset(for: normalizedLocation, fallbackEntry: entry)
localPageIndex = summary.pageRanges.firstIndex {
let range = $0.nsRange
return offset >= range.location && offset <= max(range.location + range.length - 1, range.location)
} ?? fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
} else {
localPageIndex = fallbackLocalPageIndex(for: normalizedLocation, pageCount: entry.pageCount)
}
return bookPageMap.absolutePageIndex(
spineIndex: spineIndex,
localPageIndex: min(max(localPageIndex, 0), max(entry.pageCount - 1, 0))
).map { $0 + 1 }
}
if let textBook, let publication,
let chapterData = textBook.chapterData(for: location, resolver: publication.resourceResolver, bookIdentifier: currentBookIdentifier) {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
bookIdentifier: currentBookIdentifier
) ?? location
return chapterData.pageNumber(for: normalizedLocation)
?? textBook.pageNumber(
for: location,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
}
return readingSession?.pageIndex(for: location, bookIdentifier: currentBookIdentifier).map { $0 + 1 }
}
}

View File

@ -51,6 +51,8 @@ public final class RDURLReaderController: UIViewController {
private var isRetryingPendingDemoPage = false
private let maxPendingDemoPageAttempts = 24
private let pendingDemoPageRetryDelay: TimeInterval = 0.25
private var demoStateTimer: Timer?
private var lastEmittedDemoState = ""
///
/// - Parameters:
@ -81,7 +83,17 @@ public final class RDURLReaderController: UIViewController {
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
emitDemoState()
startDemoStateTimerIfNeeded()
refreshDemoState()
}
public override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
stopDemoStateTimer()
}
deinit {
stopDemoStateTimer()
}
/// Demo
@ -155,8 +167,8 @@ public final class RDURLReaderController: UIViewController {
edgeInsets: epubConfiguration.reflowableContentInsets,
numberOfColumns: 1,
columnGap: 20,
avoidOrphans: true,
avoidWidows: true,
avoidOrphans: false,
avoidWidows: false,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85
@ -205,15 +217,17 @@ public final class RDURLReaderController: UIViewController {
guard let readerController else { return false }
guard pageNumber > 0 else { return false }
let totalPages = readerController.textBook?.pages.count ?? readerController.activePages.count
let knownPages = readerController.readerContext.bookPageMap?.totalPages
?? readerController.textBook?.pages.count
?? readerController.activePages.count
let numPages = readerController.readerView.numberOfPages()
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): textBook.pages=\(totalPages), readerView.numberOfPages=\(numPages), currentPage=\(readerController.readerView.currentPage)")
guard pageNumber <= totalPages else { return false }
readerController.readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): after transition, currentPage=\(readerController.readerView.currentPage)")
emitDemoState(prefix: "page=\(pageNumber)")
return true
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): knownPages=\(knownPages), readerView.numberOfPages=\(numPages), currentPage=\(readerController.readerView.currentPage)")
let moved = readerController.go(toPageNumber: pageNumber, animated: animated)
print("[ReadViewDemo] performDemoPageNavigation(\(pageNumber)): moved=\(moved), after currentPage=\(readerController.readerView.currentPage)")
if moved {
emitDemoState(prefix: "page=\(pageNumber)")
}
return moved
}
private func queuePendingDemoPageNavigation(_ pageNumber: Int, animated: Bool) {
@ -288,19 +302,87 @@ public final class RDURLReaderController: UIViewController {
])
}
private func startDemoStateTimerIfNeeded() {
guard demoStateTimer == nil else { return }
demoStateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
self?.refreshDemoState(logIfChanged: false)
}
if let demoStateTimer {
RunLoop.main.add(demoStateTimer, forMode: .common)
}
}
private func stopDemoStateTimer() {
demoStateTimer?.invalidate()
demoStateTimer = nil
}
private func emitDemoState(prefix: String? = nil) {
refreshDemoState(logPrefix: prefix, logIfChanged: true)
}
private func refreshDemoState(logPrefix: String? = nil, logIfChanged: Bool = true) {
let page = readerController?.currentPageNumber.map(String.init) ?? "nil"
let display = readerController?.configuration.displayType.demoArgumentValue ?? epubConfiguration.displayType.demoArgumentValue
let toolbar = readerController?.readerView.isShowToolView == true ? "visible" : "hidden"
let highlights = readerController?.highlights.count ?? 0
let selection = readerController?.currentSelection == nil ? 0 : 1
let state = "reader=opened page=\(page) display=\(display) toolbar=\(toolbar) highlights=\(highlights) selection=\(selection)"
let location = readerController?.currentLocation
let href = encodedDemoLocationHref(location?.href)
let progression = location.map { String(format: "%.4f", $0.navigationProgression) } ?? "nil"
let mapSnapshot = demoPaginationSnapshot()
let layoutConfig = readerController?.readerContext.currentTextLayoutConfig(pageSize: currentTextPageSize())
let state = [
"reader=opened",
"page=\(page)",
"display=\(display)",
"toolbar=\(toolbar)",
"highlights=\(highlights)",
"selection=\(selection)",
"href=\(href)",
"progression=\(progression)",
"mode=\(mapSnapshot.mode)",
"pagination=\(mapSnapshot.phase)",
"knownPages=\(mapSnapshot.knownPages)",
"knownChapters=\(mapSnapshot.knownChapters)",
"buildableChapters=\(mapSnapshot.buildableChapters)",
"avoidWidows=\(layoutConfig?.avoidWidows == true ? 1 : 0)",
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)"
].joined(separator: " ")
demoStateLabel.text = state
if let prefix {
logDemoState(prefix: prefix)
} else {
if let logPrefix {
logDemoState(prefix: logPrefix)
} else if logIfChanged, state != lastEmittedDemoState {
print("[ReadViewDemo] automation \(state)")
}
lastEmittedDemoState = state
}
private func encodedDemoLocationHref(_ href: String?) -> String {
guard let href, !href.isEmpty else { return "nil" }
return href.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? href.replacingOccurrences(of: " ", with: "%20")
}
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
guard let readerController else {
return ("unavailable", "none", 0, 0, 0)
}
if let bookPageMap = readerController.readerContext.bookPageMap {
let buildableChapters = readerController.publication?.spine.filter {
$0.linear && ($0.mediaType.contains("html") || $0.mediaType.contains("xhtml"))
}.count ?? 0
let phase = buildableChapters > 0 && bookPageMap.totalChapters >= buildableChapters ? "full" : "partial"
return ("bookPageMap", phase, bookPageMap.totalPages, bookPageMap.totalChapters, buildableChapters)
}
if let textBook = readerController.textBook {
return ("textBook", "full", textBook.pages.count, textBook.chapters.count, textBook.chapters.count)
}
let snapshotChapters = readerController.activeChapters.count
let snapshotPages = readerController.activePages.count
return ("snapshot", snapshotPages > 0 ? "full" : "none", snapshotPages, snapshotChapters, snapshotChapters)
}
///

View File

@ -0,0 +1,39 @@
import Foundation
enum RDEPUBBackgroundTrace {
static func log(_ scope: String, _ message: String) {
let threadRole = Thread.isMainThread ? "main" : "bg"
let threadName = resolvedThreadName()
let queueLabel = resolvedQueueLabel()
print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)][thread=\(threadName)] \(message)")
}
static func measure<T>(_ scope: String, _ message: String, work: () throws -> T) rethrows -> T {
let startedAt = CFAbsoluteTimeGetCurrent()
log(scope, "START \(message)")
do {
let result = try work()
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
log(scope, "END \(message) elapsedMs=\(elapsedMs)")
return result
} catch {
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
log(scope, "FAIL \(message) elapsedMs=\(elapsedMs) error=\(error)")
throw error
}
}
private static func resolvedThreadName() -> String {
if let name = Thread.current.name, !name.isEmpty {
return name
}
if Thread.isMainThread {
return "main"
}
return String(describing: Unmanaged.passUnretained(Thread.current).toOpaque())
}
private static func resolvedQueueLabel() -> String {
String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown"
}
}

View File

@ -0,0 +1,126 @@
import Foundation
/// BookPageMap
/// NSAttributedString 100
struct RDEPUBBookPageMapEntry {
let spineIndex: Int
let href: String
let title: String
///
let pageCount: Int
/// 0
let absolutePageStart: Int
/// fragment ID
let fragmentOffsets: [String: Int]
}
///
/// 100 /1000 100KB
///
/// spineIndex
///
struct RDEPUBBookPageMap {
let entries: [RDEPUBBookPageMapEntry]
/// spineIndex
private let indexBySpine: [Int: Int] // spineIndex -> entries
///
let totalPages: Int
init(entries: [RDEPUBBookPageMapEntry]) {
self.entries = entries
var mapping: [Int: Int] = [:]
for (i, entry) in entries.enumerated() {
mapping[entry.spineIndex] = i
}
self.indexBySpine = mapping
self.totalPages = entries.last.map { $0.absolutePageStart + $0.pageCount } ?? 0
}
static let empty = RDEPUBBookPageMap(entries: [])
// MARK: -
/// spineIndex +
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? {
guard let idx = indexBySpine[spineIndex] else { return nil }
let entry = entries[idx]
guard localPageIndex >= 0, localPageIndex < entry.pageCount else { return nil }
return entry.absolutePageStart + localPageIndex
}
/// spineIndex
func spineIndex(forAbsolutePage absolutePage: Int) -> Int? {
guard absolutePage >= 0, absolutePage < totalPages else { return nil }
// entries absolutePageStart
var lo = 0, hi = entries.count
while lo < hi {
let mid = lo + (hi - lo) / 2
if entries[mid].absolutePageStart <= absolutePage {
lo = mid + 1
} else {
hi = mid
}
}
guard lo > 0 else { return nil }
return entries[lo - 1].spineIndex
}
///
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? {
guard let si = spineIndex(forAbsolutePage: absolutePage),
let idx = indexBySpine[si] else { return nil }
let entry = entries[idx]
let local = absolutePage - entry.absolutePageStart
guard local >= 0, local < entry.pageCount else { return nil }
return local
}
/// spineIndex
func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? {
guard let idx = indexBySpine[spineIndex] else { return nil }
return entries[idx]
}
/// spineIndex entries
func chapterIndex(forSpineIndex spineIndex: Int) -> Int? {
indexBySpine[spineIndex]
}
/// spineIndex
func pageCount(forSpineIndex spineIndex: Int) -> Int? {
entry(forSpineIndex: spineIndex)?.pageCount
}
///
var totalChapters: Int { entries.count }
// MARK: -
/// Builder pageCount BookPageMap
struct Builder {
private var items: [(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int])] = []
mutating func add(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int]) {
items.append((spineIndex, href, title, pageCount, fragmentOffsets))
}
func build() -> RDEPUBBookPageMap {
// spineIndex
let sorted = items.sorted { $0.spineIndex < $1.spineIndex }
var entries: [RDEPUBBookPageMapEntry] = []
var absolutePageStart = 0
for item in sorted {
entries.append(RDEPUBBookPageMapEntry(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: item.pageCount,
absolutePageStart: absolutePageStart,
fragmentOffsets: item.fragmentOffsets
))
absolutePageStart += item.pageCount
}
return RDEPUBBookPageMap(entries: entries)
}
}
}

View File

@ -0,0 +1,8 @@
import Foundation
struct RDEPUBChapterCacheKey: Hashable {
let bookID: String
let spineIndex: Int
let renderSignature: String
let chapterContentHash: String
}

View File

@ -0,0 +1,37 @@
import Foundation
final class RDEPUBChapterDataCache {
private var storage: [Int: RDEPUBRuntimeChapter] = [:]
private let lock = NSLock()
subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
get {
lock.lock()
defer { lock.unlock() }
return storage[spineIndex]
}
set {
lock.lock()
defer { lock.unlock() }
storage[spineIndex] = newValue
}
}
var storedSpineIndices: [Int] {
lock.lock()
defer { lock.unlock() }
return Array(storage.keys)
}
func remove(spineIndex: Int) {
lock.lock()
defer { lock.unlock() }
storage.removeValue(forKey: spineIndex)
}
func removeAll() {
lock.lock()
defer { lock.unlock() }
storage.removeAll()
}
}

View File

@ -0,0 +1,534 @@
import Foundation
final class RDEPUBChapterLoader {
private unowned let context: RDEPUBReaderContext
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
init(context: RDEPUBReaderContext) {
self.context = context
}
func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) {
summaryDiskCache = cache
}
// MARK: -
enum LoadPriority {
case navigation //
case prefetch // ±1 +
}
// MARK: -
/// chapterLoadQueue 线
func loadChapter(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore,
priority: LoadPriority = .navigation,
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
) {
// 1. 线 completion 线
if let cached = store.chapterData(for: spineIndex) {
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
DispatchQueue.main.async {
completion(.success(cached))
}
return
}
// 2-3. + pageCountCache
// contentHashForSpineIndex SHA256 + I/O 线
store.markBuilding(true)
store.chapterLoadQueue.async {
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)")
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
// pageCountCache
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
if diskSummary != nil {
RDEPUBBackgroundTrace.log("ChapterLoader", "磁盘摘要缓存命中 spine=\(spineIndex)")
} else {
RDEPUBBackgroundTrace.log("ChapterLoader", "缓存未命中 spine=\(spineIndex)")
}
} else {
diskSummary = nil
RDEPUBBackgroundTrace.log("ChapterLoader", "页数缓存命中 spine=\(spineIndex)")
}
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
do {
let chapter = try RDEPUBBackgroundTrace.measure(
"ChapterLoader",
"buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)"
) {
try self.buildChapter(
spineIndex: spineIndex,
availablePageRanges: availablePageRanges,
diskSummary: diskSummary
)
}
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)")
// 5.
store.insertChapter(chapter)
let pc = RDEPUBRuntimePageCount(
cacheKey: cacheKey,
spineIndex: spineIndex,
pageRanges: chapter.pageRanges,
pageCount: chapter.pages.count,
renderSignature: cacheKey.renderSignature
)
store.insertPageCount(pc, for: cacheKey)
// 6.
switch priority {
case .navigation:
// §14.4
let nextTarget = store.consumeNavigationTarget()
if let target = nextTarget, target != spineIndex {
//
store.markBuilding(false)
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion)
return
}
store.markBuilding(false)
DispatchQueue.main.async {
completion(.success(chapter))
}
case .prefetch:
//
//
store.removePrefetchTarget(spineIndex)
store.markBuilding(false)
DispatchQueue.main.async {
completion(.success(chapter))
}
}
} catch {
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
store.markBuilding(false)
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
}
// MARK: - legacy 使
///
/// - chapterLoadQueue WXRead
/// - RDEPUBTextBook
/// - 线 chapterLoadQueue
/// - store.assertNotOnChapterLoadQueue()
/// - 使
func loadChapterSynchronouslyForMigration(
spineIndex: Int,
store: RDEPUBChapterRuntimeStore?
) throws -> RDEPUBRuntimeChapter {
if let cached = store?.chapterData(for: spineIndex) {
return cached
}
guard let store else {
throw RDEPUBChapterLoadError.missingParser
}
store.assertNotOnChapterLoadQueue()
var result: Result<RDEPUBRuntimeChapter, Error>?
let semaphore = DispatchSemaphore(value: 0)
RDEPUBBackgroundTrace.log("ChapterLoader", "sync request spine=\(spineIndex)")
store.chapterLoadQueue.async {
do {
result = try RDEPUBBackgroundTrace.measure(
"ChapterLoader",
"sync buildChapter spine=\(spineIndex)"
) {
try autoreleasepool { () -> Result<RDEPUBRuntimeChapter, Error> in
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
let diskSummary: RDEPUBChapterSummary?
if precomputedPageRanges == nil {
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
} else {
diskSummary = nil
}
let chapter = try self.buildChapter(
spineIndex: spineIndex,
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
diskSummary: diskSummary
)
store.insertChapter(chapter)
let pageCount = RDEPUBRuntimePageCount(
cacheKey: cacheKey,
spineIndex: spineIndex,
pageRanges: chapter.pageRanges,
pageCount: chapter.pages.count,
renderSignature: cacheKey.renderSignature
)
store.insertPageCount(pageCount, for: cacheKey)
return .success(chapter)
}
}
} catch {
result = .failure(error)
}
semaphore.signal()
}
semaphore.wait()
return try result!.get()
}
// MARK: -
private func buildChapter(
spineIndex: Int,
availablePageRanges: [NSRange]?,
diskSummary: RDEPUBChapterSummary? = nil
) throws -> RDEPUBRuntimeChapter {
guard let parser = context.parser,
let publication = context.publication else {
throw RDEPUBChapterLoadError.missingParser
}
let pageSize = context.currentTextPageSize()
let style = context.currentTextRenderStyle()
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
if let pageRanges = availablePageRanges {
// ---- pageCountCache chapterSummaryDiskCache ----
RDEPUBBackgroundTrace.log("ChapterLoader", "轻量路径 spine=\(spineIndex) 缓存页数=\(pageRanges.count)")
return try buildChapterFromCachedPageRanges(
spineIndex: spineIndex,
pageRanges: pageRanges,
parser: parser,
publication: publication,
pageSize: pageSize,
style: style,
layoutConfig: layoutConfig,
diskSummary: diskSummary
)
}
// ---- + ----
RDEPUBBackgroundTrace.log("ChapterLoader", "完整路径 spine=\(spineIndex)")
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
guard let result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
) else {
throw RDEPUBChapterLoadError.emptyChapter(spineIndex: spineIndex)
}
return try assembleRuntimeChapter(
from: result.chapter,
spineIndex: spineIndex,
pageSize: pageSize,
layoutConfig: layoutConfig
)
}
// MARK: - pageRanges
private func buildChapterFromCachedPageRanges(
spineIndex: Int,
pageRanges: [NSRange],
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle,
layoutConfig: RDEPUBTextLayoutConfig,
diskSummary: RDEPUBChapterSummary? = nil
) throws -> RDEPUBRuntimeChapter {
let spineItem = publication.spine[spineIndex]
let href = spineItem.href
let title = spineItem.title ?? ""
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
// 1. HTML NSAttributedString
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: href,
title: title,
rawHTML: try requireHTMLString(parser, href: href),
baseURL: baseURL,
style: style,
resourceResolver: publication.resourceResolver,
pageSize: pageSize,
layoutConfig: layoutConfig
)
let renderer = context.resolvedTextRenderer()
let rendered = try renderer.renderChapter(request: request)
let typesetString = NSMutableAttributedString(attributedString: rendered.attributedString)
RDEPUBTextRendererSupport.normalizeReadingAttributes(
in: typesetString, style: style, layoutConfig: layoutConfig
)
// 2. metadata
// - diskSummary metadata
// - diskSummary pageCountCache attributedString
let metadataSource = diskSummary?.pageMetadataList
// 3. pageRanges pages CoreText
let pages = buildPagesFromRanges(
pageRanges: pageRanges,
typesetString: typesetString,
spineIndex: spineIndex,
href: href,
title: title,
metadataSource: metadataSource
)
// 4. layouter
let layouter = RDEPUBTextLayouter(
attributedString: typesetString,
pageSize: pageSize,
config: layoutConfig
)
// 5. chapterOffsetMap
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: rendered.fragmentOffsets,
pageStartOffsets: pages.map { $0.pageStartOffset },
pageEndOffsets: pages.map { $0.pageEndOffset }
)
return RDEPUBRuntimeChapter(
spineIndex: spineIndex,
href: href,
title: title,
sourceAttributedString: nil, // source
typesetAttributedString: typesetString,
layouter: layouter,
pageRanges: pageRanges,
pages: pages,
chapterOffsetMap: offsetMap
)
}
// MARK: - pageRanges RDEPUBTextPage
private func buildPagesFromRanges(
pageRanges: [NSRange],
typesetString: NSAttributedString,
spineIndex: Int,
href: String,
title: String,
metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? = nil
) -> [RDEPUBTextPage] {
let totalPageCount = pageRanges.count
return pageRanges.enumerated().map { (pageIndex, range) in
let pageContent = typesetString.attributedSubstring(from: range)
let metadata: RDEPUBTextPageMetadata
if let metaList = metadataSource, pageIndex < metaList.count {
// metadata
metadata = metaList[pageIndex].toPageMetadata()
} else {
// metadata attributedString
metadata = inferPageMetadata(
from: typesetString,
range: range,
isLastPage: pageIndex == totalPageCount - 1
)
}
return RDEPUBTextPage(
absolutePageIndex: -1,
chapterIndex: 0,
spineIndex: spineIndex,
href: href,
chapterTitle: title,
pageIndexInChapter: pageIndex,
totalPagesInChapter: totalPageCount,
chapterContent: typesetString,
content: pageContent,
contentRange: range,
pageStartOffset: range.location,
pageEndOffset: range.location + range.length - 1,
metadata: metadata
)
}
}
// MARK: - attributedString metadata
private func inferPageMetadata(
from string: NSAttributedString,
range: NSRange,
isLastPage: Bool
) -> RDEPUBTextPageMetadata {
var attachmentRanges: [NSRange] = []
var attachmentKinds: [RDEPUBTextAttachmentKind] = []
var blockKinds: [RDEPUBTextBlockKind] = []
var semanticHints: [RDEPUBTextSemanticHint] = []
var attachmentPlacements: [RDEPUBTextAttachmentPlacement] = []
var trailingFragmentID: String? = nil
string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in
if let rawValue = value as? String,
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue) {
attachmentRanges.append(attrRange)
attachmentKinds.append(kind)
}
}
string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in
if let rawValue = value as? String,
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
!blockKinds.contains(kind) {
blockKinds.append(kind)
}
}
string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in
if let rawValue = value as? String {
let hints = rawValue
.split(separator: ",")
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
for hint in hints where !semanticHints.contains(hint) {
semanticHints.append(hint)
}
}
}
string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in
if let rawValue = value as? String,
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
!attachmentPlacements.contains(placement) {
attachmentPlacements.append(placement)
}
}
string.enumerateAttribute(.rdPageFragmentID, in: range, options: [.reverse]) { value, _, stop in
if let fid = value as? String {
trailingFragmentID = fid
stop.pointee = true
}
}
return RDEPUBTextPageMetadata(
breakReason: isLastPage ? .chapterEnd : .frameLimit,
blockRange: nil,
attachmentRanges: attachmentRanges,
attachmentKinds: attachmentKinds,
blockKinds: blockKinds,
semanticHints: semanticHints,
attachmentPlacements: attachmentPlacements,
trailingFragmentID: trailingFragmentID,
diagnostics: []
)
}
// MARK: - RDEPUBRuntimeChapter
private func assembleRuntimeChapter(
from chapter: RDEPUBTextChapter,
spineIndex: Int,
pageSize: CGSize,
layoutConfig: RDEPUBTextLayoutConfig
) throws -> RDEPUBRuntimeChapter {
let layouter = RDEPUBTextLayouter(
attributedString: chapter.attributedContent,
pageSize: pageSize,
config: layoutConfig
)
let offsetMap = RDEPUBChapterOffsetMap(
fragmentOffsets: chapter.fragmentOffsets,
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
pageEndOffsets: chapter.pages.map { $0.pageEndOffset }
)
let pageRanges = chapter.pages.map { $0.contentRange }
// P2
let cacheKey = makeCacheKey(spineIndex: spineIndex)
let summary = RDEPUBChapterSummary(
pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: chapter.pages.map { .from($0.metadata) }
)
summaryDiskCache?.write(summary: summary, for: cacheKey)
return RDEPUBRuntimeChapter(
spineIndex: spineIndex,
href: chapter.href,
title: chapter.title,
sourceAttributedString: nil,
typesetAttributedString: chapter.attributedContent,
layouter: layouter,
pageRanges: pageRanges,
pages: chapter.pages,
chapterOffsetMap: offsetMap
)
}
// MARK: -
private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey {
let style = context.currentTextRenderStyle()
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
// renderSignature §8.2
let lineHeightMultiple = context.configuration.lineHeightMultiple
let renderSignature = [
style.font.fontName,
"\(style.font.pointSize)",
"\(lineHeightMultiple)",
"\(style.lineSpacing)",
layoutConfig.cacheSignature,
"\(RDEPUBChapterSummary.currentSchemaVersion)"
].joined(separator: "|")
let contentHash = contentHashForSpineIndex(spineIndex)
return RDEPUBChapterCacheKey(
bookID: context.currentBookIdentifier ?? "",
spineIndex: spineIndex,
renderSignature: renderSignature,
chapterContentHash: contentHash
)
}
private func contentHashForSpineIndex(_ spineIndex: Int) -> String {
guard let parser = context.parser,
let publication = context.publication else { return "" }
let href = publication.spine[spineIndex].href
guard let html = parser.htmlString(forRelativePath: href) else { return "" }
return html.sha256Hex
}
private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
guard let html = parser.htmlString(forRelativePath: href) else {
throw RDEPUBChapterLoadError.emptyChapterHref(href)
}
return html
}
}
enum RDEPUBChapterLoadError: LocalizedError {
case missingParser
case emptyChapter(spineIndex: Int)
case emptyChapterHref(String)
var errorDescription: String? {
switch self {
case .missingParser:
return "章节加载失败:缺少解析上下文。"
case .emptyChapter(let spineIndex):
return "章节加载失败:第 \(spineIndex) 章无法生成分页内容。"
case .emptyChapterHref(let href):
return "章节加载失败:未找到章节资源 \(href)"
}
}
}

View File

@ -0,0 +1,33 @@
import Foundation
///
/// RDEPUBLocation progression
public struct RDEPUBChapterLocation: Codable, Equatable {
/// spine
public var spineIndex: Int
/// 0-based
public var chapterOffset: Int
/// HTML fragment ID #section1
public var fragmentID: String?
/// progressionfragmentID nil
public var progressionInChapter: Double?
/// schema 1=, 2=
public var schemaVersion: Int
public init(
spineIndex: Int,
chapterOffset: Int,
fragmentID: String? = nil,
progressionInChapter: Double? = nil,
schemaVersion: Int = 2
) {
self.spineIndex = spineIndex
self.chapterOffset = chapterOffset
self.fragmentID = fragmentID.flatMap { $0.isEmpty ? nil : $0 }
self.progressionInChapter = progressionInChapter
self.schemaVersion = schemaVersion
}
/// schemaVersion == 1 chapterOffset progression
var isFallbackEstimate: Bool { schemaVersion == 1 }
}

View File

@ -0,0 +1,22 @@
import Foundation
struct RDEPUBChapterOffsetMap {
let fragmentOffsets: [String: Int]
let pageStartOffsets: [Int]
let pageEndOffsets: [Int]
/// fragmentID ->
func chapterOffset(forFragmentID fragmentID: String) -> Int? {
return fragmentOffsets[fragmentID]
}
/// -> 0
func pageIndex(forChapterOffset offset: Int) -> Int? {
for i in 0..<pageStartOffsets.count {
if offset >= pageStartOffsets[i] && offset <= pageEndOffsets[i] {
return i
}
}
return nil
}
}

View File

@ -0,0 +1,186 @@
import UIKit
final class RDEPUBChapterRuntimeStore {
// MARK: -
/// WXRead chapterDataCache
private let chapterDataCache = RDEPUBChapterDataCache()
/// WXRead pageCountCache
private let pageCountCache = RDEPUBPageCountCache()
/// NSCache WXRead imageCache
let imageCache = NSCache<NSString, UIImage>()
/// WXRead com.weread.chapterload
/// QoS .userInitiated/
let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated)
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
// MARK: -
/// spineIndex
private(set) var currentSpineIndex: Int?
/// spineIndex + prev + next
private(set) var windowSpineIndices: [Int] = []
// MARK: - vs
/// ///
///
private var pendingNavigationTarget: Int?
private let navigationLock = NSLock()
/// ±1
///
private var pendingPrefetchTargets: Set<Int> = []
private let prefetchLock = NSLock()
///
private(set) var isBuilding: Bool = false
private let buildingLock = NSLock()
// MARK: -
init() {
imageCache.countLimit = 50
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
}
func assertNotOnChapterLoadQueue() {
dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
}
// MARK: - 线 cache wrapper lock
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? {
return chapterDataCache[spineIndex]
}
func pageCount(for key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
return pageCountCache[key]
}
// MARK: -
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
chapterDataCache[chapter.spineIndex] = chapter
}
func insertPageCount(_ pc: RDEPUBRuntimePageCount, for key: RDEPUBChapterCacheKey) {
pageCountCache[key] = pc
}
// MARK: -
/// ±1
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int) {
currentSpineIndex = spineIndex
var window = [spineIndex]
if spineIndex > 0 { window.append(spineIndex - 1) }
if spineIndex < totalSpineCount - 1 { window.append(spineIndex + 1) }
windowSpineIndices = window
}
/// spineIndex
func evictableSpineIndices() -> [Int] {
let windowSet = Set(windowSpineIndices)
return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) }
}
// MARK: -
func evict(spineIndex: Int) {
chapterDataCache.remove(spineIndex: spineIndex)
pageCountCache.remove(forSpineIndex: spineIndex)
}
func evictAllExceptCurrent() {
guard let current = currentSpineIndex else {
chapterDataCache.removeAll()
pageCountCache.removeAll()
return
}
let currentChapter = chapterDataCache[current]
chapterDataCache.removeAll()
if let ch = currentChapter {
chapterDataCache[current] = ch
}
// WXRead pageCountCache
pageCountCache.removeAll()
}
// MARK: -
func handleMemoryWarning() {
evictAllExceptCurrent()
imageCache.removeAllObjects()
}
// MARK: - §14.4
///
///
func setNavigationTarget(spineIndex: Int) {
navigationLock.lock()
pendingNavigationTarget = spineIndex
navigationLock.unlock()
}
///
func consumeNavigationTarget() -> Int? {
navigationLock.lock()
let target = pendingNavigationTarget
pendingNavigationTarget = nil
navigationLock.unlock()
return target
}
// MARK: -
/// ±1
///
func addPrefetchTarget(_ spineIndex: Int) {
prefetchLock.lock()
pendingPrefetchTargets.insert(spineIndex)
prefetchLock.unlock()
}
///
func removePrefetchTarget(_ spineIndex: Int) {
prefetchLock.lock()
pendingPrefetchTargets.remove(spineIndex)
prefetchLock.unlock()
}
///
func clearPrefetchTargets() {
prefetchLock.lock()
pendingPrefetchTargets.removeAll()
prefetchLock.unlock()
}
///
func hasPrefetchTarget(_ spineIndex: Int) -> Bool {
prefetchLock.lock()
let has = pendingPrefetchTargets.contains(spineIndex)
prefetchLock.unlock()
return has
}
func markBuilding(_ building: Bool) {
buildingLock.lock()
isBuilding = building
buildingLock.unlock()
}
// MARK: - P1: §8.5
func invalidateAllForSettingsChange() {
chapterDataCache.removeAll()
pageCountCache.removeAll()
imageCache.removeAllObjects()
}
}

View File

@ -0,0 +1,154 @@
import Foundation
final class RDEPUBChapterSummaryDiskCache {
private let cacheDirectory: URL
private let fileManager = FileManager.default
private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility)
init(cacheDirectory: URL) {
self.cacheDirectory = cacheDirectory
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
// MARK: -
func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
queue.async {
self.writeImmediately(summary: summary, for: key)
}
}
///
func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
queue.sync {
self.writeImmediately(summary: summary, for: key)
}
}
// MARK: - loadChapter
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
let fileURL = self.fileURL(for: key)
guard let data = try? Data(contentsOf: fileURL) else { return nil }
return try? JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
}
// MARK: - BookPageMap
/// spineIndex summary
/// 线
/// contentHash
func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
summaries: [Int: RDEPUBChapterSummary],
mapBuilder: RDEPUBBookPageMap.Builder
) {
var summaries: [Int: RDEPUBChapterSummary] = [:]
var mapBuilder = RDEPUBBookPageMap.Builder()
for item in keys {
if let summary = read(for: item.key) {
summaries[item.spineIndex] = summary
mapBuilder.add(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: summary.pageCount,
fragmentOffsets: summary.fragmentOffsets
)
}
}
return (summaries, mapBuilder)
}
///
func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
for key in keys {
if read(for: key) == nil {
return false
}
}
return true
}
/// renderSignature
func containsCompleteSet(keys: [RDEPUBChapterCacheKey]) -> Bool {
isCacheComplete(keys: keys)
}
///
func removeAll() {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
for fileURL in files where fileURL.pathExtension == "json" {
try? fileManager.removeItem(at: fileURL)
}
}
// MARK: - key ->
/// 使 Hashable.hashValue
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
let digest = rawKey.sha256Hex
return cacheDirectory.appendingPathComponent("\(digest).json")
}
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
let fileURL = self.fileURL(for: key)
let data = try? JSONEncoder().encode(summary)
try? data?.write(to: fileURL)
}
}
struct RDEPUBChapterSummary: Codable {
let pageRanges: [RangeData]
let pageCount: Int
let fragmentOffsets: [String: Int]
let renderSignature: String
let schemaVersion: Int
let chapterContentHash: String
let pageMetadataList: [PageMetadataSummary]
static let currentSchemaVersion = 6
struct RangeData: Codable {
let location: Int
let length: Int
var nsRange: NSRange { NSRange(location: location, length: length) }
}
struct PageMetadataSummary: Codable {
let breakReason: String
let attachmentRanges: [RangeData]
let attachmentKinds: [String]
let blockKinds: [String]
let semanticHints: [String]
let attachmentPlacements: [String]
let trailingFragmentID: String?
func toPageMetadata() -> RDEPUBTextPageMetadata {
RDEPUBTextPageMetadata(
breakReason: RDEPUBTextPageBreakReason(rawValue: breakReason) ?? .frameLimit,
blockRange: nil,
attachmentRanges: attachmentRanges.map { $0.nsRange },
attachmentKinds: attachmentKinds.compactMap { RDEPUBTextAttachmentKind(rawValue: $0) },
blockKinds: blockKinds.compactMap { RDEPUBTextBlockKind(rawValue: $0) },
semanticHints: semanticHints.compactMap { RDEPUBTextSemanticHint(rawValue: $0) },
attachmentPlacements: attachmentPlacements.compactMap { RDEPUBTextAttachmentPlacement(rawValue: $0) },
trailingFragmentID: trailingFragmentID,
diagnostics: []
)
}
static func from(_ metadata: RDEPUBTextPageMetadata) -> PageMetadataSummary {
PageMetadataSummary(
breakReason: metadata.breakReason.rawValue,
attachmentRanges: metadata.attachmentRanges.map { .init(location: $0.location, length: $0.length) },
attachmentKinds: metadata.attachmentKinds.map { $0.rawValue },
blockKinds: metadata.blockKinds.map { $0.rawValue },
semanticHints: metadata.semanticHints.map { $0.rawValue },
attachmentPlacements: metadata.attachmentPlacements.map { $0.rawValue },
trailingFragmentID: metadata.trailingFragmentID
)
}
}
}

View File

@ -0,0 +1,312 @@
import Foundation
final class RDEPUBChapterWindowCoordinator {
private unowned let context: RDEPUBReaderContext
private let store: RDEPUBChapterRuntimeStore
private let loader: RDEPUBChapterLoader
///
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
///
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore, loader: RDEPUBChapterLoader) {
self.context = context
self.store = store
self.loader = loader
}
///
private var restoreChapterOffset: Int?
// MARK: -
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) {
let totalSpineCount = context.publication?.spine.count ?? 0
store.setCurrentChapter(spineIndex: targetSpineIndex, totalSpineCount: totalSpineCount)
self.restoreChapterOffset = restoreChapterOffset
//
isSwitchingChapter = true
//
store.setNavigationTarget(spineIndex: targetSpineIndex)
//
store.clearPrefetchTargets()
//
loadChapterWithFallback(initialSpineIndex: targetSpineIndex, totalSpineCount: totalSpineCount)
}
///
private func loadChapterWithFallback(initialSpineIndex: Int, totalSpineCount: Int) {
loader.loadChapter(spineIndex: initialSpineIndex, store: store, priority: .navigation) { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let chapter):
self.isSwitchingChapter = false
self.buildSnapshotAroundCurrent(chapter: chapter)
case .failure(let error):
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
// / linear=false spine
let nextIndex = initialSpineIndex + 1
if nextIndex < totalSpineCount {
self.store.setCurrentChapter(spineIndex: nextIndex, totalSpineCount: totalSpineCount)
self.store.setNavigationTarget(spineIndex: nextIndex)
self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount)
} else {
//
self.isSwitchingChapter = false
self.handle(error: error)
}
}
}
}
// MARK: -
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
guard let current = store.currentSpineIndex else {
print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT")
return
}
let prev = current > 0 ? store.chapterData(for: current - 1) : nil
let next = store.chapterData(for: current + 1)
let snapshot = RDEPUBChapterWindowSnapshot.from(
currentChapter: chapter,
previousChapter: prev,
nextChapter: next
)
print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)")
currentSnapshot = snapshot
isApplyingSnapshot = true
onSnapshotChanged?(snapshot)
isApplyingSnapshot = false
// chapterOffset
if let offset = restoreChapterOffset,
let chapter = snapshot.chapterForPage(flattenedPageIndex: snapshot.anchorPageOffset),
let pageIndex = chapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
let targetPage = snapshot.anchorPageOffset + pageIndex
context.readerView?.transitionToPage(pageNum: targetPage, animated: false)
} else if snapshot.pageCount > 0 {
//
// reloadData() switchReaderDisplayType currentPage -1 0
// 0 anchorPageOffset transition
context.readerView?.transitionToPage(pageNum: snapshot.anchorPageOffset, animated: false)
}
restoreChapterOffset = nil
// ±1
prefetchAdjacent(current: current)
}
// MARK: -
private func prefetchAdjacent(current: Int) {
let totalSpineCount = context.publication?.spine.count ?? 0
// prev
if current > 0 && store.chapterData(for: current - 1) == nil {
let prevIndex = current - 1
store.addPrefetchTarget(prevIndex)
loader.loadChapter(spineIndex: prevIndex, store: store, priority: .prefetch) { [weak self] result in
guard let self = self, case .success = result else { return }
self.refreshSnapshot()
}
}
// next
if current < totalSpineCount - 1 && store.chapterData(for: current + 1) == nil {
let nextIndex = current + 1
store.addPrefetchTarget(nextIndex)
loader.loadChapter(spineIndex: nextIndex, store: store, priority: .prefetch) { [weak self] result in
guard let self = self, case .success = result else { return }
self.refreshSnapshot()
}
}
}
// MARK: -
///
func flipToNextChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
guard let current = store.currentSpineIndex else { return }
let next = current + 1
let totalSpineCount = context.publication?.spine.count ?? 0
guard next < totalSpineCount else { return }
flipToChapter(spineIndex: next, completion: completion)
}
///
func flipToPreviousChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
guard let current = store.currentSpineIndex, current > 0 else { return }
flipToChapter(spineIndex: current - 1, completion: completion)
}
/// //
func flipToChapter(
spineIndex: Int,
completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void
) {
let totalSpineCount = context.publication?.spine.count ?? 0
//
store.setNavigationTarget(spineIndex: spineIndex)
//
store.clearPrefetchTargets()
//
isSwitchingChapter = true
//
store.setCurrentChapter(spineIndex: spineIndex, totalSpineCount: totalSpineCount)
let evictable = store.evictableSpineIndices()
for idx in evictable {
store.evict(spineIndex: idx)
}
//
if let cached = store.chapterData(for: spineIndex) {
buildSnapshotAroundCurrent(chapter: cached)
isSwitchingChapter = false
if let snap = currentSnapshot {
completion(.success(snap))
}
return
}
//
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in
guard let self = self else { return }
self.isSwitchingChapter = false
switch result {
case .success(let chapter):
self.buildSnapshotAroundCurrent(chapter: chapter)
if let snap = self.currentSnapshot {
completion(.success(snap))
}
case .failure(let error):
completion(.failure(error))
}
}
}
// MARK: -
func refreshSnapshot() {
guard let current = store.currentSpineIndex,
let currentChapter = store.chapterData(for: current) else { return }
//
guard isReaderIdle() else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.refreshSnapshot()
}
return
}
let prev = current > 0 ? store.chapterData(for: current - 1) : nil
let next = store.chapterData(for: current + 1)
let newSnapshot = RDEPUBChapterWindowSnapshot.from(
currentChapter: currentChapter,
previousChapter: prev,
nextChapter: next
)
if snapshotContentChanged(old: currentSnapshot, new: newSnapshot) {
currentSnapshot = newSnapshot
isApplyingSnapshot = true
onSnapshotChanged?(newSnapshot)
isApplyingSnapshot = false
}
}
// MARK: - P1:
///
func maintainWindow(afterMovingTo spineIndex: Int) {
let totalSpineCount = context.publication?.spine.count ?? 0
//
store.setCurrentChapter(spineIndex: spineIndex, totalSpineCount: totalSpineCount)
for idx in store.evictableSpineIndices() {
store.evict(spineIndex: idx)
}
// prev
if spineIndex > 0 && store.chapterData(for: spineIndex - 1) == nil {
let prevIndex = spineIndex - 1
store.addPrefetchTarget(prevIndex)
loader.loadChapter(spineIndex: prevIndex, store: store, priority: .prefetch) { [weak self] result in
guard let self = self, case .success = result else { return }
self.refreshSnapshot()
}
}
// next
if spineIndex < totalSpineCount - 1 && store.chapterData(for: spineIndex + 1) == nil {
let nextIndex = spineIndex + 1
store.addPrefetchTarget(nextIndex)
loader.loadChapter(spineIndex: nextIndex, store: store, priority: .prefetch) { [weak self] result in
guard let self = self, case .success = result else { return }
self.refreshSnapshot()
}
}
}
// MARK: -
private func handle(error: Error) {
//
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
// loading
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.context.hideLoading()
//
if self.currentSnapshot == nil {
print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank")
}
}
}
private func snapshotContentChanged(
old: RDEPUBChapterWindowSnapshot?,
new: RDEPUBChapterWindowSnapshot
) -> Bool {
guard let old = old else { return true }
if old.chapters.count != new.chapters.count { return true }
let oldSpines = old.chapters.map { $0.spineIndex }
let newSpines = new.chapters.map { $0.spineIndex }
if oldSpines != newSpines { return true }
if old.pageCount != new.pageCount { return true }
for (oldCh, newCh) in zip(old.chapters, new.chapters) {
if oldCh.pages.count != newCh.pages.count { return true }
}
if old.anchorChapterIndex != new.anchorChapterIndex
|| old.anchorPageOffset != new.anchorPageOffset { return true }
return false
}
private func isReaderIdle() -> Bool {
guard !store.isBuilding else { return false }
guard !isSwitchingChapter else { return false }
guard !isApplyingSnapshot else { return false }
return true
}
///
private var isSwitchingChapter: Bool = false
///
private var isApplyingSnapshot: Bool = false
}

View File

@ -0,0 +1,86 @@
import Foundation
struct RDEPUBChapterWindowSnapshot {
/// prev, current, next
let chapters: [RDEPUBRuntimeChapter]
/// RDReaderView
/// RDEPUBTextPage
/// flattenedPages 0
let flattenedPages: [RDEPUBTextPage]
/// chapters
let anchorChapterIndex: Int
/// flattenedPages 0
let anchorPageOffset: Int
/// spineIndex
let windowStartSpineIndex: Int
// MARK: -
///
static func from(
currentChapter: RDEPUBRuntimeChapter,
previousChapter: RDEPUBRuntimeChapter?,
nextChapter: RDEPUBRuntimeChapter?
) -> RDEPUBChapterWindowSnapshot {
var chapters: [RDEPUBRuntimeChapter] = []
var anchorIndex = 0
var pageOffset = 0
if let prev = previousChapter {
chapters.append(prev)
anchorIndex = 1
pageOffset = prev.pages.count
}
chapters.append(currentChapter)
if let next = nextChapter {
chapters.append(next)
}
//
var allPages: [RDEPUBTextPage] = []
for (chIdx, ch) in chapters.enumerated() {
for var page in ch.pages {
page.chapterIndex = chIdx
allPages.append(page)
}
}
let windowStartSpineIndex = chapters.first?.spineIndex ?? currentChapter.spineIndex
return RDEPUBChapterWindowSnapshot(
chapters: chapters,
flattenedPages: allPages,
anchorChapterIndex: anchorIndex,
anchorPageOffset: pageOffset,
windowStartSpineIndex: windowStartSpineIndex
)
}
// MARK: -
/// flattenedPages ->
func chapterForPage(flattenedPageIndex: Int) -> RDEPUBRuntimeChapter? {
var offset = 0
for ch in chapters {
if flattenedPageIndex < offset + ch.pages.count {
return ch
}
offset += ch.pages.count
}
return nil
}
/// flattenedPages -> spineIndex
func spineIndexForPage(flattenedPageIndex: Int) -> Int? {
return chapterForPage(flattenedPageIndex: flattenedPageIndex)?.spineIndex
}
///
var pageCount: Int { flattenedPages.count }
}

View File

@ -0,0 +1,111 @@
import Foundation
struct RDEPUBLocationConverter {
// MARK: -
/// RDEPUBLocation -> RDEPUBChapterLocation
/// chapterLength
/// fallback
static func convert(
legacy location: RDEPUBLocation,
parser: RDEPUBParser,
publication: RDEPUBPublication,
chapterLengthProvider: ((Int) -> Int?)? = nil
) -> RDEPUBChapterLocation? {
// 1. href spineIndex
guard let spineItem = publication.spine.first(where: {
$0.href == location.href || $0.href.contains(location.href)
}) else { return nil }
let spineIndex = publication.spine.firstIndex(of: spineItem) ?? 0
// 2. fragmentID progression
if let fragmentID = location.fragment {
return RDEPUBChapterLocation(
spineIndex: spineIndex,
chapterOffset: 0, // fragmentID chapterOffsetMap
fragmentID: fragmentID,
progressionInChapter: location.progression
)
}
// 3. chapterLength
if let provider = chapterLengthProvider,
let chapterLength = provider(spineIndex), chapterLength > 0 {
return convert(
legacy: location,
spineIndex: spineIndex,
chapterLength: chapterLength
)
}
// 4. Fallback
let estimatedOffset = Int(location.progression * 10000)
return RDEPUBChapterLocation(
spineIndex: spineIndex,
chapterOffset: estimatedOffset,
fragmentID: nil,
progressionInChapter: location.progression,
schemaVersion: 1 //
)
}
///
static func convert(
legacy location: RDEPUBLocation,
spineIndex: Int,
chapterLength: Int
) -> RDEPUBChapterLocation? {
let offset = Int(location.progression * Double(chapterLength))
return RDEPUBChapterLocation(
spineIndex: spineIndex,
chapterOffset: offset,
fragmentID: location.fragment,
progressionInChapter: location.progression,
schemaVersion: 2
)
}
/// RDEPUBRuntimeChapter
static func convert(
legacy location: RDEPUBLocation,
chapter: RDEPUBRuntimeChapter
) -> RDEPUBChapterLocation? {
// fragmentID
if let fragmentID = location.fragment,
let fragmentOffset = chapter.chapterOffsetMap.chapterOffset(forFragmentID: fragmentID) {
return RDEPUBChapterLocation(
spineIndex: chapter.spineIndex,
chapterOffset: fragmentOffset,
fragmentID: fragmentID,
progressionInChapter: nil,
schemaVersion: 2
)
}
// progression +
let chapterLength = chapter.typesetAttributedString.length
return convert(
legacy: location,
spineIndex: chapter.spineIndex,
chapterLength: chapterLength
)
}
/// ->
static func toLegacy(
chapterLocation: RDEPUBChapterLocation,
href: String,
chapterLength: Int
) -> RDEPUBLocation {
let progression = chapterLength > 0
? Double(chapterLocation.chapterOffset) / Double(chapterLength)
: 0
return RDEPUBLocation(
href: href,
progression: min(max(progression, 0), 1),
fragment: chapterLocation.fragmentID
)
}
}

View File

@ -0,0 +1,37 @@
import Foundation
final class RDEPUBPageCountCache {
private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:]
private let lock = NSLock()
subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
get {
lock.lock()
defer { lock.unlock() }
return storage[key]
}
set {
lock.lock()
defer { lock.unlock() }
storage[key] = newValue
}
}
func entriesForSpineIndex(_ spineIndex: Int) -> [(RDEPUBChapterCacheKey, RDEPUBRuntimePageCount)] {
lock.lock()
defer { lock.unlock() }
return storage.filter { $0.value.spineIndex == spineIndex }.map { ($0.key, $0.value) }
}
func remove(forSpineIndex spineIndex: Int) {
lock.lock()
defer { lock.unlock() }
storage = storage.filter { $0.value.spineIndex != spineIndex }
}
func removeAll() {
lock.lock()
defer { lock.unlock() }
storage.removeAll()
}
}

View File

@ -0,0 +1,35 @@
import Foundation
struct RDEPUBResolvedPage {
let page: RDEPUBTextPage
let chapter: RDEPUBRuntimeChapter
let chapterIndex: Int
}
final class RDEPUBPageResolver {
private unowned let context: RDEPUBReaderContext
private let store: RDEPUBChapterRuntimeStore
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore) {
self.context = context
self.store = store
}
func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage? {
guard let bookPageMap = context.bookPageMap,
let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
let chapter = store.chapterData(for: spineIndex),
chapter.pages.indices.contains(localPageIndex),
let chapterIndex = bookPageMap.chapterIndex(forSpineIndex: spineIndex) else {
return nil
}
var page = chapter.pages[localPageIndex]
page.absolutePageIndex = absolutePageIndex
page.chapterIndex = chapterIndex
page.pageIndexInChapter = localPageIndex
page.totalPagesInChapter = chapter.pages.count
return RDEPUBResolvedPage(page: page, chapter: chapter, chapterIndex: chapterIndex)
}
}

View File

@ -0,0 +1,52 @@
import Foundation
final class RDEPUBRuntimeChapter {
let spineIndex: Int
let href: String
let title: String
///
var sourceAttributedString: NSAttributedString?
///
let typesetAttributedString: NSAttributedString
///
let layouter: RDEPUBTextLayouter
///
let pageRanges: [NSRange]
///
let pages: [RDEPUBTextPage]
///
let chapterOffsetMap: RDEPUBChapterOffsetMap
init(
spineIndex: Int,
href: String,
title: String,
sourceAttributedString: NSAttributedString?,
typesetAttributedString: NSAttributedString,
layouter: RDEPUBTextLayouter,
pageRanges: [NSRange],
pages: [RDEPUBTextPage],
chapterOffsetMap: RDEPUBChapterOffsetMap
) {
self.spineIndex = spineIndex
self.href = href
self.title = title
self.sourceAttributedString = sourceAttributedString
self.typesetAttributedString = typesetAttributedString
self.layouter = layouter
self.pageRanges = pageRanges
self.pages = pages
self.chapterOffsetMap = chapterOffsetMap
}
/// sourceAttributedString
func releaseSourceText() {
sourceAttributedString = nil
}
}

View File

@ -0,0 +1,9 @@
import Foundation
struct RDEPUBRuntimePageCount {
let cacheKey: RDEPUBChapterCacheKey
let spineIndex: Int
let pageRanges: [NSRange]
let pageCount: Int
let renderSignature: String
}

View File

@ -0,0 +1,8 @@
import CryptoKit
extension String {
var sha256Hex: String {
let digest = SHA256.hash(data: Data(self.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
}

View File

@ -50,8 +50,8 @@ final class RDEPUBReaderAssemblyCoordinator {
}
private func setupReaderView(_ readerView: RDReaderView, in containerView: UIView) {
readerView.dataSource = context.controller as? RDReaderDataSource
readerView.delegate = context.controller as? RDReaderDelegate
readerView.dataSource = context.controller
readerView.delegate = context.controller
readerView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(readerView)
NSLayoutConstraint.activate([

View File

@ -117,7 +117,10 @@ final class RDEPUBReaderChromeCoordinator {
func presentTableOfContents() {
guard let controller else { return }
guard controller.configuration.showsTableOfContents else { return }
let items = controller.flattenedTableOfContents
let items = controller.flattenedTableOfContentsItems(
from: controller.publication?.tableOfContents ?? [],
includePageNumbers: false
)
guard !items.isEmpty else { return }
let chapterController = RDEPUBReaderChapterListController(

View File

@ -34,7 +34,10 @@ final class RDEPUBReaderContext {
///
var readingSession: RDEPUBReadingSession?
///
/// nil ChapterRuntimeStore 访
var textBook: RDEPUBTextBook?
/// 100KB/1000 NSAttributedString
var bookPageMap: RDEPUBBookPageMap?
///
var activeBookmarks: [RDEPUBBookmark] = []
///
@ -131,8 +134,9 @@ final class RDEPUBReaderContext {
edgeInsets: configuration.reflowableContentInsets,
numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap,
avoidOrphans: true,
avoidWidows: true,
//
avoidOrphans: false,
avoidWidows: false,
avoidPageBreakInsideEnabled: true,
hyphenation: true,
imageMaxHeightRatio: 0.85,
@ -186,6 +190,66 @@ final class RDEPUBReaderContext {
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
}
func makeChapterSummaryDiskCache() -> RDEPUBChapterSummaryDiskCache {
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let bookID = (currentBookIdentifier ?? "default").sha256Hex
let directory = cachesDirectory
.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
.appendingPathComponent(bookID, isDirectory: true)
return RDEPUBChapterSummaryDiskCache(cacheDirectory: directory)
}
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
let style = currentTextRenderStyle()
let pageSize = currentTextPageSize()
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
let renderSignature = [
style.font.fontName,
"\(style.font.pointSize)",
"\(configuration.lineHeightMultiple)",
"\(style.lineSpacing)",
layoutConfig.cacheSignature,
"\(RDEPUBChapterSummary.currentSchemaVersion)"
].joined(separator: "|")
let contentHash: String
if let parser,
let publication,
publication.spine.indices.contains(spineIndex) {
let href = publication.spine[spineIndex].href
contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
} else {
contentHash = ""
}
return RDEPUBChapterCacheKey(
bookID: currentBookIdentifier ?? "",
spineIndex: spineIndex,
renderSignature: renderSignature,
chapterContentHash: contentHash
)
}
func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? {
runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex))
}
func normalizedSpineIndex(for location: RDEPUBLocation) -> Int? {
guard let publication else { return nil }
let normalizedLocation = publication.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
) ?? location
guard let normalizedHref = publication.resourceResolver.normalizedHref(normalizedLocation.href) else {
return nil
}
return publication.spine.firstIndex {
publication.resourceResolver.normalizedHref($0.href) == normalizedHref
}
}
///
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)

View File

@ -25,7 +25,16 @@ final class RDEPUBReaderLocationCoordinator {
return false
}
if context.textBook == nil {
if context.bookPageMap != nil {
guard context.runtime?.prepareOnDemandChapter(forAbsolutePageNumber: targetPageNumber) == true else {
return false
}
_ = context.readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
bookIdentifier: context.currentBookIdentifier
)
} else if context.textBook == nil {
_ = context.readingSession?.queueNavigation(
to: location,
relativeToSpineIndex: nil,
@ -44,7 +53,7 @@ final class RDEPUBReaderLocationCoordinator {
let readerView = context.readerView else {
return nil
}
if context.textBook != nil, readerView.currentPage >= 0 {
if (context.textBook != nil || context.bookPageMap != nil), readerView.currentPage >= 0 {
return controller.resolvedTextLocation(forPageNumber: readerView.currentPage + 1)
}
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)

View File

@ -4,56 +4,12 @@ import Foundation
///
///
/// - /Fixed Layout/Web
/// -
/// -
/// -
/// -
/// -
final class RDEPUBReaderPaginationCoordinator {
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
private let backgroundChapterPause: TimeInterval = 0.04
private let incrementalMergeChapterThreshold = 20
private let stagedIncrementalApplyDelay: TimeInterval = 1.0
private var stagedIncrementalApplyWorkItem: DispatchWorkItem?
private let stagedBookRequestLock = NSLock()
private var stagedBookRequest: StagedBookRequest?
private struct QuickBuildState {
var quickWindow: [Int]
var chapterStore: IncrementalChapterStore
var book: RDEPUBTextBook
}
private struct StagedBookRequest {
var chapterStore: IncrementalChapterStore
var orderedSpineIndices: [Int]
var restoreLocation: RDEPUBLocation?
var isComplete: Bool
}
private final class IncrementalChapterStore {
private let lock = NSLock()
private var builtChapters: [Int: RDEPUBTextChapter] = [:]
func insert(_ chapter: RDEPUBTextChapter, for spineIndex: Int) {
lock.lock()
builtChapters[spineIndex] = chapter
lock.unlock()
}
func contains(_ spineIndex: Int) -> Bool {
lock.lock()
let contains = builtChapters[spineIndex] != nil
lock.unlock()
return contains
}
func snapshot(orderedBy spineIndices: [Int]) -> [Int: RDEPUBTextChapter] {
lock.lock()
let chapters = builtChapters
lock.unlock()
return chapters
}
}
private unowned let context: RDEPUBReaderContext
@ -61,7 +17,7 @@ final class RDEPUBReaderPaginationCoordinator {
self.context = context
}
/// TextBookBuilderFixed Layout Web Paginator
/// /Fixed Layout Web Paginator
func paginatePublication(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let parser = context.parser,
@ -78,7 +34,7 @@ final class RDEPUBReaderPaginationCoordinator {
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
if publication.readingProfile == .textReflowable {
print("[EPUB][Pagination] path=text-reflowable-fast-entry")
print("[EPUB][Pagination] path=text-reflowable-on-demand")
paginateTextPublication(
parser: parser,
publication: publication,
@ -122,10 +78,8 @@ final class RDEPUBReaderPaginationCoordinator {
///
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller else { return }
stagedIncrementalApplyWorkItem?.cancel()
stagedIncrementalApplyWorkItem = nil
clearStagedBookRequest()
context.textBook = textBook
context.bookPageMap = nil
let snapshot = controller.nativeTextSnapshot(from: textBook)
context.replaceActiveSnapshot(snapshot)
@ -144,6 +98,7 @@ final class RDEPUBReaderPaginationCoordinator {
) {
guard context.controller != nil else { return }
context.textBook = nil
context.bookPageMap = nil
context.replaceActiveSnapshot(snapshot)
guard !snapshot.pages.isEmpty else {
@ -163,6 +118,7 @@ final class RDEPUBReaderPaginationCoordinator {
readerView.reloadData()
if let targetLocation = restoreLocation {
controller.restoreReadingLocation(targetLocation)
context.readingSession?.transition(to: .idle)
} else {
readerView.transitionToPage(pageNum: 0)
context.readingSession?.transition(to: .idle)
@ -203,7 +159,6 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
/// EPUB TextBook
private func paginateTextPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@ -212,184 +167,136 @@ final class RDEPUBReaderPaginationCoordinator {
token: UUID
) {
guard let controller = context.controller else { return }
let context = self.context
let pageSize = controller.currentTextPageSize()
context.lastTextPaginationPageSize = pageSize
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
let renderStyle = controller.currentTextRenderStyle()
let quickSpineCandidates = prioritizedBuildableSpineIndices(
publication: publication,
readingSession: readingSession,
restoreLocation: restoreLocation
)
DispatchQueue.global(qos: .utility).async { [weak controller] in
guard controller != nil else { return }
var didApplyQuickChapter = false
guard context.controller != nil else { return }
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.applyBookPageMap(restoredPageMap, restoreLocation: restoreLocation)
}
return
}
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
publication: publication,
readingSession: readingSession,
restoreLocation: restoreLocation
)
guard prioritizedCandidates.first != nil else {
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.handle(error: RDEPUBParserError.emptySpine)
}
return
}
do {
self.waitForReadingInteractionToSettle()
let allBuildableIndices = self.allBuildableSpineIndices(in: publication)
let quickBuildState = try self.buildQuickTextBook(
builder: builder,
parser: parser,
publication: publication,
pageSize: pageSize,
style: renderStyle,
prioritizedCandidates: quickSpineCandidates
guard context.controller != nil,
let runtime = context.runtime else { return }
RDEPUBBackgroundTrace.log(
"QuickOpen",
"begin token=\(token.uuidString) candidates=\(prioritizedCandidates.count) restoreSpine=\(readingSession.initialSpineIndex(for: restoreLocation))"
)
if let quickBook = quickBuildState?.book {
DispatchQueue.main.sync {
guard self.context.paginationToken == token else { return }
didApplyQuickChapter = true
self.context.runtime?.applyTextBook(quickBook, restoreLocation: restoreLocation)
}
let runtimeChapter = try RDEPUBBackgroundTrace.measure(
"QuickOpen",
"loadFirstRenderableRuntimeChapter"
) {
try self.loadFirstRenderableRuntimeChapter(
prioritizedSpineIndices: prioritizedCandidates,
runtime: runtime
)
}
let chapterStore = quickBuildState?.chapterStore ?? IncrementalChapterStore()
var pendingIncrementalCount = 0
let incrementalBuildOrder = self.incrementalBuildOrder(
allBuildableIndices: allBuildableIndices,
quickWindow: quickBuildState?.quickWindow ?? []
)
for spineIndex in incrementalBuildOrder where !chapterStore.contains(spineIndex) {
self.waitForReadingInteractionToSettle()
guard let result = try builder.buildChapter(
parser: parser,
let quickWindowChapters = try RDEPUBBackgroundTrace.measure(
"QuickOpen",
"loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)"
) {
try self.loadInitialRuntimeChapters(
anchorSpineIndex: runtimeChapter.spineIndex,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: renderStyle
) else {
continue
}
chapterStore.insert(result.chapter, for: spineIndex)
pendingIncrementalCount += 1
Thread.sleep(forTimeInterval: self.backgroundChapterPause)
if pendingIncrementalCount >= self.incrementalMergeChapterThreshold {
pendingIncrementalCount = 0
self.stageBookRequest(
chapterStore: chapterStore,
orderedSpineIndices: allBuildableIndices,
restoreLocation: restoreLocation,
isComplete: false
)
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.scheduleStagedIncrementalTextBookApplication()
}
}
runtime: runtime
)
}
self.stageBookRequest(
chapterStore: chapterStore,
orderedSpineIndices: allBuildableIndices,
restoreLocation: restoreLocation,
isComplete: true
RDEPUBBackgroundTrace.log(
"QuickOpen",
"ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })"
)
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.scheduleStagedIncrementalTextBookApplication()
guard context.paginationToken == token,
context.controller != nil else { return }
runtime.chapterRuntimeStore.setCurrentChapter(
spineIndex: runtimeChapter.spineIndex,
totalSpineCount: publication.spine.count
)
let partialMap = self.makePartialPageMap(from: quickWindowChapters)
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
}
} catch {
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
if didApplyQuickChapter {
self.context.isRepaginating = false
self.context.hideLoading()
} else {
self.context.handle(error: error)
}
guard context.paginationToken == token,
context.controller != nil else { return }
context.handle(error: error)
}
}
}
}
private func buildQuickTextBook(
builder: RDEPUBTextBookBuilder,
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle,
prioritizedCandidates: [Int]
) throws -> QuickBuildState? {
var anchorResult: RDEPUBTextChapterBuildResult?
for spineIndex in prioritizedCandidates {
guard let result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
), !result.chapter.pages.isEmpty else {
continue
private func loadFirstRenderableRuntimeChapter(
prioritizedSpineIndices: [Int],
runtime: RDEPUBReaderRuntime
) throws -> RDEPUBRuntimeChapter {
var lastError: Error?
for spineIndex in prioritizedSpineIndices {
do {
return try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: runtime.chapterRuntimeStore
)
} catch {
lastError = error
RDEPUBBackgroundTrace.log("QuickOpen", "skip spine=\(spineIndex) reason=\(error)")
}
anchorResult = result
break
}
throw lastError ?? RDEPUBParserError.emptySpine
}
guard let anchorResult else {
return nil
}
let quickWindow = quickWindowSpineIndices(
around: anchorResult.chapter.spineIndex,
private func loadInitialRuntimeChapters(
anchorSpineIndex: Int,
publication: RDEPUBPublication,
runtime: RDEPUBReaderRuntime
) throws -> [RDEPUBRuntimeChapter] {
let windowSpineIndices = initialWindowSpineIndices(
around: anchorSpineIndex,
in: publication
)
let chapterStore = IncrementalChapterStore()
for spineIndex in quickWindow {
let result: RDEPUBTextChapterBuildResult?
if spineIndex == anchorResult.chapter.spineIndex {
result = anchorResult
} else {
result = try builder.buildChapter(
parser: parser,
publication: publication,
var chapters: [RDEPUBRuntimeChapter] = []
for spineIndex in windowSpineIndices {
do {
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
pageSize: pageSize,
style: style
store: runtime.chapterRuntimeStore
)
chapters.append(chapter)
} catch {
if spineIndex == anchorSpineIndex {
throw error
}
RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)")
}
guard let chapter = result?.chapter,
!chapter.pages.isEmpty else {
continue
}
chapterStore.insert(chapter, for: spineIndex)
}
guard let book = textBook(from: chapterStore.snapshot(orderedBy: quickWindow), orderedBy: quickWindow) else {
return nil
}
print("[EPUB][Pagination] quick-book chapters=\(book.chapters.count) pages=\(book.pages.count) anchor=\(anchorResult.chapter.href)")
return QuickBuildState(
quickWindow: quickWindow,
chapterStore: chapterStore,
book: book
)
return chapters
}
private func prioritizedBuildableSpineIndices(
publication: RDEPUBPublication,
readingSession: RDEPUBReadingSession,
restoreLocation: RDEPUBLocation?
) -> [Int] {
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
return publication.spine.indices
.filter { isBuildableTextSpine(at: $0, in: publication) }
.sorted { lhs, rhs in
abs(lhs - preferred) < abs(rhs - preferred)
}
}
private func quickWindowSpineIndices(
private func initialWindowSpineIndices(
around anchorSpineIndex: Int,
in publication: RDEPUBPublication,
maxChapterCount: Int = 3
@ -399,7 +306,7 @@ final class RDEPUBReaderPaginationCoordinator {
return [anchorSpineIndex]
}
var selected: [Int] = [anchorSpineIndex]
var selected = [anchorSpineIndex]
var nextPosition = anchorPosition + 1
var previousPosition = anchorPosition - 1
@ -422,131 +329,209 @@ final class RDEPUBReaderPaginationCoordinator {
return selected
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
builder.add(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
}
return builder.build()
}
private func prioritizedBuildableSpineIndices(
publication: RDEPUBPublication,
readingSession: RDEPUBReadingSession,
restoreLocation: RDEPUBLocation?
) -> [Int] {
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
return publication.spine.indices
.filter { isBuildableTextSpine(at: $0, in: publication) }
.sorted { lhs, rhs in
abs(lhs - preferred) < abs(rhs - preferred)
}
}
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
}
func scheduleStagedIncrementalTextBookApplication() {
stagedIncrementalApplyWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.applyStagedIncrementalTextBookIfPossible()
}
stagedIncrementalApplyWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + stagedIncrementalApplyDelay, execute: workItem)
}
private func applyStagedIncrementalTextBookIfPossible() {
guard context.secondsSinceLastUserNavigation() >= backgroundInteractionCooldown,
!context.isRepaginating,
context.readingSession?.navigatorState == .idle else {
scheduleStagedIncrementalTextBookApplication()
return
}
guard let staged = consumeStagedBookRequest() else {
stagedIncrementalApplyWorkItem = nil
return
}
stagedIncrementalApplyWorkItem = nil
guard let stagedBook = textBook(
from: staged.chapterStore.snapshot(orderedBy: staged.orderedSpineIndices),
orderedBy: staged.orderedSpineIndices
) else {
return
}
let restoreLocation = context.currentVisibleLocation() ?? staged.restoreLocation
let logPrefix = staged.isComplete ? "full-book" : "applied-staged-book"
print("[EPUB][Pagination] \(logPrefix) chapters=\(stagedBook.chapters.count) pages=\(stagedBook.pages.count)")
context.runtime?.applyTextBook(stagedBook, restoreLocation: restoreLocation)
}
private func waitForReadingInteractionToSettle() {
while context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
private func waitForReadingInteractionToSettle(using context: RDEPUBReaderContext) {
while context.controller != nil,
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
Thread.sleep(forTimeInterval: 0.08)
}
}
private func incrementalBuildOrder(
allBuildableIndices: [Int],
quickWindow: [Int]
) -> [Int] {
guard let firstWindowIndex = quickWindow.first,
let lastWindowIndex = quickWindow.last else {
return allBuildableIndices
}
let forward = allBuildableIndices.filter { $0 > lastWindowIndex }
let backward = allBuildableIndices.filter { $0 < firstWindowIndex }
return quickWindow + forward + backward
}
private func textBook(
from builtChapters: [Int: RDEPUBTextChapter],
orderedBy spineIndices: [Int]
) -> RDEPUBTextBook? {
var chapters: [RDEPUBTextChapter] = []
var pages: [RDEPUBTextPage] = []
for spineIndex in spineIndices {
guard var chapter = builtChapters[spineIndex],
!chapter.pages.isEmpty else {
continue
}
chapter.chapterIndex = chapters.count
let normalizedPages = chapter.pages.enumerated().map { localPageIndex, page -> RDEPUBTextPage in
var page = page
page.absolutePageIndex = pages.count + localPageIndex
page.chapterIndex = chapters.count
page.pageIndexInChapter = localPageIndex
page.totalPagesInChapter = chapter.pages.count
return page
}
chapter.pages = normalizedPages
chapters.append(chapter)
pages.append(contentsOf: normalizedPages)
}
guard !pages.isEmpty else {
return nil
}
return RDEPUBTextBook(chapters: chapters, pages: pages)
}
private func stageBookRequest(
chapterStore: IncrementalChapterStore,
orderedSpineIndices: [Int],
restoreLocation: RDEPUBLocation?,
isComplete: Bool
) {
stagedBookRequestLock.lock()
stagedBookRequest = StagedBookRequest(
chapterStore: chapterStore,
orderedSpineIndices: orderedSpineIndices,
restoreLocation: restoreLocation,
isComplete: isComplete
)
stagedBookRequestLock.unlock()
}
private func consumeStagedBookRequest() -> StagedBookRequest? {
stagedBookRequestLock.lock()
defer { stagedBookRequestLock.unlock() }
let request = stagedBookRequest
stagedBookRequest = nil
return request
}
private func clearStagedBookRequest() {
stagedBookRequestLock.lock()
stagedBookRequest = nil
stagedBookRequestLock.unlock()
}
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
guard publication.spine.indices.contains(index) else { return false }
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
// MARK: - Phase 0
/// pageCountpageRangesfragmentOffsets
/// RDEPUBTextBook
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
let context = self.context
guard let parser = context.parser,
let publication = context.publication else { return }
let pageSize = context.currentTextPageSize()
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
let style = context.currentTextRenderStyle()
let allBuildableIndices = allBuildableSpineIndices(in: publication)
let summaryDiskCache = context.runtime?.summaryDiskCache
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self else { return }
guard context.controller != nil else { return }
let catalog = allBuildableIndices.map { spineIndex in
let item = publication.spine[spineIndex]
return (
key: context.chapterCacheKey(forSpineIndex: spineIndex),
spineIndex: spineIndex,
href: item.href,
title: item.title
)
}
let restored = summaryDiskCache?.readAll(keys: catalog)
RDEPUBBackgroundTrace.log(
"MetadataParse",
"begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count)"
)
var mapBuilder = restored?.mapBuilder ?? RDEPUBBookPageMap.Builder()
var lastAppliedCount = 0
let cachedSpineIndices = Set((restored?.summaries ?? [:]).keys)
if !cachedSpineIndices.isEmpty {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)"
)
let cachedMap = mapBuilder.build()
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(cachedMap)
}
}
for (offset, spineIndex) in allBuildableIndices.enumerated() {
guard context.controller != nil,
context.paginationToken == token else {
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
return
}
if cachedSpineIndices.contains(spineIndex) {
continue
}
self.waitForReadingInteractionToSettle(using: context)
do {
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(allBuildableIndices.count)")
let lightweightEntry = try RDEPUBBackgroundTrace.measure(
"MetadataParse",
"spine=\(spineIndex)"
) {
try autoreleasepool { () -> RDEPUBBookPageMapEntry? in
guard let result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
) else {
return nil
}
let chapter = result.chapter
let cacheKey = context.chapterCacheKey(forSpineIndex: spineIndex)
let summary = RDEPUBChapterSummary(
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: chapter.pages.map { .from($0.metadata) }
)
summaryDiskCache?.writeSynchronously(summary: summary, for: cacheKey)
return RDEPUBBookPageMapEntry(
spineIndex: spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
absolutePageStart: 0,
fragmentOffsets: chapter.fragmentOffsets
)
}
}
if let lightweightEntry {
mapBuilder.add(
spineIndex: lightweightEntry.spineIndex,
href: lightweightEntry.href,
title: lightweightEntry.title,
pageCount: lightweightEntry.pageCount,
fragmentOffsets: lightweightEntry.fragmentOffsets
)
let builtCount = offset + 1
if builtCount - lastAppliedCount >= 16 || builtCount == allBuildableIndices.count {
lastAppliedCount = builtCount
let partialMap = mapBuilder.build()
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(partialMap)
}
}
}
} catch {
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
}
}
let pageMap = mapBuilder.build()
RDEPUBBackgroundTrace.log(
"MetadataParse",
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages)"
)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(pageMap)
}
}
}
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
guard let summaryDiskCache = context.runtime?.summaryDiskCache else {
return nil
}
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
let item = publication.spine[spineIndex]
return (
key: context.chapterCacheKey(forSpineIndex: spineIndex),
spineIndex: spineIndex,
href: item.href,
title: item.title
)
}
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
return nil
}
let restored = summaryDiskCache.readAll(keys: catalog)
guard restored.summaries.count == catalog.count else {
return nil
}
return restored.mapBuilder.build()
}
}

View File

@ -6,6 +6,15 @@ import UIKit
final class RDEPUBReaderRuntime {
private unowned let context: RDEPUBReaderContext
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
lazy var summaryDiskCache = context.makeChapterSummaryDiskCache()
lazy var chapterLoader: RDEPUBChapterLoader = {
let loader = RDEPUBChapterLoader(context: context)
loader.setSummaryDiskCache(summaryDiskCache)
return loader
}()
lazy var pageResolver = RDEPUBPageResolver(context: context, store: chapterRuntimeStore)
lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
@ -42,9 +51,11 @@ final class RDEPUBReaderRuntime {
context.clearActiveSnapshot()
context.readingSession = nil
context.textBook = nil
context.bookPageMap = nil
context.activeBookmarks = []
context.activeHighlights = []
context.searchState = nil
clearOnDemandPageModeState()
viewportMonitor.resetForReload()
annotationCoordinator.updateCurrentSelection(nil)
readerView.reloadData()
@ -84,6 +95,17 @@ final class RDEPUBReaderRuntime {
return true
}
if context.bookPageMap != nil {
guard prepareOnDemandChapter(forAbsolutePageNumber: pageNumber) else {
return false
}
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
if let location = locationCoordinator.currentVisibleLocation() {
context.persist(location: location)
}
return true
}
guard context.activePages.indices.contains(pageNumber - 1) else {
return false
}
@ -282,6 +304,40 @@ final class RDEPUBReaderRuntime {
paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) {
context.textBook = nil
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
guard let readerView = context.readerView,
let controller = context.controller else { return }
let currentPage = max(readerView.currentPage, 0)
let currentLocation = locationCoordinator.currentVisibleLocation()
context.textBook = nil
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
//
readerView.reloadPageCountOnly()
//
if context.currentSelection == nil, bookPageMap.totalPages > 0 {
readerView.transitionToPage(
pageNum: min(currentPage, max(bookPageMap.totalPages - 1, 0)),
animated: false
)
}
if let currentLocation {
locationCoordinator.persist(location: currentLocation)
} else if let resolvedLocation = controller.resolvedTextLocation(forPageNumber: currentPage + 1) {
locationCoordinator.persist(location: resolvedLocation)
}
}
///
func finishPagination(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
@ -325,4 +381,174 @@ final class RDEPUBReaderRuntime {
) {
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
}
@discardableResult
func prepareOnDemandChapter(forAbsolutePageNumber pageNumber: Int) -> Bool {
guard let bookPageMap = context.bookPageMap,
let publication = context.publication else {
return false
}
let absolutePageIndex = pageNumber - 1
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
return false
}
chapterRuntimeStore.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: publication.spine.count
)
RDEPUBBackgroundTrace.log(
"Runtime",
"prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)"
)
if chapterRuntimeStore.chapterData(for: spineIndex) == nil {
do {
_ = try chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: chapterRuntimeStore
)
} catch {
RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)")
return false
}
}
for evictable in chapterRuntimeStore.evictableSpineIndices() {
chapterRuntimeStore.evict(spineIndex: evictable)
}
let adjacent = [spineIndex - 1, spineIndex + 1].filter { publication.spine.indices.contains($0) }
for adjacentSpineIndex in adjacent where chapterRuntimeStore.chapterData(for: adjacentSpineIndex) == nil {
chapterRuntimeStore.addPrefetchTarget(adjacentSpineIndex)
RDEPUBBackgroundTrace.log(
"Runtime",
"schedule prefetch currentSpine=\(spineIndex) adjacentSpine=\(adjacentSpineIndex)"
)
chapterLoader.loadChapter(
spineIndex: adjacentSpineIndex,
store: chapterRuntimeStore,
priority: .prefetch
) { _ in }
}
return true
}
func extendPartialBookPageMapIfNeeded(currentPageNumber: Int, minimumTrailingPages: Int = 2, batchChapterCount: Int = 3) {
guard let publication = context.publication,
let currentMap = context.bookPageMap,
let readerView = context.readerView else {
return
}
let buildableSpineIndices = publication.spine.indices.filter {
let item = publication.spine[$0]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
guard currentMap.totalChapters < buildableSpineIndices.count else {
return
}
guard currentMap.totalPages - currentPageNumber <= minimumTrailingPages else {
return
}
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
let nextSpineIndices = buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount)
guard !nextSpineIndices.isEmpty else {
return
}
RDEPUBBackgroundTrace.log(
"Runtime",
"extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(Array(nextSpineIndices))"
)
var appendedEntries: [RDEPUBBookPageMapEntry] = []
for spineIndex in nextSpineIndices {
do {
let chapter = try chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: chapterRuntimeStore
)
appendedEntries.append(
RDEPUBBookPageMapEntry(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
absolutePageStart: 0,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
)
} catch {
RDEPUBBackgroundTrace.log("Runtime", "extendPartialBookPageMap skip spine=\(spineIndex) error=\(error)")
}
}
guard !appendedEntries.isEmpty else {
return
}
let combinedEntries = (currentMap.entries.map {
RDEPUBBookPageMapEntry(
spineIndex: $0.spineIndex,
href: $0.href,
title: $0.title,
pageCount: $0.pageCount,
absolutePageStart: 0,
fragmentOffsets: $0.fragmentOffsets
)
} + appendedEntries).sorted { $0.spineIndex < $1.spineIndex }
var absolutePageStart = 0
let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in
let normalized = RDEPUBBookPageMapEntry(
spineIndex: entry.spineIndex,
href: entry.href,
title: entry.title,
pageCount: entry.pageCount,
absolutePageStart: absolutePageStart,
fragmentOffsets: entry.fragmentOffsets
)
absolutePageStart += entry.pageCount
return normalized
}
let newMap = RDEPUBBookPageMap(entries: normalizedEntries)
RDEPUBBackgroundTrace.log(
"Runtime",
"extendPartialBookPageMap applied chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
)
context.bookPageMap = newMap
context.replaceActiveSnapshot(makeSnapshot(from: newMap))
readerView.reloadData()
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
}
func clearOnDemandPageModeState() {
chapterRuntimeStore.invalidateAllForSettingsChange()
context.bookPageMap = nil
}
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
let pages = bookPageMap.entries.flatMap { entry in
(0..<entry.pageCount).map { localPageIndex in
EPUBPage(
spineIndex: entry.spineIndex,
chapterIndex: bookPageMap.chapterIndex(forSpineIndex: entry.spineIndex) ?? 0,
pageIndexInChapter: localPageIndex,
totalPagesInChapter: entry.pageCount,
chapterTitle: entry.title,
fixedSpread: nil
)
}
}
let chapters = bookPageMap.entries.map { entry in
EPUBChapterInfo(
spineIndex: entry.spineIndex,
title: entry.title,
pageCount: entry.pageCount
)
}
return (pages, chapters)
}
}

View File

@ -24,7 +24,7 @@ extension RDReaderView {
}
let contentViewClass = contentViews[identifier]
assert(contentViewClass != nil, "请调用register(contentViewcontentViewWithReuseIdentifier")
var contentView = contentViewClass!.init()
let contentView = contentViewClass!.init()
return contentView
}

View File

@ -742,6 +742,13 @@ public class RDReaderView: UIView {
bottomToolView = resolvedBottomChromeView()
}
///
public func reloadPageCountOnly() {
collectionView.reloadData()
topToolView = resolvedTopChromeView()
bottomToolView = resolvedBottomChromeView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")