diff --git a/docs/wiki/architecture.md b/docs/wiki/architecture.md index 17b7658..f9f1d8a 100644 --- a/docs/wiki/architecture.md +++ b/docs/wiki/architecture.md @@ -19,7 +19,8 @@ AppDelegate - `AppDelegate*.swift`: app lifecycle, menu, help, update UI. - `ReaderWindowController*.swift`: reader shell, document opening, navigation, search, AI integration, vocabulary, sessions. -- `DocumentLoading*.swift`: EPUB/DOCX archive handling, shared document helpers, and cancellable DOCX streaming preparation. Prepared DOCX output is cached by a content fingerprint, verified before reuse, and published atomically so a stale or interrupted load cannot replace valid content. +- `DocumentLoading*.swift` and `DOCXPreparedCache.swift`: EPUB/DOCX archive handling, shared document helpers, and cancellable DOCX streaming preparation. DOCX preparation extracts only rendering dependencies; prepared output is cached by a content fingerprint, verified before reuse, bounded by entry and byte quotas, and published atomically so concurrent, stale, or interrupted loads cannot replace valid content. +- `ReaderContentBackend.swift`: typed PDFKit and WebKit adapters used by shared navigation and zoom commands while preserving renderer-native behavior. - `ProcessRunner.swift`: bounded external process execution for archive helpers and other command-line runtimes. - `PDFDocumentTextSnapshot.swift`: cancellable PDF text extraction and a verified, content-addressed cache reused by whole-document AI indexing. Background extraction and index construction pause briefly during reader interaction. - `AIChatPanel*.swift`: AI chat UI, request lifecycle, bubble layout, selection handling. @@ -51,6 +52,8 @@ Reader selection and automatic background embedding work do not inspect Keychain - `mac-app/ReaderWindowController.swift` - `mac-app/ReaderWindowController+UI.swift` - `mac-app/DocumentLoading.swift` +- `mac-app/DOCXPreparedCache.swift` +- `mac-app/ReaderContentBackend.swift` - `mac-app/ProcessRunner.swift` - `mac-app/AIChatPanel.swift` - `mac-app/SpeechPlaybackCoordinator.swift` diff --git a/docs/wiki/code-map.md b/docs/wiki/code-map.md index f2c305b..45ed21d 100644 --- a/docs/wiki/code-map.md +++ b/docs/wiki/code-map.md @@ -4,9 +4,9 @@ Generated by `./scripts/generate_code_wiki.sh`. ## Summary -- Code files: 451 -- Main code lines: 62767 -- Swift app lines: 50849 +- Code files: 456 +- Main code lines: 63495 +- Swift app lines: 51375 - Full Swift type index: [Type Index](type-index.md) ## Largest Files @@ -42,6 +42,7 @@ Generated by `./scripts/generate_code_wiki.sh`. - `mac-app/ReaderWindowController+AIRetrieval.swift` - `mac-app/ReaderWindowController+AISourceAnnotations.swift` - `mac-app/ReaderWindowController+AIState.swift` +- `mac-app/ReaderWindowController+Backend.swift` - `mac-app/ReaderWindowController+ChromeUI.swift` - `mac-app/ReaderWindowController+Document.swift` - `mac-app/ReaderWindowController+DocumentLoading.swift` diff --git a/docs/wiki/development-tasks.md b/docs/wiki/development-tasks.md index 97fcb0e..cb16fbf 100644 --- a/docs/wiki/development-tasks.md +++ b/docs/wiki/development-tasks.md @@ -8,6 +8,7 @@ Start with: - `mac-app/PDFReaderView.swift` - `mac-app/PDFPagingPolicy.swift` +- `mac-app/ReaderContentBackend.swift` - `mac-app/ReaderWindowController+Navigation.swift` Run: @@ -21,6 +22,7 @@ Watch for: - Duplicate page turns after one scroll gesture. - Losing native PDFKit scroll or rubber-band behavior. - Thresholds that work for short pages but fail on long technical books. +- Bypassing the typed reader backend from shared navigation or zoom commands. ## Change PDF Text Indexing Or Vocabulary Marks @@ -60,6 +62,7 @@ Start with: - `mac-app/DocumentLoading+Archive.swift` - `mac-app/DocumentLoading+DOCXStreaming.swift` - `mac-app/DocumentLoading+DOCXCache.swift` +- `mac-app/DOCXPreparedCache.swift` - `mac-app/ReaderWindowController+DocumentLoading.swift` - `mac-app/Resources/reader-web-text.js` - `mac-app/Resources/reader-web-marks.js` @@ -78,6 +81,8 @@ Watch for: - A superseded EPUB or DOCX load mutating the current document or leaving temporary resources behind. - Reusing prepared DOCX output after the source bytes change, even when path, size, or timestamp are unchanged. - Publishing an incomplete DOCX cache entry after cancellation or extraction failure. +- Extracting unrelated DOCX package entries or letting prepared cache data exceed its byte quota. +- Letting concurrent DOCX builders replace a complete cache entry with partial output. - Forgetting to invalidate normalized Web text indexes after a DOM text mutation. - Relying on CSS Custom Highlight without retaining the DOM-span fallback required by older WebKit versions. - Starting PDF table-of-contents, cover, or persisted-mark restoration before the first visible reader update. diff --git a/docs/wiki/feature-map.md b/docs/wiki/feature-map.md index a391b13..f2b76f1 100644 --- a/docs/wiki/feature-map.md +++ b/docs/wiki/feature-map.md @@ -14,6 +14,7 @@ Use this page when the task starts from a product feature instead of a file name - `mac-app/PDFReaderView.swift`: PDFKit view subclass and edge paging events. - `mac-app/PDFPagingPolicy.swift`: page turn thresholds and duplicate-turn guard. +- `mac-app/ReaderContentBackend.swift`: typed PDFKit/WebKit navigation and zoom adapters. - `mac-app/ReaderWindowController+Navigation.swift`: page navigation commands. ## EPUB And DOCX Reading @@ -21,6 +22,8 @@ Use this page when the task starts from a product feature instead of a file name - `mac-app/DocumentLoading.swift`: shared document model and loader entry point. - `mac-app/DocumentLoading+EPUB.swift`: EPUB package, cover, TOC, and resources. - `mac-app/DocumentLoading+DOCX.swift`: DOCX paragraph, table, and media rendering. +- `mac-app/DocumentLoading+DOCXStreaming.swift`: cancellable streaming XML rendering. +- `mac-app/DocumentLoading+DOCXCache.swift` and `mac-app/DOCXPreparedCache.swift`: selective extraction, verified prepared output, concurrent publication, and bounded cache cleanup. - `mac-app/DocumentLoading+HTML.swift`: generated HTML wrapper and rewriting. - `mac-app/Resources/reader-web.js`: WebKit reader behavior, selection, and highlights. diff --git a/docs/wiki/type-index.md b/docs/wiki/type-index.md index 90bfb36..c424249 100644 --- a/docs/wiki/type-index.md +++ b/docs/wiki/type-index.md @@ -107,6 +107,8 @@ Generated by `./scripts/generate_code_wiki.sh`. | `mac-app/AppText.swift` | 3 | `struct ChatMessage` | | `mac-app/ArchiveSafetyValidator.swift` | 3 | `struct ArchiveSafetyPolicy {` | | `mac-app/ArchiveSafetyValidator.swift` | 40 | `enum ArchiveSafetyValidator {` | +| `mac-app/DOCXPreparedCache.swift` | 46 | `enum DOCXPreparedCache {` | +| `mac-app/DOCXPreparedCache.swift` | 4 | `struct DOCXPreparedCachePolicy {` | | `mac-app/DebouncedTask.swift` | 3 | `final class DebouncedTask {` | | `mac-app/DiagnosticsPanelController.swift` | 3 | `final class DiagnosticsPanelController` | | `mac-app/DiagnosticsPanelTheme.swift` | 3 | `struct DiagnosticsPanelTheme {` | @@ -119,10 +121,12 @@ Generated by `./scripts/generate_code_wiki.sh`. | `mac-app/DictionaryLookupService.swift` | 8 | `struct VocabularyDictionaryAnswer {` | | `mac-app/DocumentContentIdentity.swift` | 4 | `enum DocumentContentIdentity {` | | `mac-app/DocumentIdentity.swift` | 4 | `enum DocumentIdentity {` | +| `mac-app/DocumentLoadMeasurement.swift` | 13 | `struct DocumentLoadMeasurement` | +| `mac-app/DocumentLoadMeasurement.swift` | 3 | `enum DocumentLoadStage` | | `mac-app/DocumentLoading+Archive.swift` | 3 | `extension WebDocumentLoader {` | | `mac-app/DocumentLoading+DOCX.swift` | 11 | `extension WebDocumentLoader {` | | `mac-app/DocumentLoading+DOCX.swift` | 3 | `struct DOCXParagraph {` | -| `mac-app/DocumentLoading+DOCXCache.swift` | 236 | `extension WebDocumentLoader {` | +| `mac-app/DocumentLoading+DOCXCache.swift` | 3 | `extension WebDocumentLoader {` | | `mac-app/DocumentLoading+DOCXStreaming.swift` | 3 | `struct DOCXStreamingResult {` | | `mac-app/DocumentLoading+DOCXStreaming.swift` | 433 | `extension WebDocumentLoader {` | | `mac-app/DocumentLoading+DOCXStreaming.swift` | 9 | `enum DOCXMediaReferenceStyle {` | @@ -130,9 +134,9 @@ Generated by `./scripts/generate_code_wiki.sh`. | `mac-app/DocumentLoading+HTML.swift` | 3 | `extension WebDocumentLoader {` | | `mac-app/DocumentLoading.swift` | 26 | `struct WebReadableDocument {` | | `mac-app/DocumentLoading.swift` | 3 | `final class DocumentLoadCancellationToken {` | -| `mac-app/DocumentLoading.swift` | 60 | `struct ReaderTOCItem {` | -| `mac-app/DocumentLoading.swift` | 66 | `struct HTMLBodyFragment {` | -| `mac-app/DocumentLoading.swift` | 72 | `enum WebDocumentLoader {` | +| `mac-app/DocumentLoading.swift` | 63 | `struct ReaderTOCItem {` | +| `mac-app/DocumentLoading.swift` | 69 | `struct HTMLBodyFragment {` | +| `mac-app/DocumentLoading.swift` | 75 | `enum WebDocumentLoader {` | | `mac-app/DocumentOpenPanelConfiguration.swift` | 4 | `enum DocumentOpenPanelConfiguration {` | | `mac-app/DocumentQuestionPromptRequest.swift` | 3 | `struct DocumentQuestionPromptRequest {` | | `mac-app/ECDICTDictionary.swift` | 16 | `struct ECDICTDiagnosticInfo {` | @@ -249,6 +253,16 @@ Generated by `./scripts/generate_code_wiki.sh`. | `mac-app/ReaderChromeViews.swift` | 3 | `final class FlippedStackView` | | `mac-app/ReaderChromeViews.swift` | 426 | `final class PassthroughOverlayView` | | `mac-app/ReaderChromeViews.swift` | 7 | `final class GradientButton` | +| `mac-app/ReaderContentBackend.swift` | 10 | `enum ReaderZoomStep` | +| `mac-app/ReaderContentBackend.swift` | 139 | `final class WebKitReaderAdapter` | +| `mac-app/ReaderContentBackend.swift` | 15 | `enum ReaderPageScrollDirection` | +| `mac-app/ReaderContentBackend.swift` | 20 | `enum ReaderPagePlacement` | +| `mac-app/ReaderContentBackend.swift` | 25 | `struct ReaderPagedViewportAnchor` | +| `mac-app/ReaderContentBackend.swift` | 30 | `protocol ReaderContentBackend` | +| `mac-app/ReaderContentBackend.swift` | 40 | `protocol ReaderPagedBackend` | +| `mac-app/ReaderContentBackend.swift` | 49 | `protocol ReaderContinuousBackend` | +| `mac-app/ReaderContentBackend.swift` | 55 | `final class PDFKitReaderAdapter` | +| `mac-app/ReaderContentBackend.swift` | 5 | `enum ReaderContentBackendKind` | | `mac-app/ReaderDocumentImportCoordinator.swift` | 3 | `enum ReaderDocumentImportCoordinator {` | | `mac-app/ReaderDocumentKind.swift` | 3 | `enum ReaderDocumentKind {` | | `mac-app/ReaderDocumentState.swift` | 4 | `struct ReaderDocumentState {` | @@ -279,6 +293,7 @@ Generated by `./scripts/generate_code_wiki.sh`. | `mac-app/ReaderWindowController+AIRetrieval.swift` | 3 | `extension ReaderWindowController {` | | `mac-app/ReaderWindowController+AISourceAnnotations.swift` | 4 | `extension ReaderWindowController {` | | `mac-app/ReaderWindowController+AIState.swift` | 3 | `extension ReaderWindowController {` | +| `mac-app/ReaderWindowController+Backend.swift` | 1 | `extension ReaderWindowController {` | | `mac-app/ReaderWindowController+ChromeUI.swift` | 3 | `extension ReaderWindowController {` | | `mac-app/ReaderWindowController+Document.swift` | 3 | `extension ReaderWindowController {` | | `mac-app/ReaderWindowController+DocumentLoading.swift` | 11 | `extension ReaderWindowController {` | diff --git a/mac-app/DOCXPreparedCache.swift b/mac-app/DOCXPreparedCache.swift new file mode 100644 index 0000000..d4d07d2 --- /dev/null +++ b/mac-app/DOCXPreparedCache.swift @@ -0,0 +1,336 @@ +import CryptoKit +import Foundation + +struct DOCXPreparedCachePolicy { + let maximumBytes: Int64 + let maximumEntries: Int + + init(maximumBytes: Int64 = 512 * 1_024 * 1_024, maximumEntries: Int = 10) { + self.maximumBytes = maximumBytes + self.maximumEntries = maximumEntries + } +} + +private struct DOCXPreparedTOCItem: Codable { + let title: String + let href: String + let level: Int + + init(_ item: ReaderTOCItem) { + title = item.title + href = item.href + level = item.level + } + + var readerItem: ReaderTOCItem { + ReaderTOCItem(title: title, href: href, level: level) + } +} + +private struct DOCXPreparedMediaFile: Codable { + let path: String + let bytes: Int64 +} + +private struct DOCXPreparedManifest: Codable { + let schemaVersion: Int + let fingerprint: String + let title: String + let entryBytes: Int64 + let htmlSHA256: String + let plainTextSHA256: String + let tocSHA256: String + let media: [DOCXPreparedMediaFile] +} + +enum DOCXPreparedCache { + static let schemaVersion = 2 + static let manifestName = "manifest.json" + static let htmlName = "rendered.html" + static let plainTextName = "plain-text.txt" + static let tocName = "toc.json" + + static func root(override: URL? = nil) throws -> URL { + let root: URL + if let override { + root = override + } else if let path = ProcessInfo.processInfo.environment["LEAFREADER_DOCX_CACHE_ROOT"], + !path.isEmpty { + root = URL(fileURLWithPath: path, isDirectory: true) + } else { + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + root = caches + .appendingPathComponent("LeafReader", isDirectory: true) + .appendingPathComponent("DOCXPreparedCache", isDirectory: true) + } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + static func key(fingerprint: String, title: String) -> String { + digest(Data("\(schemaVersion)\u{0}\(fingerprint)\u{0}\(title)".utf8)) + } + + static func selectedArchiveEntries(from entries: [String]) throws -> [String] { + for path in entries { + let components = path.split(separator: "/", omittingEmptySubsequences: false) + guard !path.contains("\\"), + !path.contains("\0"), + !path.hasPrefix("/"), + !components.contains(".."), + EPUBPathResolver.safeArchivePath(path) != nil else { + throw cacheError(AppText.localized( + "DOCX 压缩包包含不安全路径。", + "The DOCX archive contains an unsafe path: \(path)" + ), code: -3) + } + } + guard entries.contains("word/document.xml") else { + throw cacheError(AppText.localized( + "DOCX 中缺少 word/document.xml。", + "The DOCX archive has no word/document.xml entry." + ), code: -2) + } + return entries.filter { + $0 == "word/document.xml" + || $0 == "word/_rels/document.xml.rels" + || ($0.hasPrefix("word/media/") && !$0.hasSuffix("/")) + } + } + + static func load( + directory: URL, + fingerprint: String, + title: String, + ownedResource: OwnedTemporaryResource? = nil, + measurements: [DocumentLoadMeasurement] = [] + ) throws -> WebReadableDocument { + let manifestURL = directory.appendingPathComponent(manifestName) + let htmlURL = directory.appendingPathComponent(htmlName) + let plainTextURL = directory.appendingPathComponent(plainTextName) + let tocURL = directory.appendingPathComponent(tocName) + let manifestData = try Data(contentsOf: manifestURL, options: .mappedIfSafe) + let manifest = try JSONDecoder().decode(DOCXPreparedManifest.self, from: manifestData) + guard manifest.schemaVersion == schemaVersion, + manifest.fingerprint == fingerprint, + manifest.title == title, + digest(htmlURL) == manifest.htmlSHA256, + digest(plainTextURL) == manifest.plainTextSHA256, + digest(tocURL) == manifest.tocSHA256 else { + throw cacheError(AppText.localized( + "DOCX 缓存数据无效。", + "The prepared DOCX cache entry is invalid." + )) + } + let fixedBytes = fileSize(htmlURL) + fileSize(plainTextURL) + fileSize(tocURL) + guard fixedBytes >= 0 else { + throw cacheError(AppText.localized("DOCX 缓存文件缺失。", "A prepared DOCX cache file is missing.")) + } + var mediaBytes: Int64 = 0 + for media in manifest.media { + guard let safePath = EPUBPathResolver.safeArchivePath(media.path), + safePath == media.path, + fileSize(directory.appendingPathComponent(safePath)) == media.bytes else { + throw cacheError(AppText.localized( + "DOCX 缓存媒体文件无效。", + "A prepared DOCX media file is invalid." + )) + } + mediaBytes += media.bytes + } + guard manifest.entryBytes == fixedBytes + mediaBytes + Int64(manifestData.count) else { + throw cacheError(AppText.localized("DOCX 缓存大小无效。", "The prepared DOCX cache size is invalid.")) + } + let plainText = try String(contentsOf: plainTextURL, encoding: .utf8) + let toc = try JSONDecoder().decode( + [DOCXPreparedTOCItem].self, + from: Data(contentsOf: tocURL, options: .mappedIfSafe) + ).map(\.readerItem) + try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: directory.path) + return WebReadableDocument( + html: "", + htmlFileURL: htmlURL, + baseURL: directory, + plainText: plainText, + plainTextLoader: nil, + coverImageURL: nil, + tocItems: toc, + diagnostics: [], + ownedResource: ownedResource, + loadMeasurements: measurements + ) + } + + static func write( + directory: URL, + fingerprint: String, + title: String, + content: DOCXStreamingResult + ) throws -> Int64 { + let htmlURL = directory.appendingPathComponent(htmlName) + let plainTextURL = directory.appendingPathComponent(plainTextName) + let tocURL = directory.appendingPathComponent(tocName) + let body = content.html.isEmpty + ? "

\(WebDocumentLoader.escapeHTML(AppText.localized("无法读取 DOCX 内容。", "Unable to read DOCX content.")))

" + : content.html + let html = WebDocumentLoader.pageHTML( + title: title, + body: body, + documentStyles: WebDocumentLoader.docxReaderStyles, + profile: .docx + ) + try Data(html.utf8).write(to: htmlURL, options: .atomic) + try Data(content.plainText.joined(separator: "\n\n").utf8).write(to: plainTextURL, options: .atomic) + try JSONEncoder().encode(content.tocItems.map(DOCXPreparedTOCItem.init)).write(to: tocURL, options: .atomic) + + let media = try mediaFiles(in: directory) + let contentBytes = fileSize(htmlURL) + fileSize(plainTextURL) + fileSize(tocURL) + + media.reduce(0) { $0 + $1.bytes } + guard contentBytes >= 0 else { + throw cacheError(AppText.localized("无法计算 DOCX 缓存大小。", "Unable to size the prepared DOCX cache.")) + } + let base = DOCXPreparedManifest( + schemaVersion: schemaVersion, + fingerprint: fingerprint, + title: title, + entryBytes: contentBytes, + htmlSHA256: try requiredDigest(htmlURL), + plainTextSHA256: try requiredDigest(plainTextURL), + tocSHA256: try requiredDigest(tocURL), + media: media + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + var manifest = base + var data = try encoder.encode(manifest) + for _ in 0..<5 { + let exactBytes = contentBytes + Int64(data.count) + guard exactBytes != manifest.entryBytes else { break } + manifest = DOCXPreparedManifest( + schemaVersion: base.schemaVersion, + fingerprint: base.fingerprint, + title: base.title, + entryBytes: exactBytes, + htmlSHA256: base.htmlSHA256, + plainTextSHA256: base.plainTextSHA256, + tocSHA256: base.tocSHA256, + media: base.media + ) + data = try encoder.encode(manifest) + } + try data.write(to: directory.appendingPathComponent(manifestName), options: .atomic) + return manifest.entryBytes + } + + static func cleanup(root: URL, keeping key: String, policy: DOCXPreparedCachePolicy) { + guard var entries = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ).filter({ $0.lastPathComponent != key }) else { return } + entries.sort { + let left = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + let right = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + return left < right + } + let current = root.appendingPathComponent(key, isDirectory: true) + var totalEntries = FileManager.default.fileExists(atPath: current.path) ? 1 : 0 + var totalBytes = entryBytes(at: current) + for entry in entries { + totalEntries += 1 + totalBytes += entryBytes(at: entry) + } + while (totalEntries > policy.maximumEntries || totalBytes > policy.maximumBytes), !entries.isEmpty { + let victim = entries.removeFirst() + let bytes = entryBytes(at: victim) + if (try? FileManager.default.removeItem(at: victim)) != nil { + totalEntries -= 1 + totalBytes -= bytes + } + } + } + + static func fingerprint(url: URL, cancellationToken: DocumentLoadCancellationToken?) throws -> String { + let handle = try FileHandle(forReadingFrom: url.standardizedFileURL) + defer { try? handle.close() } + var hasher = SHA256() + while true { + try cancellationToken?.checkCancellation() + let data = handle.readData(ofLength: 1_048_576) + if data.isEmpty { break } + hasher.update(data: data) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + private static func entryBytes(at directory: URL) -> Int64 { + let manifestURL = directory.appendingPathComponent(manifestName) + if let data = try? Data(contentsOf: manifestURL), + let manifest = try? JSONDecoder().decode(DOCXPreparedManifest.self, from: data), + manifest.entryBytes > 0 { + return manifest.entryBytes + } + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { return 0 } + var bytes: Int64 = 0 + for case let file as URL in enumerator { + let values = try? file.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + if values?.isRegularFile == true { + bytes += Int64(values?.fileSize ?? 0) + } + } + return bytes + } + + private static func mediaFiles(in directory: URL) throws -> [DOCXPreparedMediaFile] { + let mediaRoot = directory.appendingPathComponent("word/media", isDirectory: true) + guard FileManager.default.fileExists(atPath: mediaRoot.path) else { return [] } + guard let enumerator = FileManager.default.enumerator( + at: mediaRoot, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { return [] } + var files: [DOCXPreparedMediaFile] = [] + for case let file as URL in enumerator { + let values = try file.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true else { continue } + let path = EPUBPathResolver.relativeFilePath(from: directory, to: file) + guard EPUBPathResolver.safeArchivePath(path) == path else { + throw cacheError(AppText.localized("DOCX 媒体路径不安全。", "A prepared DOCX media path is unsafe."), code: -3) + } + files.append(DOCXPreparedMediaFile(path: path, bytes: Int64(values.fileSize ?? 0))) + } + return files.sorted { $0.path < $1.path } + } + + private static func fileSize(_ url: URL) -> Int64 { + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values?.isRegularFile == true else { return -1 } + return Int64(values?.fileSize ?? -1) + } + + private static func requiredDigest(_ url: URL) throws -> String { + let value = digest(url) + guard !value.isEmpty else { + throw cacheError(AppText.localized("无法校验 DOCX 缓存。", "Unable to fingerprint a prepared DOCX file.")) + } + return value + } + + private static func digest(_ url: URL) -> String { + guard let data = try? Data(contentsOf: url, options: .mappedIfSafe) else { return "" } + return digest(data) + } + + private static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func cacheError(_ description: String, code: Int = -4) -> Error { + NSError(domain: "LeafReader", code: code, userInfo: [NSLocalizedDescriptionKey: description]) + } +} diff --git a/mac-app/DocumentLoadMeasurement.swift b/mac-app/DocumentLoadMeasurement.swift new file mode 100644 index 0000000..1e8cd06 --- /dev/null +++ b/mac-app/DocumentLoadMeasurement.swift @@ -0,0 +1,16 @@ +import Foundation + +enum DocumentLoadStage: String, Codable { + case docxFingerprint + case docxCacheLookup + case docxArchiveExtraction + case docxRelationshipParse + case docxXMLRender + case docxCacheCommit + case docxCacheHitLoad +} + +struct DocumentLoadMeasurement: Equatable { + let stage: DocumentLoadStage + let milliseconds: Double +} diff --git a/mac-app/DocumentLoading+Archive.swift b/mac-app/DocumentLoading+Archive.swift index a6a3927..605c65a 100644 --- a/mac-app/DocumentLoading+Archive.swift +++ b/mac-app/DocumentLoading+Archive.swift @@ -141,6 +141,77 @@ extension WebDocumentLoader { try ArchiveSafetyValidator.validateExtractedTree(at: destination, policy: .document) } + static func zipEntryPaths( + in url: URL, + cancellationToken: DocumentLoadCancellationToken? = nil + ) throws -> [String] { + try cancellationToken?.checkCancellation() + try ArchiveSafetyValidator.validateZIP(at: url) + let result = try ProcessRunner.run( + executableURL: URL(fileURLWithPath: "/usr/bin/unzip"), + arguments: ["-Z1", url.path], + timeout: archiveProcessTimeout, + isCancelled: { cancellationToken?.isCancelled == true } + ) + if result.wasCancelled { + throw CancellationError() + } + guard !result.timedOut, result.terminationStatus == 0 else { + throw NSError(domain: "LeafReader", code: Int(result.terminationStatus), userInfo: [ + NSLocalizedDescriptionKey: archiveProcessErrorMessage( + prefix: "Unable to inspect \(url.lastPathComponent)", + stderr: result.stderr + ) + ]) + } + guard let output = String(data: result.stdout, encoding: .utf8) else { + throw NSError(domain: "LeafReader", code: -2, userInfo: [ + NSLocalizedDescriptionKey: "Unable to decode the archive directory for \(url.lastPathComponent)." + ]) + } + try cancellationToken?.checkCancellation() + return output.split(whereSeparator: \.isNewline).map(String.init) + } + + static func unzip( + url: URL, + to destination: URL, + entryPaths: [String], + cancellationToken: DocumentLoadCancellationToken? = nil + ) throws { + guard !entryPaths.isEmpty else { + throw NSError(domain: "LeafReader", code: -2, userInfo: [ + NSLocalizedDescriptionKey: "The document archive has no readable entries." + ]) + } + try cancellationToken?.checkCancellation() + try ArchiveSafetyValidator.validateZIP(at: url) + let result = try ProcessRunner.run( + executableURL: URL(fileURLWithPath: "/usr/bin/unzip"), + arguments: ["-qq", "-o", url.path] + entryPaths + ["-d", destination.path], + timeout: archiveProcessTimeout, + isCancelled: { cancellationToken?.isCancelled == true } + ) + if result.wasCancelled { + throw CancellationError() + } + guard !result.timedOut else { + throw NSError(domain: "LeafReader", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Unable to unpack \(url.lastPathComponent): unzip timed out." + ]) + } + guard result.terminationStatus == 0 else { + throw NSError(domain: "LeafReader", code: Int(result.terminationStatus), userInfo: [ + NSLocalizedDescriptionKey: archiveProcessErrorMessage( + prefix: "Unable to unpack \(url.lastPathComponent)", + stderr: result.stderr + ) + ]) + } + try cancellationToken?.checkCancellation() + try ArchiveSafetyValidator.validateExtractedTree(at: destination, policy: .document) + } + static func zipEntryData(in url: URL, entryPath: String) throws -> Data? { guard let entryPath = EPUBPathResolver.safeArchivePath(entryPath) else { return nil } let result = try ProcessRunner.run( diff --git a/mac-app/DocumentLoading+DOCXCache.swift b/mac-app/DocumentLoading+DOCXCache.swift index 2127d08..c8c9f2b 100644 --- a/mac-app/DocumentLoading+DOCXCache.swift +++ b/mac-app/DocumentLoading+DOCXCache.swift @@ -1,288 +1,228 @@ -import CryptoKit import Foundation -private struct DOCXPreparedTOCItem: Codable { - let title: String - let href: String - let level: Int - - init(_ item: ReaderTOCItem) { - title = item.title - href = item.href - level = item.level - } - - var readerItem: ReaderTOCItem { - ReaderTOCItem(title: title, href: href, level: level) - } -} - -private struct DOCXPreparedMediaFile: Codable { - let path: String - let bytes: Int64 -} - -private struct DOCXPreparedManifest: Codable { - let schemaVersion: Int - let fingerprint: String - let title: String - let htmlSHA256: String - let plainTextSHA256: String - let tocSHA256: String - let media: [DOCXPreparedMediaFile] -} - -private enum DOCXPreparedCache { - static let schemaVersion = 1 - static let maximumEntries = 10 - static let lock = NSLock() - static let manifestName = "manifest.json" - static let htmlName = "rendered.html" - static let plainTextName = "plain-text.txt" - static let tocName = "toc.json" - - static func root() throws -> URL { - if let override = ProcessInfo.processInfo.environment["LEAFREADER_DOCX_CACHE_ROOT"], - !override.isEmpty { - let root = URL(fileURLWithPath: override, isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - return root - } - let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first - ?? FileManager.default.temporaryDirectory - let root = caches - .appendingPathComponent("LeafReader", isDirectory: true) - .appendingPathComponent("DOCXPreparedCache", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - return root - } - - static func key(fingerprint: String, title: String) -> String { - digest(Data("\(schemaVersion)\u{0}\(fingerprint)\u{0}\(title)".utf8)) - } - - static func load(directory: URL, fingerprint: String, title: String) throws -> WebReadableDocument { - let manifestURL = directory.appendingPathComponent(manifestName) - let htmlURL = directory.appendingPathComponent(htmlName) - let plainTextURL = directory.appendingPathComponent(plainTextName) - let tocURL = directory.appendingPathComponent(tocName) - let manifest = try JSONDecoder().decode( - DOCXPreparedManifest.self, - from: Data(contentsOf: manifestURL, options: .mappedIfSafe) - ) - guard manifest.schemaVersion == schemaVersion, - manifest.fingerprint == fingerprint, - manifest.title == title, - digest(htmlURL) == manifest.htmlSHA256, - digest(plainTextURL) == manifest.plainTextSHA256, - digest(tocURL) == manifest.tocSHA256 else { - throw cacheError(AppText.localized("DOCX 缓存数据无效。", "The prepared DOCX cache entry is invalid.")) - } - for media in manifest.media { - guard let safePath = EPUBPathResolver.safeArchivePath(media.path), - safePath == media.path, - fileSize(directory.appendingPathComponent(safePath)) == media.bytes else { - throw cacheError(AppText.localized("DOCX 缓存媒体文件无效。", "A prepared DOCX media file is invalid.")) - } - } - let plainText = try String(contentsOf: plainTextURL, encoding: .utf8) - let toc = try JSONDecoder().decode( - [DOCXPreparedTOCItem].self, - from: Data(contentsOf: tocURL, options: .mappedIfSafe) - ).map(\.readerItem) - try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: directory.path) - return WebReadableDocument( - html: "", - htmlFileURL: htmlURL, - baseURL: directory, - plainText: plainText, - plainTextLoader: nil, - coverImageURL: nil, - tocItems: toc, - diagnostics: [], - ownedResource: nil - ) - } - - static func prepare( - sourceURL: URL, - directory: URL, - fingerprint: String, - title: String, - cancellationToken: DocumentLoadCancellationToken? - ) throws -> WebReadableDocument { - try cancellationToken?.checkCancellation() - try WebDocumentLoader.unzip( - url: sourceURL, - to: directory, - cancellationToken: cancellationToken - ) - try cancellationToken?.checkCancellation() - let relationships = try WebDocumentLoader.docxStreamingRelationships( - from: directory.appendingPathComponent("word/_rels/document.xml.rels"), - cancellationToken: cancellationToken - ) - let content = try WebDocumentLoader.docxStreamingContent( - from: directory.appendingPathComponent("word/document.xml"), - directory: directory, - relationships: relationships, - mediaReferenceStyle: .relativeToPreparedEntry, - cancellationToken: cancellationToken - ) - try cancellationToken?.checkCancellation() - let body = content.html.isEmpty - ? "

\(WebDocumentLoader.escapeHTML(AppText.localized("无法读取 DOCX 内容。", "Unable to read DOCX content.")))

" - : content.html - let html = WebDocumentLoader.pageHTML( - title: title, - body: body, - documentStyles: WebDocumentLoader.docxReaderStyles, - profile: .docx - ) - let plainText = content.plainText.joined(separator: "\n\n") - let toc = content.tocItems.map(DOCXPreparedTOCItem.init) - let htmlURL = directory.appendingPathComponent(htmlName) - let plainTextURL = directory.appendingPathComponent(plainTextName) - let tocURL = directory.appendingPathComponent(tocName) - try Data(html.utf8).write(to: htmlURL, options: .atomic) - try Data(plainText.utf8).write(to: plainTextURL, options: .atomic) - try JSONEncoder().encode(toc).write(to: tocURL, options: .atomic) - let manifest = DOCXPreparedManifest( - schemaVersion: schemaVersion, - fingerprint: fingerprint, - title: title, - htmlSHA256: digest(htmlURL), - plainTextSHA256: digest(plainTextURL), - tocSHA256: digest(tocURL), - media: mediaFiles(in: directory) - ) - try JSONEncoder().encode(manifest).write( - to: directory.appendingPathComponent(manifestName), - options: .atomic - ) - return try load(directory: directory, fingerprint: fingerprint, title: title) - } - - static func cleanup(root: URL, keeping key: String) { - guard let entries = try? FileManager.default.contentsOfDirectory( - at: root, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] - ), entries.count > maximumEntries else { return } - let stale = entries - .filter { $0.lastPathComponent != key } - .sorted { - let left = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - let right = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast - return left < right - } - .prefix(entries.count - maximumEntries) - for entry in stale { - try? FileManager.default.removeItem(at: entry) - } - } - - static func fingerprint(url: URL, cancellationToken: DocumentLoadCancellationToken?) throws -> String { - let handle = try FileHandle(forReadingFrom: url.standardizedFileURL) - defer { try? handle.close() } - var hasher = SHA256() - while true { - try cancellationToken?.checkCancellation() - let data = handle.readData(ofLength: 1_048_576) - if data.isEmpty { break } - hasher.update(data: data) - } - return hasher.finalize().map { String(format: "%02x", $0) }.joined() - } - - static func digest(_ url: URL) -> String { - guard let data = try? Data(contentsOf: url, options: .mappedIfSafe) else { return "" } - return digest(data) - } - - static func digest(_ data: Data) -> String { - SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() - } - - static func mediaFiles(in directory: URL) -> [DOCXPreparedMediaFile] { - let mediaRoot = directory.appendingPathComponent("word/media", isDirectory: true) - guard let enumerator = FileManager.default.enumerator( - at: mediaRoot, - includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], - options: [.skipsHiddenFiles] - ) else { return [] } - return enumerator.compactMap { item in - guard let url = item as? URL, - let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), - values.isRegularFile == true else { return nil } - let prefix = directory.standardizedFileURL.path + "/" - guard url.standardizedFileURL.path.hasPrefix(prefix) else { return nil } - let path = String(url.standardizedFileURL.path.dropFirst(prefix.count)) - return DOCXPreparedMediaFile(path: path, bytes: Int64(values.fileSize ?? 0)) - } - } - - static func fileSize(_ url: URL) -> Int64 { - let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) - guard values?.isRegularFile == true else { return -1 } - return Int64(values?.fileSize ?? -1) +extension WebDocumentLoader { + static func validatedDOCXArchiveEntries(_ entries: [String]) throws -> [String] { + try DOCXPreparedCache.selectedArchiveEntries(from: entries) } - static func cacheError(_ description: String) -> Error { - NSError(domain: "LeafReader", code: -4, userInfo: [NSLocalizedDescriptionKey: description]) - } -} - -extension WebDocumentLoader { static func loadPreparedDOCX( url: URL, - cancellationToken: DocumentLoadCancellationToken? + cacheRootURL: URL? = nil, + policy: DOCXPreparedCachePolicy = DOCXPreparedCachePolicy(), + cancellationToken: DocumentLoadCancellationToken? = nil ) throws -> WebReadableDocument { + try cancellationToken?.checkCancellation() let title = url.deletingPathExtension().lastPathComponent + var measurements: [DocumentLoadMeasurement] = [] + + var startedAt = ProcessInfo.processInfo.systemUptime let fingerprint = try DOCXPreparedCache.fingerprint(url: url, cancellationToken: cancellationToken) - let root = try DOCXPreparedCache.root() + measurements.append(measurement(.docxFingerprint, since: startedAt)) + + let root: URL + do { + root = try DOCXPreparedCache.root(override: cacheRootURL) + } catch { + return try loadUncachedStreamingDOCX( + url: url, + measurements: measurements, + cancellationToken: cancellationToken + ) + } let key = DOCXPreparedCache.key(fingerprint: fingerprint, title: title) let destination = root.appendingPathComponent(key, isDirectory: true) - DOCXPreparedCache.lock.lock() - defer { DOCXPreparedCache.lock.unlock() } - try cancellationToken?.checkCancellation() + startedAt = ProcessInfo.processInfo.systemUptime if FileManager.default.fileExists(atPath: destination.path) { do { - let document = try DOCXPreparedCache.load( + try cancellationToken?.checkCancellation() + var hitMeasurements = measurements + hitMeasurements.append(measurement(.docxCacheLookup, since: startedAt)) + let loadStartedAt = ProcessInfo.processInfo.systemUptime + var document = try DOCXPreparedCache.load( directory: destination, fingerprint: fingerprint, - title: title + title: title, + measurements: hitMeasurements ) - DOCXPreparedCache.cleanup(root: root, keeping: key) + document.loadMeasurements.append(measurement(.docxCacheHitLoad, since: loadStartedAt)) + try cancellationToken?.checkCancellation() + DOCXPreparedCache.cleanup(root: root, keeping: key, policy: policy) return document } catch { + if error is CancellationError { throw error } try? FileManager.default.removeItem(at: destination) } } + measurements.append(measurement(.docxCacheLookup, since: startedAt)) - let temporary = root.appendingPathComponent("\(key)-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true) + let temporary = root.appendingPathComponent( + ".building-\(key)-\(UUID().uuidString)", + isDirectory: true + ) do { - _ = try DOCXPreparedCache.prepare( - sourceURL: url, + try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true) + try cancellationToken?.checkCancellation() + startedAt = ProcessInfo.processInfo.systemUptime + let archiveEntries = try zipEntryPaths(in: url, cancellationToken: cancellationToken) + let selectedEntries = try validatedDOCXArchiveEntries(archiveEntries) + try unzip( + url: url, + to: temporary, + entryPaths: selectedEntries, + cancellationToken: cancellationToken + ) + measurements.append(measurement(.docxArchiveExtraction, since: startedAt)) + + startedAt = ProcessInfo.processInfo.systemUptime + let relationships = try docxStreamingRelationships( + from: temporary.appendingPathComponent("word/_rels/document.xml.rels"), + cancellationToken: cancellationToken + ) + measurements.append(measurement(.docxRelationshipParse, since: startedAt)) + + startedAt = ProcessInfo.processInfo.systemUptime + let content = try docxStreamingContent( + from: temporary.appendingPathComponent("word/document.xml"), + directory: temporary, + relationships: relationships, + mediaReferenceStyle: .relativeToPreparedEntry, + cancellationToken: cancellationToken + ) + measurements.append(measurement(.docxXMLRender, since: startedAt)) + try? FileManager.default.removeItem(at: temporary.appendingPathComponent("word/document.xml")) + try? FileManager.default.removeItem(at: temporary.appendingPathComponent("word/_rels", isDirectory: true)) + try cancellationToken?.checkCancellation() + + startedAt = ProcessInfo.processInfo.systemUptime + let entryBytes = try DOCXPreparedCache.write( directory: temporary, fingerprint: fingerprint, title: title, - cancellationToken: cancellationToken + content: content ) try cancellationToken?.checkCancellation() - try FileManager.default.moveItem(at: temporary, to: destination) + measurements.append(measurement(.docxCacheCommit, since: startedAt)) + if entryBytes > policy.maximumBytes || policy.maximumEntries < 1 { + let owner = OwnedTemporaryResource(url: temporary) + return try DOCXPreparedCache.load( + directory: temporary, + fingerprint: fingerprint, + title: title, + ownedResource: owner, + measurements: measurements + ) + } + + do { + try FileManager.default.moveItem(at: temporary, to: destination) + } catch { + if FileManager.default.fileExists(atPath: destination.path), + let winner = try? DOCXPreparedCache.load( + directory: destination, + fingerprint: fingerprint, + title: title, + measurements: measurements + ) { + try cancellationToken?.checkCancellation() + try? FileManager.default.removeItem(at: temporary) + DOCXPreparedCache.cleanup(root: root, keeping: key, policy: policy) + return winner + } + throw error + } + DOCXPreparedCache.cleanup(root: root, keeping: key, policy: policy) let document = try DOCXPreparedCache.load( directory: destination, fingerprint: fingerprint, - title: title + title: title, + measurements: measurements ) - DOCXPreparedCache.cleanup(root: root, keeping: key) + try cancellationToken?.checkCancellation() return document } catch { try? FileManager.default.removeItem(at: temporary) + if error is CancellationError { throw error } + if (error as NSError).domain == "LeafReader.UnsafeArchive" { throw error } + return try loadUncachedStreamingDOCX( + url: url, + measurements: measurements, + cancellationToken: cancellationToken + ) + } + } + + private static func loadUncachedStreamingDOCX( + url: URL, + measurements initialMeasurements: [DocumentLoadMeasurement], + cancellationToken: DocumentLoadCancellationToken? + ) throws -> WebReadableDocument { + var measurements = initialMeasurements + var startedAt = ProcessInfo.processInfo.systemUptime + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LeafReader-DOCX-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let owner = OwnedTemporaryResource(url: directory) + do { + try cancellationToken?.checkCancellation() + let archiveEntries = try zipEntryPaths(in: url, cancellationToken: cancellationToken) + let selectedEntries = try validatedDOCXArchiveEntries(archiveEntries) + try unzip( + url: url, + to: directory, + entryPaths: selectedEntries, + cancellationToken: cancellationToken + ) + measurements.append(measurement(.docxArchiveExtraction, since: startedAt)) + + startedAt = ProcessInfo.processInfo.systemUptime + let relationships = try docxStreamingRelationships( + from: directory.appendingPathComponent("word/_rels/document.xml.rels"), + cancellationToken: cancellationToken + ) + measurements.append(measurement(.docxRelationshipParse, since: startedAt)) + + startedAt = ProcessInfo.processInfo.systemUptime + let content = try docxStreamingContent( + from: directory.appendingPathComponent("word/document.xml"), + directory: directory, + relationships: relationships, + cancellationToken: cancellationToken + ) + measurements.append(measurement(.docxXMLRender, since: startedAt)) + let title = url.deletingPathExtension().lastPathComponent + return WebReadableDocument( + html: pageHTML( + title: title, + body: content.html.isEmpty + ? "

\(escapeHTML(AppText.localized("无法读取 DOCX 内容。", "Unable to read DOCX content.")))

" + : content.html, + documentStyles: docxReaderStyles, + profile: .docx + ), + htmlFileURL: nil, + baseURL: directory, + plainText: content.plainText.joined(separator: "\n\n"), + plainTextLoader: nil, + coverImageURL: nil, + tocItems: content.tocItems, + diagnostics: [], + ownedResource: owner, + loadMeasurements: measurements + ) + } catch { + owner.release() throw error } } + + private static func measurement( + _ stage: DocumentLoadStage, + since startedAt: TimeInterval + ) -> DocumentLoadMeasurement { + DocumentLoadMeasurement( + stage: stage, + milliseconds: (ProcessInfo.processInfo.systemUptime - startedAt) * 1_000 + ) + } } diff --git a/mac-app/DocumentLoading.swift b/mac-app/DocumentLoading.swift index 1994858..9aca885 100644 --- a/mac-app/DocumentLoading.swift +++ b/mac-app/DocumentLoading.swift @@ -33,6 +33,7 @@ struct WebReadableDocument { let tocItems: [ReaderTOCItem] let diagnostics: [String] let ownedResource: OwnedTemporaryResource? + var loadMeasurements: [DocumentLoadMeasurement] init( html: String, @@ -43,7 +44,8 @@ struct WebReadableDocument { coverImageURL: URL?, tocItems: [ReaderTOCItem], diagnostics: [String], - ownedResource: OwnedTemporaryResource? = nil + ownedResource: OwnedTemporaryResource? = nil, + loadMeasurements: [DocumentLoadMeasurement] = [] ) { self.html = html self.htmlFileURL = htmlFileURL @@ -54,6 +56,7 @@ struct WebReadableDocument { self.tocItems = tocItems self.diagnostics = diagnostics self.ownedResource = ownedResource + self.loadMeasurements = loadMeasurements } } diff --git a/mac-app/ReaderContentBackend.swift b/mac-app/ReaderContentBackend.swift new file mode 100644 index 0000000..e074db0 --- /dev/null +++ b/mac-app/ReaderContentBackend.swift @@ -0,0 +1,215 @@ +import CoreGraphics +import PDFKit +import WebKit + +enum ReaderContentBackendKind: Equatable { + case pdf + case web +} + +enum ReaderZoomStep: Equatable { + case increment + case decrement +} + +enum ReaderPageScrollDirection: Equatable { + case previous + case next +} + +enum ReaderPagePlacement: Equatable { + case top + case bottom +} + +struct ReaderPagedViewportAnchor: Equatable { + let pageIndex: Int + let point: CGPoint +} + +protocol ReaderContentBackend: AnyObject { + var kind: ReaderContentBackendKind { get } + var zoomPercent: Int? { get } + + func focus() + func clearSelection() + @discardableResult func setZoomPercent(_ percent: Int) -> Int? + @discardableResult func stepZoom(_ step: ReaderZoomStep) -> Int? +} + +protocol ReaderPagedBackend: ReaderContentBackend { + var currentPageIndex: Int? { get } + var pageCount: Int { get } + var viewportAnchor: ReaderPagedViewportAnchor? { get } + + @discardableResult func navigate(toPage index: Int, placement: ReaderPagePlacement) -> Bool + @discardableResult func restoreViewportAnchor(_ anchor: ReaderPagedViewportAnchor) -> Bool +} + +protocol ReaderContinuousBackend: ReaderContentBackend { + func scrollByPage(_ direction: ReaderPageScrollDirection) + func scrollToCover() + func scroll(toProgress progress: Double, animated: Bool) +} + +final class PDFKitReaderAdapter: ReaderPagedBackend { + private weak var view: PDFView? + + init(view: PDFView) { + self.view = view + } + + let kind: ReaderContentBackendKind = .pdf + + var currentPageIndex: Int? { + guard let view, let document = view.document, let page = view.currentPage else { return nil } + let index = document.index(for: page) + return index == NSNotFound ? nil : index + } + + var pageCount: Int { + view?.document?.pageCount ?? 0 + } + + var viewportAnchor: ReaderPagedViewportAnchor? { + guard let view, let document = view.document, document.pageCount > 0 else { return nil } + let anchorInView = CGPoint( + x: view.bounds.midX, + y: max(view.bounds.minY, view.bounds.maxY - ReaderSessionPolicy.pdfViewportAnchorTopInset) + ) + guard let page = view.page(for: anchorInView, nearest: true) ?? view.currentPage else { return nil } + let pageIndex = document.index(for: page) + guard pageIndex != NSNotFound else { return nil } + return ReaderPagedViewportAnchor(pageIndex: pageIndex, point: view.convert(anchorInView, to: page)) + } + + var zoomPercent: Int? { + view.map { Int(round($0.scaleFactor * 100)) } + } + + func focus() { + guard let view else { return } + view.window?.makeFirstResponder(view) + } + + func clearSelection() { + view?.clearSelection() + } + + @discardableResult + func setZoomPercent(_ percent: Int) -> Int? { + guard let view else { return nil } + view.autoScales = false + view.scaleFactor = min(max(CGFloat(percent) / 100, 0.1), 8) + return zoomPercent + } + + @discardableResult + func stepZoom(_ step: ReaderZoomStep) -> Int? { + guard let view else { return nil } + let multiplier: CGFloat = step == .increment ? 1.25 : 0.8 + view.autoScales = false + view.scaleFactor = min(max(view.scaleFactor * multiplier, 0.1), 8) + return zoomPercent + } + + @discardableResult + func navigate(toPage index: Int, placement: ReaderPagePlacement) -> Bool { + guard let view, let page = view.document?.page(at: index) else { return false } + DispatchQueue.main.async { [weak view] in + guard let view, view.document?.index(for: page) != NSNotFound else { return } + let bounds = page.bounds(for: view.displayBox) + let destinationY = placement == .top ? bounds.maxY : bounds.minY + view.go(to: PDFDestination(page: page, at: CGPoint(x: bounds.minX, y: destinationY))) + } + return true + } + + @discardableResult + func restoreViewportAnchor(_ anchor: ReaderPagedViewportAnchor) -> Bool { + guard let view, let page = view.document?.page(at: anchor.pageIndex) else { return false } + DispatchQueue.main.async { [weak view] in + guard let view, view.document?.index(for: page) != NSNotFound else { return } + view.go(to: PDFDestination(page: page, at: anchor.point)) + } + return true + } +} + +final class WebKitReaderAdapter: ReaderContinuousBackend { + private weak var view: WKWebView? + private var appliedZoomPercent: Int + + init(view: WKWebView, initialZoomPercent: Int = 100) { + self.view = view + appliedZoomPercent = initialZoomPercent + } + + let kind: ReaderContentBackendKind = .web + var zoomPercent: Int? { appliedZoomPercent } + + func focus() { + guard let view else { return } + view.window?.makeFirstResponder(view) + } + + func clearSelection() { + view?.evaluateJavaScript("window.leafReaderClearSelection && window.leafReaderClearSelection();") + } + + @discardableResult + func setZoomPercent(_ percent: Int) -> Int? { + let clamped = min(max(percent, 60), 220) + appliedZoomPercent = clamped + guard let view else { return nil } + view.pageZoom = 1 + view.evaluateJavaScript(""" + document.documentElement.style.setProperty('--reader-zoom', '\(Double(clamped) / 100)'); + """) + return clamped + } + + @discardableResult + func stepZoom(_ step: ReaderZoomStep) -> Int? { + setZoomPercent(appliedZoomPercent + (step == .increment ? 10 : -10)) + } + + func scrollByPage(_ direction: ReaderPageScrollDirection) { + let sign = direction == .previous ? "-" : "" + view?.evaluateJavaScript( + "window.scrollBy({top: \(sign)Math.max(240, window.innerHeight * 0.86), behavior: 'smooth'});" + ) + } + + func scrollToCover() { + view?.evaluateJavaScript(""" + (() => { + const cover = document.querySelector('section.reader-section[data-leaf-cover="true"]') || document.querySelector('section.reader-section'); + if (cover) { + cover.scrollIntoView({behavior:'smooth', block:'start'}); + } else { + window.scrollTo({top:0, behavior:'smooth'}); + } + })(); + """) + } + + func scroll(toProgress progress: Double, animated: Bool) { + let clampedProgress = min(1, max(0, progress)) + let behavior = animated ? "smooth" : "auto" + view?.evaluateJavaScript(""" + (() => { + const progress = \(clampedProgress); + const scroll = () => { + const scrollHeight = Math.max(1, document.documentElement.scrollHeight - window.innerHeight); + window.scrollTo({ top: scrollHeight * progress, behavior: '\(behavior)' }); + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => requestAnimationFrame(scroll), { once: true }); + } else { + requestAnimationFrame(() => requestAnimationFrame(scroll)); + } + })(); + """) + } +} diff --git a/mac-app/ReaderWindowController+Backend.swift b/mac-app/ReaderWindowController+Backend.swift new file mode 100644 index 0000000..67dfcdf --- /dev/null +++ b/mac-app/ReaderWindowController+Backend.swift @@ -0,0 +1,13 @@ +extension ReaderWindowController { + var activeReaderBackend: ReaderContentBackend? { + currentDocumentKind == .pdf ? pdfReaderBackend : webReaderBackend + } + + var activePagedReaderBackend: ReaderPagedBackend? { + activeReaderBackend as? ReaderPagedBackend + } + + var activeContinuousReaderBackend: ReaderContinuousBackend? { + activeReaderBackend as? ReaderContinuousBackend + } +} diff --git a/mac-app/ReaderWindowController+Navigation.swift b/mac-app/ReaderWindowController+Navigation.swift index 9b47683..5e3a5ce 100644 --- a/mac-app/ReaderWindowController+Navigation.swift +++ b/mac-app/ReaderWindowController+Navigation.swift @@ -1,12 +1,12 @@ -import PDFKit +import Foundation extension ReaderWindowController { @objc func applyPageFromField() { guard currentDocumentKind == .pdf, - let document = pdfView.document, - document.pageCount > 0 else { + let backend = activePagedReaderBackend, + backend.pageCount > 0 else { updatePageLabel() - window?.makeFirstResponder(currentDocumentKind == .pdf ? pdfView : webView) + activeReaderBackend?.focus() return } @@ -19,24 +19,22 @@ extension ReaderWindowController { } guard let requestedPage = Int(pageNumberText) else { updatePageLabel() - window?.makeFirstResponder(pdfView) + backend.focus() return } - let targetIndex = min(max(requestedPage, 1), document.pageCount) - 1 - guard let page = document.page(at: targetIndex) else { + let targetIndex = min(max(requestedPage, 1), backend.pageCount) - 1 + guard backend.navigate(toPage: targetIndex, placement: .top) else { updatePageLabel() - window?.makeFirstResponder(pdfView) + backend.focus() return } clearAISelectionForNavigation() - pdfView.go(to: page) lastPageIndex = targetIndex - scrollPageToTop(page) updatePageLabel() saveSession() - window?.makeFirstResponder(pdfView) + backend.focus() } @objc func prevPage() { @@ -63,28 +61,16 @@ extension ReaderWindowController { } func scrollWebPage(direction: Int) { - let sign = direction < 0 ? "-" : "" - webView.evaluateJavaScript("window.scrollBy({top: \(sign)Math.max(240, window.innerHeight * 0.86), behavior: 'smooth'});") + activeContinuousReaderBackend?.scrollByPage(direction < 0 ? .previous : .next) } @objc func goToCover() { clearAISelectionForNavigation() guard currentDocumentKind == .pdf else { - webView.evaluateJavaScript(""" - (() => { - const cover = document.querySelector('section.reader-section[data-leaf-cover="true"]') || document.querySelector('section.reader-section'); - if (cover) { - cover.scrollIntoView({behavior:'smooth', block:'start'}); - } else { - window.scrollTo({top:0, behavior:'smooth'}); - } - })(); - """) + activeContinuousReaderBackend?.scrollToCover() return } - guard let firstPage = pdfView.document?.page(at: 0) else { return } - pdfView.go(to: firstPage) - scrollPageToTop(firstPage) + guard activePagedReaderBackend?.navigate(toPage: 0, placement: .top) == true else { return } updatePageLabel() saveSession() } @@ -102,23 +88,23 @@ extension ReaderWindowController { jumpToWebProgress(storedProgress?.scrollProgress ?? webScrollProgress, animated: true) return } - guard let document = pdfView.document, document.pageCount > 0 else { return } + guard let backend = activePagedReaderBackend, backend.pageCount > 0 else { return } let storedProgress = sessionStore.loadFarthestPDFProgress() - let targetIndex = min(max(storedProgress?.pageIndex ?? currentPageIndex() ?? 0, 0), document.pageCount - 1) - guard let page = document.page(at: targetIndex) else { return } - pdfView.go(to: page) + let targetIndex = min(max(storedProgress?.pageIndex ?? backend.currentPageIndex ?? 0, 0), backend.pageCount - 1) lastPageIndex = targetIndex if let storedProgress, ReaderSessionPolicy.isRestorablePDFScale(storedProgress.scale) { applyReadablePDFScale(storedProgress.scale) } if let anchorPoint = storedProgress?.anchorPoint { - restorePDFViewportAnchor(page: page, point: anchorPoint) + guard backend.restoreViewportAnchor( + ReaderPagedViewportAnchor(pageIndex: targetIndex, point: anchorPoint) + ) else { return } } else { - scrollPageToTop(page) + guard backend.navigate(toPage: targetIndex, placement: .top) else { return } } updatePageLabel() saveSession() - window?.makeFirstResponder(pdfView) + backend.focus() } func jumpToWebProgress(_ progressValue: Double, animated: Bool) { @@ -127,26 +113,11 @@ extension ReaderWindowController { updateWebProgressLabel(progress) scrollWebToProgress(progress, animated: animated) saveSession() - window?.makeFirstResponder(webView) + activeContinuousReaderBackend?.focus() } func scrollWebToProgress(_ progress: Double, animated: Bool) { - let behavior = animated ? "smooth" : "auto" - let script = """ - (() => { - const progress = \(progress); - const scroll = () => { - const scrollHeight = Math.max(1, document.documentElement.scrollHeight - window.innerHeight); - window.scrollTo({ top: scrollHeight * progress, behavior: '\(behavior)' }); - }; - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => requestAnimationFrame(scroll), { once: true }); - } else { - requestAnimationFrame(() => requestAnimationFrame(scroll)); - } - })(); - """ - webView.evaluateJavaScript(script) + activeContinuousReaderBackend?.scroll(toProgress: progress, animated: animated) } @@ -165,12 +136,12 @@ extension ReaderWindowController { direction: EdgePagingPDFView.ScrollPageDirection, targetPlacement: PDFPageNavigationPlacement ) { - guard let document = pdfView.document, document.pageCount > 0 else { return } + guard let backend = activePagedReaderBackend, backend.pageCount > 0 else { return } let readingMode = currentPDFReadingMode() let currentIndex = PDFPagingPolicy.navigationPageIndex( readingMode: readingMode, - viewportPageIndex: currentPDFViewportAnchor()?.pageIndex, - currentPageIndex: currentPageIndex() + viewportPageIndex: backend.viewportAnchor?.pageIndex, + currentPageIndex: backend.currentPageIndex ) ?? 0 let targetIndex: Int switch direction { @@ -180,42 +151,20 @@ extension ReaderWindowController { targetIndex = currentIndex + 1 } guard targetIndex >= 0, - targetIndex < document.pageCount, - let page = document.page(at: targetIndex) else { + targetIndex < backend.pageCount, + backend.navigate( + toPage: targetIndex, + placement: targetPlacement == .top ? .top : .bottom + ) else { updatePageLabel() saveSession() return } - scrollPage(page, to: targetPlacement) lastPageIndex = targetIndex updatePageLabel() saveSession() } - func scrollPageToTop(_ page: PDFPage) { - scrollPage(page, to: .top) - } - - private func scrollPage(_ page: PDFPage, to placement: PDFPageNavigationPlacement) { - DispatchQueue.main.async { [weak self] in - guard let self = self, - self.pdfView.document?.index(for: page) != NSNotFound else { - return - } - let bounds = page.bounds(for: self.pdfView.displayBox) - let destinationY: CGFloat - switch placement { - case .top: - destinationY = bounds.maxY - case .bottom: - destinationY = bounds.minY - } - let destination = PDFDestination(page: page, at: NSPoint(x: bounds.minX, y: destinationY)) - self.pdfView.go(to: destination) - self.updatePageLabel() - } - } - @objc func toggleFullScreen() { window?.toggleFullScreen(nil) } diff --git a/mac-app/ReaderWindowController+Zoom.swift b/mac-app/ReaderWindowController+Zoom.swift index b5c2da2..35c35cb 100644 --- a/mac-app/ReaderWindowController+Zoom.swift +++ b/mac-app/ReaderWindowController+Zoom.swift @@ -3,72 +3,53 @@ import Cocoa extension ReaderWindowController { @objc func zoomIn() { markReaderInteraction() - guard currentDocumentKind == .pdf else { - setWebZoom(webZoomPercent + 10) - return - } - pdfView.autoScales = false - pdfView.scaleFactor = min(pdfView.scaleFactor * 1.25, 8) + guard let applied = activeReaderBackend?.stepZoom(.increment) else { return } + syncZoomPercentFromBackend(applied) updateZoomLabel() saveSession() } @objc func zoomOut() { markReaderInteraction() - guard currentDocumentKind == .pdf else { - setWebZoom(webZoomPercent - 10) - return - } - pdfView.autoScales = false - pdfView.scaleFactor = max(pdfView.scaleFactor * 0.8, 0.1) + guard let applied = activeReaderBackend?.stepZoom(.decrement) else { return } + syncZoomPercentFromBackend(applied) updateZoomLabel() saveSession() } @objc func applyZoomFromField() { markReaderInteraction() - guard currentDocumentKind == .pdf else { - let raw = zoomField.stringValue - .replacingOccurrences(of: "%", with: "") - .replacingOccurrences(of: "%", with: "") - .replacingOccurrences(of: ",", with: "") - .trimmingCharacters(in: .whitespacesAndNewlines) - guard let percent = Int(raw), percent > 0 else { - updateZoomLabel() - return - } - setWebZoom(percent) - return - } let raw = zoomField.stringValue .replacingOccurrences(of: "%", with: "") .replacingOccurrences(of: "%", with: "") .replacingOccurrences(of: ",", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) - guard let percent = Double(raw), percent > 0 else { + guard let percent = Double(raw), percent > 0, + let applied = activeReaderBackend?.setZoomPercent(Int(percent.rounded())) else { updateZoomLabel() return } - pdfView.autoScales = false - pdfView.scaleFactor = min(max(percent, 10), 800) / 100 + syncZoomPercentFromBackend(applied) updateZoomLabel() saveSession() - window?.makeFirstResponder(currentDocumentKind == .pdf ? pdfView : webView) + activeReaderBackend?.focus() } func setWebZoom(_ percent: Int) { - webZoomPercent = min(max(percent, 60), 220) + guard let applied = webReaderBackend.setZoomPercent(percent) else { return } + webZoomPercent = applied zoomField.stringValue = "\(webZoomPercent)%" - applyWebZoomToPage() saveSession() - window?.makeFirstResponder(webView) + webReaderBackend.focus() } func applyWebZoomToPage() { - guard webView != nil else { return } - webView.pageZoom = 1 - webView.evaluateJavaScript(""" - document.documentElement.style.setProperty('--reader-zoom', '\(Double(webZoomPercent) / 100)'); - """) + _ = webReaderBackend.setZoomPercent(webZoomPercent) + } + + private func syncZoomPercentFromBackend(_ percent: Int) { + guard currentDocumentKind != .pdf else { return } + webZoomPercent = percent + zoomField.stringValue = "\(webZoomPercent)%" } } diff --git a/mac-app/ReaderWindowController.swift b/mac-app/ReaderWindowController.swift index 8fbf994..81b8ae6 100644 --- a/mac-app/ReaderWindowController.swift +++ b/mac-app/ReaderWindowController.swift @@ -54,6 +54,8 @@ final class ReaderWindowController: NSWindowController, NSWindowDelegate, PDFVie var pdfView: EdgePagingPDFView! var webView: ReaderWebView! + lazy var pdfReaderBackend = PDFKitReaderAdapter(view: pdfView) + lazy var webReaderBackend = WebKitReaderAdapter(view: webView) let contentArea = NSView() let pdfContainer = ClippingView() let pdfDimOverlay = PassthroughOverlayView() diff --git a/tests/DOCXStreamingParserTests.swift b/tests/DOCXStreamingParserTests.swift index efe05c6..00ac564 100644 --- a/tests/DOCXStreamingParserTests.swift +++ b/tests/DOCXStreamingParserTests.swift @@ -36,6 +36,9 @@ private func writeFixture(root: URL, title: String) throws -> URL { to: relationshipsDirectory.appendingPathComponent("document.xml.rels") ) try Data([0x89, 0x50, 0x4E, 0x47]).write(to: mediaDirectory.appendingPathComponent("image 1.png")) + let docPropsDirectory = root.appendingPathComponent("docProps", isDirectory: true) + try FileManager.default.createDirectory(at: docPropsDirectory, withIntermediateDirectories: true) + try Data("not needed by the reader".utf8).write(to: docPropsDirectory.appendingPathComponent("unused.txt")) return wordDirectory.appendingPathComponent("document.xml") } @@ -44,12 +47,35 @@ private func makeArchive(from root: URL, at archiveURL: URL) throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/zip") process.currentDirectoryURL = root - process.arguments = ["-q", "-r", archiveURL.path, "word"] + process.arguments = ["-q", "-r", archiveURL.path, "word", "docProps"] try process.run() process.waitUntilExit() assert(process.terminationStatus == 0, "fixture archive creation should succeed") } +private func cacheEntries(in root: URL) throws -> [URL] { + guard FileManager.default.fileExists(atPath: root.path) else { return [] } + return try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) + .filter { !$0.lastPathComponent.hasPrefix(".") } +} + +private final class ConcurrentDOCXResults: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Result] = [] + + func append(_ result: Result) { + lock.lock() + storage.append(result) + lock.unlock() + } + + func snapshot() -> [Result] { + lock.lock() + defer { lock.unlock() } + return storage + } +} + @main private struct DOCXStreamingParserTestRunner { static func main() throws { @@ -87,9 +113,21 @@ private struct DOCXStreamingParserTestRunner { assert(first.plainText.contains("First title"), "prepared DOCX should expose plain text") let firstHTML = try String(contentsOf: firstHTMLURL, encoding: .utf8) assert(firstHTML.contains("First title"), "prepared HTML should contain rendered content") + assert( + !FileManager.default.fileExists(atPath: first.baseURL.appendingPathComponent("docProps/unused.txt").path), + "prepared DOCX entries should not extract unrelated archive content" + ) + assert( + first.loadMeasurements.contains { $0.stage == .docxXMLRender }, + "a cache miss should record its rendering stage" + ) let second = try WebDocumentLoader.loadDOCX(url: archiveURL) assert(second.htmlFileURL == firstHTMLURL, "unchanged DOCX content should reuse its prepared cache") + assert( + second.loadMeasurements.contains { $0.stage == .docxCacheHitLoad }, + "a cache hit should record its cache-load stage" + ) try Data("tampered".utf8).write(to: firstHTMLURL) let repaired = try WebDocumentLoader.loadDOCX(url: archiveURL) @@ -115,6 +153,78 @@ private struct DOCXStreamingParserTestRunner { // Expected. } + do { + _ = try WebDocumentLoader.validatedDOCXArchiveEntries([ + "word/document.xml", + "../escape.txt" + ]) + assert(false, "unsafe DOCX archive paths should be rejected") + } catch { + // Expected. + } + let selected = try WebDocumentLoader.validatedDOCXArchiveEntries([ + "word/document.xml", + "word/_rels/document.xml.rels", + "word/media/image 1.png", + "docProps/unused.txt" + ]) + assert( + selected == ["word/document.xml", "word/_rels/document.xml.rels", "word/media/image 1.png"], + "DOCX extraction should select only rendering dependencies" + ) + + let quotaCache = root.appendingPathComponent("quota-cache", isDirectory: true) + let transient = try WebDocumentLoader.loadPreparedDOCX( + url: archiveURL, + cacheRootURL: quotaCache, + policy: DOCXPreparedCachePolicy(maximumBytes: 1, maximumEntries: 10) + ) + assert(transient.ownedResource != nil, "an oversized prepared entry should have an explicit temporary owner") + let quotaEntries = try cacheEntries(in: quotaCache) + assert(quotaEntries.isEmpty, "an oversized prepared entry should not enter the persistent cache") + let transientDirectory = transient.baseURL + transient.ownedResource?.release() + assert( + !FileManager.default.fileExists(atPath: transientDirectory.path), + "releasing an oversized prepared entry should remove its temporary directory" + ) + + let evictionCache = root.appendingPathComponent("eviction-cache", isDirectory: true) + let renamedArchive = root.appendingPathComponent("renamed.docx") + try FileManager.default.copyItem(at: archiveURL, to: renamedArchive) + let oneEntryPolicy = DOCXPreparedCachePolicy(maximumBytes: 512 * 1_024 * 1_024, maximumEntries: 1) + _ = try WebDocumentLoader.loadPreparedDOCX( + url: archiveURL, + cacheRootURL: evictionCache, + policy: oneEntryPolicy + ) + _ = try WebDocumentLoader.loadPreparedDOCX( + url: renamedArchive, + cacheRootURL: evictionCache, + policy: oneEntryPolicy + ) + let evictionEntries = try cacheEntries(in: evictionCache) + assert(evictionEntries.count == 1, "cache cleanup should enforce the entry quota") + + let concurrentCache = root.appendingPathComponent("concurrent-cache", isDirectory: true) + let concurrentResults = ConcurrentDOCXResults() + let group = DispatchGroup() + for _ in 0..<2 { + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + concurrentResults.append(Result { + try WebDocumentLoader.loadPreparedDOCX(url: archiveURL, cacheRootURL: concurrentCache) + }) + group.leave() + } + } + assert(group.wait(timeout: .now() + 20) == .success, "concurrent DOCX preparation should finish") + let documents = try concurrentResults.snapshot().map { try $0.get() } + assert(documents.count == 2, "both concurrent DOCX callers should receive a result") + assert(documents[0].plainText == documents[1].plainText, "concurrent DOCX results should agree") + let concurrentEntries = try cacheEntries(in: concurrentCache) + assert(concurrentEntries.count == 1, "concurrent builders should converge on one cache entry") + print("DOCXStreamingParserTests passed") } } diff --git a/tests/ReaderContentBackendTests.swift b/tests/ReaderContentBackendTests.swift new file mode 100644 index 0000000..917922f --- /dev/null +++ b/tests/ReaderContentBackendTests.swift @@ -0,0 +1,82 @@ +import CoreGraphics +import Foundation + +private func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + guard condition() else { + fputs("ReaderContentBackendTests failed: \(message)\n", stderr) + exit(1) + } +} + +private final class FakePagedReaderBackend: ReaderPagedBackend { + let kind: ReaderContentBackendKind = .pdf + let pageCount = 3 + private(set) var currentPageIndex: Int? = 0 + private(set) var zoomPercent: Int? = 100 + private(set) var didFocus = false + private(set) var didClearSelection = false + private(set) var lastPlacement: ReaderPagePlacement? + private(set) var restoredAnchor: ReaderPagedViewportAnchor? + + var viewportAnchor: ReaderPagedViewportAnchor? { + currentPageIndex.map { ReaderPagedViewportAnchor(pageIndex: $0, point: .zero) } + } + + func focus() { + didFocus = true + } + + func clearSelection() { + didClearSelection = true + } + + func setZoomPercent(_ percent: Int) -> Int? { + zoomPercent = min(max(percent, 10), 800) + return zoomPercent + } + + func stepZoom(_ step: ReaderZoomStep) -> Int? { + setZoomPercent((zoomPercent ?? 100) + (step == .increment ? 10 : -10)) + } + + func navigate(toPage index: Int, placement: ReaderPagePlacement) -> Bool { + guard (0.. Bool { + guard (0..